Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dca4bd5a20 | ||
|
|
eb57666033 | ||
|
|
2c4123214d | ||
|
|
8cd6c0aac8 | ||
|
|
1757ba5e36 | ||
|
|
c34c4e1923 | ||
|
|
b127495e52 | ||
|
|
76aa7bb4aa | ||
|
|
8e444f7434 | ||
|
|
83afcb6deb | ||
|
|
30bfe0313f | ||
|
|
0f3b26991e | ||
|
|
e190b5958b | ||
|
|
6e2899dbfd | ||
|
|
48b6b9be22 | ||
|
|
202b0f5e41 | ||
|
|
7b9e630838 | ||
|
|
109ec3dad0 | ||
|
|
f2a005e008 | ||
|
|
60502784db | ||
|
|
05cfdc3662 | ||
|
|
010add828b | ||
|
|
aca19a53fb | ||
|
|
e64dcec094 | ||
|
|
43c75c8e36 |
@@ -0,0 +1,282 @@
|
|||||||
|
# AI 重新修图接口优化需求
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
图片预览页面支持用户在“原图”Tab 上重新选择修图模板。
|
||||||
|
|
||||||
|
用户可能只生成过一种关联图片,例如之前仅生成了“原图精修”,尚未生成“氛围感”图片。此时用户重新修图时,可以同时选择:
|
||||||
|
|
||||||
|
- 原图精修模板
|
||||||
|
- 氛围感修图模板
|
||||||
|
|
||||||
|
客户端会调用现有 `ai-reretouch` 接口,并通过 `type = 3` 提交两个模板,希望一次创建两个修图输出任务。
|
||||||
|
|
||||||
|
## 2. 问题复现
|
||||||
|
|
||||||
|
### 前置状态
|
||||||
|
|
||||||
|
- 原图素材 ID:`702`
|
||||||
|
- 原 AI 修图批次 ID:`61`
|
||||||
|
- 已存在并完成:原图精修
|
||||||
|
- 从未生成:氛围感图片
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/yf-handset-app/photog/travel-album/ai-reretouch
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ai_retouch_batch_id": 61,
|
||||||
|
"id": 702,
|
||||||
|
"type": 3,
|
||||||
|
"refined_template_id": 2,
|
||||||
|
"atmosphere_template_id": 14
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段含义:
|
||||||
|
|
||||||
|
| 字段 | 值 | 说明 |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| `ai_retouch_batch_id` | `61` | 原 AI 修图批次 ID |
|
||||||
|
| `id` | `702` | 原图素材 ID |
|
||||||
|
| `type` | `3` | 同时处理精修和氛围感 |
|
||||||
|
| `refined_template_id` | `2` | 本次选择的精修模板 |
|
||||||
|
| `atmosphere_template_id` | `14` | 本次选择的氛围感模板 |
|
||||||
|
|
||||||
|
### 当前响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100099,
|
||||||
|
"data": [],
|
||||||
|
"msg": "仅已完成修图的照片可重新修图",
|
||||||
|
"time": "2026-08-14 17:41:17"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 当前问题
|
||||||
|
|
||||||
|
从响应判断,后端可能在 `type = 3` 时要求精修图和氛围感图都已经存在且完成,才允许重新修图。
|
||||||
|
|
||||||
|
但本场景中:
|
||||||
|
|
||||||
|
- 精修图已经存在,本次需要重新生成,并在成功后覆盖旧精修图。
|
||||||
|
- 氛围感图从未生成,本次需要创建新的关联图片。
|
||||||
|
|
||||||
|
由于氛围感图历史上不存在,接口拒绝了整个请求,导致客户端无法通过一次请求同时完成“覆盖已有精修图”和“新增氛围感图”。
|
||||||
|
|
||||||
|
客户端当前请求已经正确传递 `type = 3` 和两个模板 ID,无需修改请求结构。
|
||||||
|
|
||||||
|
## 4. 期望接口行为
|
||||||
|
|
||||||
|
建议将 `ai-reretouch` 调整为按输出类型分别执行的 **upsert(存在则覆盖,不存在则新增)** 语义。
|
||||||
|
|
||||||
|
对于本次请求:
|
||||||
|
|
||||||
|
### 原图精修
|
||||||
|
|
||||||
|
- 请求传入了 `refined_template_id`。
|
||||||
|
- 旧精修图已经存在。
|
||||||
|
- 后端创建新的精修任务。
|
||||||
|
- 新任务成功后覆盖旧精修图。
|
||||||
|
- 新任务处理中或失败时保留旧精修图。
|
||||||
|
|
||||||
|
### 氛围感修图
|
||||||
|
|
||||||
|
- 请求传入了 `atmosphere_template_id`。
|
||||||
|
- 旧氛围感图不存在。
|
||||||
|
- 后端创建新的氛围感任务。
|
||||||
|
- 任务成功后,将结果保存为原图的新关联图片。
|
||||||
|
|
||||||
|
### 整体任务
|
||||||
|
|
||||||
|
- 一次请求创建两个输出任务:`refined` 和 `atmosphere`。
|
||||||
|
- 按两个实际创建的输出预占 2 张修图额度。
|
||||||
|
- 两个输出分别执行、分别记录状态、分别结算额度。
|
||||||
|
- 单个输出失败不应影响另一个已经成功的输出。
|
||||||
|
|
||||||
|
## 5. 建议的后端处理规则
|
||||||
|
|
||||||
|
### 5.1 基础校验
|
||||||
|
|
||||||
|
建议校验:
|
||||||
|
|
||||||
|
- 原图素材存在。
|
||||||
|
- 原图素材属于指定相册或批次。
|
||||||
|
- 原图当前状态允许提交 AI 修图任务。
|
||||||
|
- 请求中至少传入一个与 `type` 匹配的有效模板 ID。
|
||||||
|
- 用户剩余额度满足本次实际创建的输出数量。
|
||||||
|
|
||||||
|
不建议校验:
|
||||||
|
|
||||||
|
- `type = 3` 时要求精修和氛围感历史结果都必须存在。
|
||||||
|
- 某一种关联图从未生成时直接拒绝整个请求。
|
||||||
|
|
||||||
|
### 5.2 按模板字段创建任务
|
||||||
|
|
||||||
|
后端根据本次实际传入的模板字段创建对应任务:
|
||||||
|
|
||||||
|
| 请求情况 | 期望处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| 只传 `refined_template_id` | 只创建精修任务 |
|
||||||
|
| 只传 `atmosphere_template_id` | 只创建氛围感任务 |
|
||||||
|
| 两个模板 ID 都传 | 同时创建精修和氛围感任务 |
|
||||||
|
| 某个模板 ID 未传 | 不处理、不覆盖该类型的已有结果 |
|
||||||
|
|
||||||
|
### 5.3 按输出类型处理已有结果
|
||||||
|
|
||||||
|
对于每个实际提交的输出类型,分别判断:
|
||||||
|
|
||||||
|
| 历史状态 | 建议处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| 已存在成功结果 | 创建重修任务,新结果成功后替换旧结果 |
|
||||||
|
| 从未生成 | 创建新任务,成功后新增关联图片 |
|
||||||
|
| 历史任务失败或已取消 | 允许重新创建任务 |
|
||||||
|
| 当前已有处理中或排队任务 | 拒绝该类型重复提交,或通过幂等机制复用已有任务 |
|
||||||
|
|
||||||
|
### 5.4 替换时机
|
||||||
|
|
||||||
|
继续采用现有 `replace_on_success` 语义:
|
||||||
|
|
||||||
|
- 新结果成功后,才替换同类型旧结果。
|
||||||
|
- 新任务排队、处理中或失败时,保留旧结果。
|
||||||
|
- 精修和氛围感分别替换,互不影响。
|
||||||
|
|
||||||
|
## 6. `type` 参数建议语义
|
||||||
|
|
||||||
|
保持现有请求类型和接口路径不变:
|
||||||
|
|
||||||
|
| `type` | 语义 | 模板规则 |
|
||||||
|
| ---: | --- | --- |
|
||||||
|
| `1` | 只处理精修 | 必须传 `refined_template_id`,忽略氛围感模板 |
|
||||||
|
| `2` | 只处理氛围感 | 必须传 `atmosphere_template_id`,忽略精修模板 |
|
||||||
|
| `3` | 同时处理精修和氛围感 | 根据实际传入的两个模板分别创建任务 |
|
||||||
|
|
||||||
|
对于 `type = 3`:
|
||||||
|
|
||||||
|
- 必须传 `refined_template_id`。
|
||||||
|
- `atmosphere_template_id` 可以选传。
|
||||||
|
- 传入氛围感模板时创建氛围感任务。
|
||||||
|
- 未传氛围感模板时,只重修精修图,并保留已有氛围感图。
|
||||||
|
|
||||||
|
## 7. 建议处理伪代码
|
||||||
|
|
||||||
|
```text
|
||||||
|
validateOriginalMaterial(request.id)
|
||||||
|
validateBatch(request.ai_retouch_batch_id)
|
||||||
|
|
||||||
|
outputs = []
|
||||||
|
|
||||||
|
if request.refined_template_id is not null:
|
||||||
|
outputs.add(
|
||||||
|
createTask(
|
||||||
|
outputType = refined,
|
||||||
|
templateId = request.refined_template_id,
|
||||||
|
mode = existingRefinedResult ? replace_on_success : create
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.atmosphere_template_id is not null:
|
||||||
|
outputs.add(
|
||||||
|
createTask(
|
||||||
|
outputType = atmosphere,
|
||||||
|
templateId = request.atmosphere_template_id,
|
||||||
|
mode = existingAtmosphereResult ? replace_on_success : create
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if outputs is empty:
|
||||||
|
return invalid_template_error
|
||||||
|
|
||||||
|
reserveQuota(outputs.count)
|
||||||
|
submitTasks(outputs)
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:实际是否存在旧结果,应按照各自的 `output_type` 独立查询,不能用“两个类型都已完成”作为 `type = 3` 的整体前置条件。
|
||||||
|
|
||||||
|
## 8. 期望响应
|
||||||
|
|
||||||
|
接口成功响应建议继续返回任务批次信息,并明确本次创建的输出类型。例如:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"data": {
|
||||||
|
"ai_retouch_batch_id": 61,
|
||||||
|
"source_material_id": 702,
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"type": "refined",
|
||||||
|
"mode": "replace_on_success",
|
||||||
|
"status": "queued"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "atmosphere",
|
||||||
|
"mode": "create",
|
||||||
|
"status": "queued"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"reserved_units": 2
|
||||||
|
},
|
||||||
|
"msg": "AI修图任务已提交"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应结构可沿用现有实现,不强制增加上述字段;关键要求是一次请求能够成功创建两个对应的修图任务。
|
||||||
|
|
||||||
|
## 9. 验收用例
|
||||||
|
|
||||||
|
### 用例一:只有旧精修图,同时提交两个模板
|
||||||
|
|
||||||
|
- 历史状态:精修已完成,氛围感不存在。
|
||||||
|
- 请求:`type = 3`,同时传精修和氛围感模板。
|
||||||
|
- 期望:创建两个任务;精修成功后覆盖,氛围感成功后新增。
|
||||||
|
|
||||||
|
### 用例二:只有旧氛围感图,同时提交两个模板
|
||||||
|
|
||||||
|
- 历史状态:精修不存在,氛围感已完成。
|
||||||
|
- 请求:`type = 3`,同时传精修和氛围感模板。
|
||||||
|
- 期望:创建两个任务;精修成功后新增,氛围感成功后覆盖。
|
||||||
|
|
||||||
|
### 用例三:两种旧结果都存在
|
||||||
|
|
||||||
|
- 历史状态:精修、氛围感都已完成。
|
||||||
|
- 请求:`type = 3`,同时传两个模板。
|
||||||
|
- 期望:创建两个任务,分别在成功后覆盖各自旧结果。
|
||||||
|
|
||||||
|
### 用例四:原图 Tab 只选择精修模板
|
||||||
|
|
||||||
|
- 历史状态:精修、氛围感均可能存在。
|
||||||
|
- 请求:`type = 3`,只传 `refined_template_id`。
|
||||||
|
- 期望:只创建精修任务;不处理、不删除、不覆盖旧氛围感图;预占 1 张额度。
|
||||||
|
|
||||||
|
### 用例五:精修后 Tab 重新修图
|
||||||
|
|
||||||
|
- 请求:`type = 1`,只传 `refined_template_id`。
|
||||||
|
- 期望:只创建精修任务,新精修成功后覆盖旧精修图。
|
||||||
|
|
||||||
|
### 用例六:氛围感 Tab 重新修图
|
||||||
|
|
||||||
|
- 请求:`type = 2`,只传 `atmosphere_template_id`。
|
||||||
|
- 期望:只创建氛围感任务,新氛围感成功后覆盖旧氛围感图。
|
||||||
|
|
||||||
|
### 用例七:其中一个输出失败
|
||||||
|
|
||||||
|
- 请求创建精修和氛围感两个任务。
|
||||||
|
- 精修成功,氛围感失败。
|
||||||
|
- 期望:保留成功的新精修结果;氛围感旧图存在时继续保留旧图,不存在时不生成关联图;额度分别结算。
|
||||||
|
|
||||||
|
### 用例八:防止重复提交
|
||||||
|
|
||||||
|
- 同一原图、同一输出类型已经存在排队中或处理中的任务。
|
||||||
|
- 用户再次提交相同任务。
|
||||||
|
- 期望:通过幂等机制返回原任务,或仅拒绝重复的输出类型,避免重复扣减额度和重复生成结果。
|
||||||
|
|
||||||
|
## 10. 总结
|
||||||
|
|
||||||
|
希望 `ai-reretouch` 支持以下统一语义:
|
||||||
|
|
||||||
|
> 对本次传入模板对应的每种输出独立处理:已有结果则在新任务成功后覆盖,没有结果则新增;未传模板的类型不处理。不要因为某一种关联图历史上从未生成,就拒绝整个重新修图请求。
|
||||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 7.8 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,85 @@
|
|||||||
|
# AI 修图完整链路设计稿 V1
|
||||||
|
|
||||||
|
## 交付内容
|
||||||
|
|
||||||
|
| 文件 | 页面 / 状态 |
|
||||||
|
|---|---|
|
||||||
|
| `00-flow-overview.png` | 六张关键屏稿总览 |
|
||||||
|
| `01-album-multi-selection.png` | 相册管理 · 已选择 4 张照片 |
|
||||||
|
| `02-template-selection.png` | AI 修图模板选择 Sheet |
|
||||||
|
| `03-template-preview.png` | 模板全屏预览 |
|
||||||
|
| `04-result-success.png` | 精修结果 · 成功 |
|
||||||
|
| `05-result-processing.png` | 精修结果 · 生成新版本中 |
|
||||||
|
| `06-result-failed.png` | 精修结果 · 新版本生成失败 |
|
||||||
|
|
||||||
|
独立屏稿均为 853 × 1844 像素(约 9:19.5),面向 iOS 16+、iPhone 竖屏、浅色模式。
|
||||||
|
|
||||||
|
## 设计系统
|
||||||
|
|
||||||
|
- 主色:`#1677FF`;辅助渐变:`#5CA8FF`、`#A7D8FF`。
|
||||||
|
- 页面底色:`#F7FAFF`;主文字:`#172033`;次文字:`#667085`。
|
||||||
|
- 失败色:`#EF4444`;赠送/免费标签使用橙色,避免与主要操作竞争。
|
||||||
|
- 字体:SF Pro / 苹方风格;圆角 12–16 pt;以 8 pt 为基础间距。
|
||||||
|
- 正文置于高不透明度表面;极光光晕只用于背景与重点操作,不承载关键信息。
|
||||||
|
- 结果页只通过“原图 / 精修后 / 氛围感”标签切换,不使用对比滑杆。
|
||||||
|
- 重新修图期间及失败后保留上一版成功图片;状态反馈只占局部,不遮挡照片。
|
||||||
|
|
||||||
|
## 可复用 ImageGen 提示词
|
||||||
|
|
||||||
|
以下提示词使用内置 ImageGen。首屏以 `../travel-album-management-redesign-v2.png` 为风格参考;后续页面以上一张已确认屏稿作为参考,确保视觉连续。
|
||||||
|
|
||||||
|
### 通用前缀
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: ui-mockup
|
||||||
|
Asset type: shippable high-fidelity iOS app screen, full portrait canvas, no device frame
|
||||||
|
Style/medium: realistic production UIKit UI, light Aurora blue, crisp SF Pro / PingFang-like typography, SF Symbols-style icons, 12–16 pt corners, 8 pt spacing grid, subtle borders and restrained shadows.
|
||||||
|
Color palette: primary #1677FF, gradient accents #5CA8FF and #A7D8FF, background #F7FAFF, text #172033 and #667085, danger #EF4444.
|
||||||
|
Constraints: iPhone portrait 9:19.5; iOS status bar and home indicator; practical UIKit-buildable hierarchy; accessible contrast; minimum 44 pt touch targets; no dark mode; no watermark; no decorative device frame; no annotations.
|
||||||
|
Text constraints: render specified Chinese text exactly once; no gibberish, duplicate labels or unrelated copy.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 01 · 相册多选
|
||||||
|
|
||||||
|
```text
|
||||||
|
Create the album management screen in multi-selection mode. Top navigation: “相册管理” and “完成”. Album summary: “旅拍相册”, “138****8000”. Filter row: “全部 128”, “已购 36”, “时间 ↓”. Use a precise three-column grid of nine travel photos. Four photos have blue checkmarks, blue outlines and subtle selected overlays; other photos show empty selectors. Fixed bottom actions: red outline “删除 4 张” and blue gradient “AI修图 4 张”.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 02 · 模板选择
|
||||||
|
|
||||||
|
```text
|
||||||
|
Create a large native page sheet titled “AI修图” with subtitle “已选择 4 张照片”. Show the note “每张照片生成精修结果,氛围感可选;本次可免费生成 1 张封面”. Include three horizontal sections: “原图精修” + “必选”, “氛围感修图” + “选填”, “封面风格” + “赠送 · 不占额度”. Each section has three photographic cards, a clear single selected state and “预览” buttons. Cost card: “AI精修 · 按张收费”, “预计消耗 8 张”, “剩余 12 张”. Fixed actions: “取消” and “确定”.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 03 · 模板预览
|
||||||
|
|
||||||
|
```text
|
||||||
|
Create an immersive full-screen preview for the selected “自然通透” template. Use a dark navy photographic canvas, a circular back button, title “自然通透” and subtitle “双指缩放查看细节”. The large photo shows a woman in a straw hat overlooking Santorini. Add “模板效果预览”. Bottom information panel: “自然通透”, “提亮肤色与画面通透感,保留自然质感”, “当前已选择”. Primary action: “使用此模板”. Do not add a comparison slider.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 04 · 正常结果
|
||||||
|
|
||||||
|
```text
|
||||||
|
Create “照片预览” with counter “1 / 9”. Add exactly three tabs: “原图”, “精修后”, “氛围感”; select “精修后”. Show the same Santorini photo with badge “AI已修”, filename “IMG_8291.JPG” and “自然通透 · 2026/08/13”. Information card: “精修结果”, “自然通透模板 · 已生成最新版本”. Fixed actions: “AI修图”, “删除”, “刷新”. Use tabs only; no slider or press-and-hold comparison.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 05 · 处理中状态编辑
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: precise-object-edit. Keep the successful result screen, photo, tabs and layout unchanged. Replace “AI已修” with spinner + “修图中”. Replace the result card with “正在生成新版本” and “当前仍展示上一版结果,完成后自动更新”, plus an indeterminate shimmer line with no percentage. Keep the previous photo unobscured. Disable only the “AI修图” button; leave “删除” and “刷新” available.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 06 · 失败状态编辑
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: precise-object-edit. Keep the successful result screen, photo, tabs and layout unchanged. Replace “AI已修” with a local red badge “生成失败”. Replace the result card with pale-red recovery content: “新版本生成失败”, “上一版结果已保留,可重新修图”, and an outline action “重新修图”. Keep the previous photo unobscured and restore the bottom “AI修图” button to its primary blue state. Do not show a modal, stack trace or error code.
|
||||||
|
```
|
||||||
|
|
||||||
|
## QA 记录
|
||||||
|
|
||||||
|
- 六张独立屏稿尺寸一致,使用同一旅拍照片体系与组件语言。
|
||||||
|
- 精修必选、氛围感选填、四张照片触发封面、封面不占额度均已表达。
|
||||||
|
- 成功、处理中、失败三种状态均保留同一照片与结果标签,便于直接比较。
|
||||||
|
- 处理中不展示虚构百分比;重复提交按钮禁用,刷新仍可用。
|
||||||
|
- 失败状态提供“重新修图”恢复路径,且上一版结果保持可见。
|
||||||
|
- 本目录只包含设计稿与说明,未修改 UIKit、业务逻辑或 Asset Catalog。
|
||||||
|
After Width: | Height: | Size: 8.0 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,76 @@
|
|||||||
|
# AI 修图完整链路设计稿 V2
|
||||||
|
|
||||||
|
## 本轮调整
|
||||||
|
|
||||||
|
- 第 1、2 张沿用 V1 的浅色“轻盈极光蓝”风格。
|
||||||
|
- 第 3–6 张改为统一的黑色专业影像工作区,突出照片本身。
|
||||||
|
- 暗色页面不做简单反色:背景、浮层、边框、文字和按钮均使用独立暗色语义层级。
|
||||||
|
- V1 完整保留在 `../ai-retouch-v1/`,便于对比和回退。
|
||||||
|
|
||||||
|
## 交付内容
|
||||||
|
|
||||||
|
| 文件 | 页面 / 状态 | 风格 |
|
||||||
|
|---|---|---|
|
||||||
|
| `00-flow-overview-v2.png` | 六张关键屏稿总览 | 混合流程 |
|
||||||
|
| `01-album-multi-selection.png` | 相册管理 · 已选择 4 张 | 浅色 |
|
||||||
|
| `02-template-selection.png` | AI 修图模板选择 Sheet | 浅色 |
|
||||||
|
| `03-template-preview-dark.png` | 模板全屏预览 | 暗色 |
|
||||||
|
| `04-result-success-dark.png` | 精修结果 · 成功 | 暗色 |
|
||||||
|
| `05-result-processing-dark.png` | 精修结果 · 生成中 | 暗色 |
|
||||||
|
| `06-result-failed-dark.png` | 精修结果 · 生成失败 | 暗色 |
|
||||||
|
|
||||||
|
## 暗色设计 Token
|
||||||
|
|
||||||
|
- 页面背景:`#05070D`,允许向深海军蓝 `#07111F` 做非常轻微的层次过渡。
|
||||||
|
- 抬升表面:`#111827`;边框:`#263244`。
|
||||||
|
- 主文字:`#F8FAFC`;次文字:`#9CA3AF`;未选中标签:`#C7CED9`。
|
||||||
|
- 品牌主色:`#1677FF`;蓝色高光:`#5CA8FF`。
|
||||||
|
- 失败色:`#EF4444`,仅用于失败徽标、恢复入口和局部边框。
|
||||||
|
- 图片保持明亮自然,不叠加全屏蒙层;信息区和操作区不出现白色表面。
|
||||||
|
|
||||||
|
## 可复用 ImageGen 暗色编辑提示词
|
||||||
|
|
||||||
|
以下提示词使用内置 ImageGen,以对应 V1 页面作为 edit target;第 3 张暗色稿确定后,也作为第 4–6 张的风格参考。
|
||||||
|
|
||||||
|
### 通用暗色编辑约束
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: precise-object-edit
|
||||||
|
Restyle the target into a cohesive dark professional photography workspace.
|
||||||
|
Preserve the exact iPhone portrait dimensions, photo content and crop, person identity and pose, all Chinese copy, hierarchy, controls and safe-area spacing.
|
||||||
|
Use background #05070D, elevated surface #111827, border #263244, primary #1677FF, text #F8FAFC and secondary #9CA3AF.
|
||||||
|
Keep the bright photo unchanged as the focal point. Use white text and SF Symbols-style white icons. Use brand blue only for selected state and primary actions.
|
||||||
|
No white page areas, no comparison slider, no device frame, no annotations, no watermark and no additional copy.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模板预览
|
||||||
|
|
||||||
|
```text
|
||||||
|
Keep “自然通透”, “双指缩放查看细节”, the Santorini photo and “模板效果预览”. Change the lower information panel to charcoal with white heading, muted description and blue “当前已选择”. Change the complete bottom safe area to black and retain the blue “使用此模板” action.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修图结果
|
||||||
|
|
||||||
|
```text
|
||||||
|
Keep “照片预览”, “1 / 9” and the three tabs “原图 / 精修后 / 氛围感”. Use a charcoal segmented control with “精修后” selected in blue. Keep “AI已修”, the filename metadata, the photo and the result information. Change the result card and secondary actions “删除 / 刷新” to dark surfaces; keep “AI修图” blue.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 处理中状态
|
||||||
|
|
||||||
|
```text
|
||||||
|
Based on the approved dark result screen, change only the badge to spinner + “修图中”. Keep the previous photo visible. Change the card to “正在生成新版本” and “当前仍展示上一版结果,完成后自动更新”, with an indeterminate shimmer line and no percentage. Disable only “AI修图”.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 失败状态
|
||||||
|
|
||||||
|
```text
|
||||||
|
Based on the approved dark result screen, change only the badge to “生成失败”. Keep the previous photo visible. Use a charcoal recovery card with restrained red border, “新版本生成失败”, “上一版结果已保留,可重新修图” and “重新修图”. Keep the main “AI修图” button blue. Do not use a white or pale-red card.
|
||||||
|
```
|
||||||
|
|
||||||
|
## QA 记录
|
||||||
|
|
||||||
|
- 第 1、2 张与 V1 像素级复用,没有重新生成。
|
||||||
|
- 第 3–6 张均为 853 × 1844,暗色背景、表面、文字和操作层级一致。
|
||||||
|
- 成功、处理中、失败三态保留同一结果标签、照片和底部操作布局。
|
||||||
|
- 处理中无虚构百分比;失败状态有明确恢复动作;旧结果始终可见。
|
||||||
|
- 本目录只新增设计稿与说明,未修改 UIKit、业务逻辑或 Asset Catalog。
|
||||||
@@ -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;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1020101;
|
CURRENT_PROJECT_VERSION = 1030101;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||||
@@ -460,7 +460,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2.1;
|
MARKETING_VERSION = 1.3.1;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@@ -502,7 +502,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1020101;
|
CURRENT_PROJECT_VERSION = 1030101;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||||
@@ -526,7 +526,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2.1;
|
MARKETING_VERSION = 1.3.1;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@@ -741,7 +741,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1020101;
|
CURRENT_PROJECT_VERSION = 1030101;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -761,7 +761,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2.1;
|
MARKETING_VERSION = 1.3.1;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "ai_retouch_template_placeholder.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "travel_album_before_after.svg",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12 4H20V20H12Z" fill="#000000" fill-opacity="0.32"/>
|
||||||
|
<rect x="4" y="4" width="16" height="16" stroke="#000000" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 4V9L15 12L12 15V20" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 389 B |
@@ -5,25 +5,32 @@ enum PushDestination: Sendable, Equatable {
|
|||||||
case paymentRecord
|
case paymentRecord
|
||||||
case paymentDetails
|
case paymentDetails
|
||||||
case messageCenter
|
case messageCenter
|
||||||
|
case aiRetouchTaskList
|
||||||
|
case aiRetouchTaskDetail(batchId: Int)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 极光/APNs payload 解析结果,仅提取业务消息类型用于点击路由。
|
/// 极光/APNs payload 解析结果,仅提取业务消息类型用于点击路由。
|
||||||
struct PushPayload: Sendable, Equatable {
|
struct PushPayload: Sendable, Equatable {
|
||||||
private let type: String
|
private let type: String
|
||||||
|
private let aiRetouchBatchId: Int?
|
||||||
|
|
||||||
/// 从系统通知 userInfo 提取顶层或推送包装层中的业务消息类型。
|
/// 从系统通知 userInfo 提取顶层或推送包装层中的业务消息类型。
|
||||||
nonisolated init(userInfo: [AnyHashable: Any]) {
|
nonisolated init(userInfo: [AnyHashable: Any]) {
|
||||||
type = Self.extractType(from: userInfo)
|
type = Self.extractType(from: userInfo)
|
||||||
|
aiRetouchBatchId = Self.extractBatchId(from: userInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从可发送的字符串字典创建 payload,仅保留业务消息类型。
|
/// 从可发送的字符串字典创建 payload,仅保留业务消息类型。
|
||||||
nonisolated init(values: [String: String]) {
|
nonisolated init(values: [String: String]) {
|
||||||
type = Self.normalizedType(values["type"])
|
type = Self.normalizedType(values["type"])
|
||||||
|
aiRetouchBatchId = Int(values["ai_retouch_batch_id"] ?? "").flatMap { $0 > 0 ? $0 : nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 可发送的规范化字段快照。
|
/// 可发送的规范化字段快照。
|
||||||
nonisolated var normalizedValues: [String: String] {
|
nonisolated var normalizedValues: [String: String] {
|
||||||
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
|
return .paymentRecord
|
||||||
case "6":
|
case "6":
|
||||||
return .paymentDetails
|
return .paymentDetails
|
||||||
|
case "14":
|
||||||
|
if let aiRetouchBatchId, aiRetouchBatchId > 0 {
|
||||||
|
return .aiRetouchTaskDetail(batchId: aiRetouchBatchId)
|
||||||
|
}
|
||||||
|
return .aiRetouchTaskList
|
||||||
default:
|
default:
|
||||||
return .messageCenter
|
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 = [
|
private nonisolated static let nestedTypeContainerKeys = [
|
||||||
"extras", "extra", "JMessageExtra", "n_extras",
|
"extras", "extra", "JMessageExtra", "n_extras",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -30,6 +30,37 @@ enum MessageJSONValue: Decodable, Hashable, Sendable {
|
|||||||
self = .object(try container.decode([String: MessageJSONValue].self))
|
self = .object(try container.decode([String: MessageJSONValue].self))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将消息字段转换为合法的正整数标识;兼容后端返回数字或数字字符串。
|
||||||
|
var positiveInt: Int? {
|
||||||
|
let value: Int?
|
||||||
|
switch self {
|
||||||
|
case let .number(number):
|
||||||
|
guard number.isFinite, number.rounded() == number else { return nil }
|
||||||
|
value = Int(exactly: number)
|
||||||
|
case let .string(string):
|
||||||
|
value = Int(string.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
|
default:
|
||||||
|
value = nil
|
||||||
|
}
|
||||||
|
guard let value, value > 0 else { return nil }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回对象类型的原始键值。
|
||||||
|
var objectValue: [String: MessageJSONValue]? {
|
||||||
|
switch self {
|
||||||
|
case let .object(value):
|
||||||
|
return value
|
||||||
|
case let .string(value):
|
||||||
|
guard let data = value.data(using: .utf8),
|
||||||
|
let decoded = try? JSONDecoder().decode(MessageJSONValue.self, from: data)
|
||||||
|
else { return nil }
|
||||||
|
return decoded.objectValue
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 消息未读数量响应,用于同步首页红点和桌面图标角标。
|
/// 消息未读数量响应,用于同步首页红点和桌面图标角标。
|
||||||
@@ -203,6 +234,20 @@ struct MessageItem: Decodable, Hashable, Sendable {
|
|||||||
return MessageDateFormatter.formatDateTime(createdAt) ?? createdAt
|
return MessageDateFormatter.formatDateTime(createdAt) ?? createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 是否为 AI 修图任务通知;人工修图的 `type = 10` 不属于该类型。
|
||||||
|
var isAIRetouchTaskNotification: Bool {
|
||||||
|
type == 14
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图批次 ID,兼容 `extra_data` 直接字段及 `extra_data.data` 包装结构。
|
||||||
|
var aiRetouchBatchId: Int? {
|
||||||
|
guard isAIRetouchTaskNotification, let extraData else { return nil }
|
||||||
|
if let batchId = extraData["ai_retouch_batch_id"]?.positiveInt {
|
||||||
|
return batchId
|
||||||
|
}
|
||||||
|
return extraData["data"]?.objectValue?["ai_retouch_batch_id"]?.positiveInt
|
||||||
|
}
|
||||||
|
|
||||||
/// 返回已读状态的消息副本。
|
/// 返回已读状态的消息副本。
|
||||||
func markedRead() -> MessageItem {
|
func markedRead() -> MessageItem {
|
||||||
MessageItem(
|
MessageItem(
|
||||||
|
|||||||
@@ -163,6 +163,16 @@ final class MessageDetailViewModel {
|
|||||||
self.message = message
|
self.message = message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务通知在详情页展示任务入口。
|
||||||
|
var showsAIRetouchTaskAction: Bool {
|
||||||
|
message.isAIRetouchTaskNotification
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前消息携带的 AI 修图批次 ID;缺失时由页面降级进入任务列表。
|
||||||
|
var aiRetouchBatchId: Int? {
|
||||||
|
message.aiRetouchBatchId
|
||||||
|
}
|
||||||
|
|
||||||
/// 删除当前消息。
|
/// 删除当前消息。
|
||||||
func delete(api: any MessageCenterServing) async {
|
func delete(api: any MessageCenterServing) async {
|
||||||
guard message.id > 0 else {
|
guard message.id > 0 else {
|
||||||
|
|||||||
@@ -0,0 +1,816 @@
|
|||||||
|
# AI 修图任务中心需求与接口设计
|
||||||
|
|
||||||
|
> 文档状态:待产品、后端、Android、iOS 联审
|
||||||
|
> 更新日期:2026-08-14
|
||||||
|
> 适用范围:随心瞰商家版 AI 修图任务,不包含人工修图任务
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
AI 修图属于异步长耗时任务。当前用户提交后只能等待或主动返回相册刷新,无法明确知道任务是否仍在排队、预计何时完成、哪些照片成功或失败。
|
||||||
|
|
||||||
|
本需求增加:
|
||||||
|
|
||||||
|
1. 当前账号全部相册的 AI 修图任务列表。
|
||||||
|
2. 单次 AI 修图任务详情。
|
||||||
|
3. AI 修图终态消息推送。
|
||||||
|
4. 从推送和任务列表进入对应任务的完整导航链路;提交成功后仅 Toast 提示并返回相册管理。
|
||||||
|
|
||||||
|
本期解决“看得到进度、完成会通知、结果可直达”的问题,不增加任务取消、批量重试、历史版本管理或后台供应商诊断能力。
|
||||||
|
|
||||||
|
## 2. 核心产品决策
|
||||||
|
|
||||||
|
### 2.1 推送跳转结论
|
||||||
|
|
||||||
|
AI 修图推送优先跳转到对应任务详情页。
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 用户点击完成通知时,核心意图是确认这一次任务的结果,而不是重新查找任务。
|
||||||
|
- 详情页可以直接表达成功、部分成功和失败,减少一次列表定位操作。
|
||||||
|
- `ai_retouch_batch_id` 是稳定任务标识,可以支撑前台、后台和冷启动直达。
|
||||||
|
|
||||||
|
以下情况降级进入任务列表:
|
||||||
|
|
||||||
|
- 推送缺少 `ai_retouch_batch_id`。
|
||||||
|
- ID 类型异常、为 0 或负数。
|
||||||
|
- 详情接口返回任务不存在、已失效或当前账号无权访问。
|
||||||
|
- 未来客户端收到无法识别的 AI 修图终态数据。
|
||||||
|
|
||||||
|
### 2.2 推送类型
|
||||||
|
|
||||||
|
| `type` | 业务含义 | 本需求处理 |
|
||||||
|
|---:|---|---|
|
||||||
|
| `10` | 人工修图完成通知 | 保持原业务语义和原点击行为,不作复用 |
|
||||||
|
| `14` | AI 修图任务通知 | 新增,按 `ai_retouch_batch_id` 进入 AI 修图任务详情 |
|
||||||
|
|
||||||
|
后端需在 `PushMsg` 类型定义及类型名称映射中增加:
|
||||||
|
|
||||||
|
```text
|
||||||
|
14 => AI修图任务通知
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 任务唯一标识
|
||||||
|
|
||||||
|
列表、详情、推送和提交响应统一使用已有的 `ai_retouch_batch_id`,类型固定为正整数。不得再引入另一套 `job_id`,避免与素材接口和重新修图接口中的批次标识无法对应。
|
||||||
|
|
||||||
|
### 2.4 列表范围
|
||||||
|
|
||||||
|
任务列表展示当前登录账号有权限查看的全部相册任务,不要求用户先进入某个相册。任务卡必须显示所属相册信息。
|
||||||
|
|
||||||
|
## 3. 用户流程与入口
|
||||||
|
|
||||||
|
### 3.1 主流程
|
||||||
|
|
||||||
|
```text
|
||||||
|
相册管理选择照片
|
||||||
|
→ 提交 AI 修图
|
||||||
|
→ 后端返回 ai_retouch_batch_id
|
||||||
|
→ 客户端 Toast 提示“AI修图任务已提交,完成后将通过消息通知”
|
||||||
|
→ 关闭模板页及可能存在的照片预览页,回到相册管理并刷新列表
|
||||||
|
→ 用户可离开页面
|
||||||
|
→ 任务进入终态后收到 type = 14 推送
|
||||||
|
→ 点击推送进入任务详情
|
||||||
|
→ 查看成功结果或进入相册处理失败项
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 页面入口
|
||||||
|
|
||||||
|
- 相册管理页导航栏右侧增加“修图任务”,进入当前账号的全局任务列表;新增相册页不展示该入口。
|
||||||
|
- AI 修图提交成功仅展示自动消失的 Toast,不弹出查看任务确认框,也不提供立即跳转操作。
|
||||||
|
- 提交成功后回到相册管理页,并刷新相册摘要、数量和当前素材列表。
|
||||||
|
- 任务列表点击卡片进入对应任务详情。
|
||||||
|
- `type = 14` 推送携带有效任务 ID 时直达详情,否则进入任务列表。
|
||||||
|
|
||||||
|
## 4. 任务状态定义
|
||||||
|
|
||||||
|
后端状态值必须稳定,客户端根据枚举映射中文,不依赖后端返回的中文状态名。
|
||||||
|
|
||||||
|
| 状态 | 是否终态 | 列表分组 | 中文展示 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `queued` | 否 | 进行中 | 排队中 | 已受理,尚无子任务开始 |
|
||||||
|
| `processing` | 否 | 进行中 | 修图中 | 至少一个子任务开始,尚未全部结束 |
|
||||||
|
| `succeeded` | 是 | 已完成 | 已完成 | 所有子任务成功 |
|
||||||
|
| `partially_succeeded` | 是 | 已完成 | 部分完成 | 子任务全部结束,既有成功也有失败或取消 |
|
||||||
|
| `failed` | 是 | 失败 | 处理失败 | 子任务全部结束,没有成功结果 |
|
||||||
|
| `canceled` | 是 | 失败 | 已取消 | 任务因素材删除或后台操作被取消 |
|
||||||
|
|
||||||
|
进度必须满足:
|
||||||
|
|
||||||
|
```text
|
||||||
|
total = queued + processing + succeeded + failed + canceled
|
||||||
|
completed = succeeded + failed + canceled
|
||||||
|
```
|
||||||
|
|
||||||
|
客户端进度百分比只能通过 `completed / total` 计算;`total <= 0` 时隐藏进度百分比,不显示虚构进度。
|
||||||
|
|
||||||
|
## 5. 任务列表页
|
||||||
|
|
||||||
|
### 5.1 设计稿
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
### 6.2 页面结构
|
||||||
|
|
||||||
|
1. 导航栏
|
||||||
|
- 返回按钮。
|
||||||
|
- 标题“任务详情”。
|
||||||
|
- 手动刷新按钮。
|
||||||
|
2. 状态摘要卡
|
||||||
|
- 任务状态图标和文字。
|
||||||
|
- 完成数量与进度条。
|
||||||
|
- 预计完成时间或实际完成时间。
|
||||||
|
- 非终态展示“完成后将通过消息通知你”。
|
||||||
|
3. 相册与任务信息
|
||||||
|
- 相册封面、相册名称、脱敏手机号。
|
||||||
|
- 任务编号、提交时间、开始时间、完成时间或处理耗时。
|
||||||
|
4. 任务内容
|
||||||
|
- 精修、氛围感、封面的目标数量。
|
||||||
|
- 额度预占、实际消耗和释放数量。
|
||||||
|
5. 处理明细
|
||||||
|
- 按原图组织精修和氛围感子任务。
|
||||||
|
- 封面作为独立明细。
|
||||||
|
- 每项展示照片缩略图、文件名、模板名称和状态。
|
||||||
|
- 某个输出失败时,在该照片、该输出项内部紧邻状态展示后端失败原因。
|
||||||
|
6. 页面操作
|
||||||
|
- 成功结果:“查看结果”,进入照片预览对应 Tab。
|
||||||
|
- 失败结果:仅展示后端返回的用户可理解失败原因,不在失败原因后追加操作按钮。
|
||||||
|
- 页面底部始终可提供“查看相册”。
|
||||||
|
|
||||||
|
### 6.3 失败原因展示
|
||||||
|
|
||||||
|
失败原因必须和失败照片、失败输出类型绑定展示,不使用全局弹窗、Toast 或脱离上下文的页面顶部提示代替。
|
||||||
|
|
||||||
|
| 场景 | 展示规则 |
|
||||||
|
|---|---|
|
||||||
|
| 单个精修/氛围感失败 | 在对应输出项状态“生成失败”下方显示“失败原因:{error.message}” |
|
||||||
|
| 同一照片两种输出均失败 | 精修、氛围感分别显示各自原因,不合并为一个模糊原因 |
|
||||||
|
| 封面失败 | 在封面独立明细下方显示原因 |
|
||||||
|
| 任务仍在处理中但已有失败项 | 立即展示已确定的照片失败原因,不等整批任务终态 |
|
||||||
|
| 后端原因为空 | 展示客户端兜底“处理失败,请稍后重试” |
|
||||||
|
| 原因超过两行 | 默认显示两行并提供“查看完整原因”;辅助功能朗读完整文本 |
|
||||||
|
|
||||||
|
视觉规则:
|
||||||
|
|
||||||
|
- 使用红色错误图标、红色“生成失败”和淡红色原因容器;颜色不是唯一状态提示。
|
||||||
|
- `error.message` 使用正文级字号和足够对比度,不使用脚注小字弱化重要信息。
|
||||||
|
- 不展示 `error.code`、供应商名称、堆栈、请求 ID 或服务器路径。
|
||||||
|
- 无论 `retryable` 取值如何,失败原因区域均只展示原因;用户仍可通过详情页底部“查看相册”进入相册管理页。
|
||||||
|
- 用户返回页面或手动刷新后,失败原因随接口最新值更新。
|
||||||
|
|
||||||
|
### 6.4 刷新规则
|
||||||
|
|
||||||
|
- 详情首次出现时立即请求最新数据。
|
||||||
|
- 非终态且页面可见时每 8 秒刷新。
|
||||||
|
- 进入终态、页面离开或 App 进入后台时停止刷新。
|
||||||
|
- 手动刷新与定时刷新不能并发发起重复请求。
|
||||||
|
- 推送不作为唯一状态来源;用户关闭通知权限后仍可通过页面刷新获得终态。
|
||||||
|
|
||||||
|
### 6.5 查看结果规则
|
||||||
|
|
||||||
|
- `output_type = refined`:打开来源原图的照片预览,默认选中“精修后”。
|
||||||
|
- `output_type = atmosphere`:打开来源原图的照片预览,默认选中“氛围感”。
|
||||||
|
- `output_type = cover`:打开所属相册,并定位到生成的封面素材;无法定位时进入相册第一页。
|
||||||
|
- 结果资源已删除或不可用时,隐藏“查看结果”并展示“结果已失效”。
|
||||||
|
|
||||||
|
## 7. 视觉与可访问性规范
|
||||||
|
|
||||||
|
### 7.1 视觉 Token
|
||||||
|
|
||||||
|
| 用途 | 建议值 |
|
||||||
|
|---|---|
|
||||||
|
| 页面背景 | `#F5F7FB` |
|
||||||
|
| 卡片背景 | `#FFFFFF` |
|
||||||
|
| 品牌主色 | `#1677FF` |
|
||||||
|
| 蓝色高光 | `#3A91FF` |
|
||||||
|
| 主文字 | `#111827` |
|
||||||
|
| 次文字 | `#64748B` |
|
||||||
|
| 成功 | `#22A06B` |
|
||||||
|
| 警示/部分完成 | `#F59E0B` |
|
||||||
|
| 失败 | `#EF4444` |
|
||||||
|
| 卡片圆角 | 12–16 pt |
|
||||||
|
|
||||||
|
### 7.2 交互要求
|
||||||
|
|
||||||
|
- 状态必须同时使用图标、文字和颜色,不得只用颜色区分。
|
||||||
|
- 正文文字与背景对比度至少 4.5:1。
|
||||||
|
- 卡片、筛选项和操作按钮的最小触控区域为 44 × 44 pt。
|
||||||
|
- 动态字体至少覆盖系统默认至辅助功能常用档位;文字放大时允许卡片增高。
|
||||||
|
- 进度动画尊重“减弱动态效果”;关闭动画后保留静态进度与文字。
|
||||||
|
- 网络图片使用缩略图地址,并提供占位图和失败占位状态。
|
||||||
|
|
||||||
|
## 8. 后端接口总览
|
||||||
|
|
||||||
|
基础路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/api/yf-handset-app/photog/travel-album
|
||||||
|
```
|
||||||
|
|
||||||
|
| 方法 | 路径 | 类型 | 用途 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `POST` | `/ai-retouch` | 扩展现有响应 | 首次/批量提交后返回任务标识 |
|
||||||
|
| `POST` | `/ai-reretouch` | 扩展现有响应 | 重新修图提交后返回任务标识 |
|
||||||
|
| `GET` | `/ai-retouch-job-list` | 新增 | 获取当前账号全部 AI 修图任务 |
|
||||||
|
| `GET` | `/ai-retouch-job-info` | 实现并统一 | 获取指定任务详情 |
|
||||||
|
|
||||||
|
时间字段统一使用 ISO 8601 UTC,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
2026-08-14T06:26:12.123Z
|
||||||
|
```
|
||||||
|
|
||||||
|
所有 ID 的 JSON 类型必须稳定;本需求中的 `ai_retouch_batch_id`、`user_equity_travel_id` 和素材 ID 均使用整数。
|
||||||
|
|
||||||
|
## 9. 扩展 AI 修图提交响应
|
||||||
|
|
||||||
|
现有 `/ai-retouch` 和 `/ai-reretouch` 请求体保持不变,成功后统一返回可供跳转和立即展示的任务摘要。
|
||||||
|
|
||||||
|
HTTP 状态码:`202 Accepted`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "AI修图任务已提交",
|
||||||
|
"data": {
|
||||||
|
"ai_retouch_batch_id": 9521,
|
||||||
|
"user_equity_travel_id": 88,
|
||||||
|
"status": "queued",
|
||||||
|
"progress": {
|
||||||
|
"total": 9,
|
||||||
|
"queued": 9,
|
||||||
|
"processing": 0,
|
||||||
|
"succeeded": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"canceled": 0
|
||||||
|
},
|
||||||
|
"created_at": "2026-08-14T06:26:12.123Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
必有字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `ai_retouch_batch_id` | int | 大于 0 的任务唯一标识 |
|
||||||
|
| `user_equity_travel_id` | int | 所属相册 ID |
|
||||||
|
| `status` | string | 提交成功时通常为 `queued` |
|
||||||
|
| `progress` | object | 提交时的真实子任务数量 |
|
||||||
|
| `created_at` | string | 服务端受理时间 |
|
||||||
|
|
||||||
|
客户端超时后使用原幂等键重试时,后端必须返回同一个 `ai_retouch_batch_id`,不得创建重复任务。
|
||||||
|
|
||||||
|
## 10. 获取 AI 修图任务列表
|
||||||
|
|
||||||
|
### 10.1 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/yf-handset-app/photog/travel-album/ai-retouch-job-list?status_group=in_progress&limit=20&cursor=<opaque_cursor>
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 必填 | 类型 | 默认值 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `status_group` | 否 | string | `all` | `all`、`in_progress`、`completed`、`failed` |
|
||||||
|
| `limit` | 否 | int | `20` | 最小 1,最大 50 |
|
||||||
|
| `cursor` | 否 | string | — | 服务端返回的不透明游标,第一页不传 |
|
||||||
|
|
||||||
|
服务端分组映射:
|
||||||
|
|
||||||
|
| `status_group` | 包含状态 |
|
||||||
|
|---|---|
|
||||||
|
| `all` | 全部六种状态 |
|
||||||
|
| `in_progress` | `queued`、`processing` |
|
||||||
|
| `completed` | `succeeded`、`partially_succeeded` |
|
||||||
|
| `failed` | `failed`、`canceled` |
|
||||||
|
|
||||||
|
### 10.2 响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"ai_retouch_batch_id": 9521,
|
||||||
|
"user_equity_travel_id": 88,
|
||||||
|
"scope": "batch",
|
||||||
|
"status": "processing",
|
||||||
|
"album": {
|
||||||
|
"id": 88,
|
||||||
|
"name": "旅拍相册",
|
||||||
|
"user_phone": "13812348000",
|
||||||
|
"cover_url": "https://cdn.example.com/albums/88/cover.jpg"
|
||||||
|
},
|
||||||
|
"source_count": 4,
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"type": "refined",
|
||||||
|
"count": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "atmosphere",
|
||||||
|
"count": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "cover",
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"preview_images": [
|
||||||
|
{
|
||||||
|
"material_id": 2031,
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/2031_thumb.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"material_id": 2032,
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/2032_thumb.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"material_id": 2033,
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/2033_thumb.jpg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"progress": {
|
||||||
|
"total": 9,
|
||||||
|
"queued": 3,
|
||||||
|
"processing": 1,
|
||||||
|
"succeeded": 5,
|
||||||
|
"failed": 0,
|
||||||
|
"canceled": 0
|
||||||
|
},
|
||||||
|
"estimated_finish_at": "2026-08-14T06:32:00.000Z",
|
||||||
|
"failure_summary": null,
|
||||||
|
"created_at": "2026-08-14T06:26:12.123Z",
|
||||||
|
"started_at": "2026-08-14T06:26:18.000Z",
|
||||||
|
"finished_at": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0xNFQwNjoyNjoxMi4xMjNaIiwiaWQiOjk1MjF9",
|
||||||
|
"has_more": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 列表字段约定
|
||||||
|
|
||||||
|
| 字段 | 必有 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `album` | 是 | 服务端按登录态校验后返回,客户端不再逐任务查询相册 |
|
||||||
|
| `album.user_phone` | 否 | 返回原始手机号时客户端脱敏;没有时返回空字符串,不返回多种类型 |
|
||||||
|
| `outputs` | 是 | 各输出类型的计划生成数量 |
|
||||||
|
| `preview_images` | 是 | 最多返回 3 项,允许为空数组 |
|
||||||
|
| `progress` | 是 | 六种子任务数量必须满足进度恒等式 |
|
||||||
|
| `estimated_finish_at` | 否 | 无法估算或已终态时返回 `null` |
|
||||||
|
| `failure_summary` | 否 | 任务级可展示摘要;`failed` 时必有,其他状态存在失败子任务时建议返回 |
|
||||||
|
| `next_cursor` | 是 | 无下一页时为 `null` |
|
||||||
|
| `has_more` | 是 | 与 `next_cursor` 语义一致 |
|
||||||
|
|
||||||
|
`estimated_finish_at` 是动态估算而非 SLA。估算变化时允许更新;客户端只展示最新值,不做倒计时承诺。
|
||||||
|
|
||||||
|
`failure_summary` 不代替逐照片失败原因。存在多个不同失败原因时,列表建议返回“N 张照片处理失败,点击查看原因”;只有单一且简短的原因时才直接返回该原因。建议限制在 60 个中文字符以内。
|
||||||
|
|
||||||
|
## 11. 获取 AI 修图任务详情
|
||||||
|
|
||||||
|
### 11.1 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/yf-handset-app/photog/travel-album/ai-retouch-job-info?ai_retouch_batch_id=9521
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 必填 | 类型 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ai_retouch_batch_id` | 是 | int | 大于 0 的 AI 修图批次 ID |
|
||||||
|
|
||||||
|
接口不要求客户端再传 `user_equity_travel_id`。后端应从登录态和任务归属完成权限校验,并在响应中返回相册信息。
|
||||||
|
|
||||||
|
### 11.2 响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"ai_retouch_batch_id": 9521,
|
||||||
|
"user_equity_travel_id": 88,
|
||||||
|
"scope": "batch",
|
||||||
|
"status": "processing",
|
||||||
|
"album": {
|
||||||
|
"id": 88,
|
||||||
|
"name": "旅拍相册",
|
||||||
|
"user_phone": "13812348000",
|
||||||
|
"cover_url": "https://cdn.example.com/albums/88/cover.jpg"
|
||||||
|
},
|
||||||
|
"source_count": 4,
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"type": "refined",
|
||||||
|
"count": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "atmosphere",
|
||||||
|
"count": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "cover",
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"progress": {
|
||||||
|
"total": 9,
|
||||||
|
"queued": 2,
|
||||||
|
"processing": 1,
|
||||||
|
"succeeded": 5,
|
||||||
|
"failed": 1,
|
||||||
|
"canceled": 0
|
||||||
|
},
|
||||||
|
"quota_settlement": {
|
||||||
|
"status": "partially_settled",
|
||||||
|
"reserved_units": 8,
|
||||||
|
"consumed_units": 5,
|
||||||
|
"released_units": 1,
|
||||||
|
"cover_units": 0
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"target_id": 30101,
|
||||||
|
"source_material": {
|
||||||
|
"id": 2031,
|
||||||
|
"file_name": "IMG_8291.JPG",
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/2031_thumb.jpg"
|
||||||
|
},
|
||||||
|
"input_material_ids": [2031],
|
||||||
|
"output_type": "refined",
|
||||||
|
"template": {
|
||||||
|
"id": 11,
|
||||||
|
"name": "自然通透"
|
||||||
|
},
|
||||||
|
"status": "succeeded",
|
||||||
|
"result_asset": {
|
||||||
|
"id": 9201,
|
||||||
|
"material_id": 2031,
|
||||||
|
"url": "https://cdn.example.com/albums/88/2031_refined_v2.jpg",
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/2031_refined_v2_thumb.jpg"
|
||||||
|
},
|
||||||
|
"error": null,
|
||||||
|
"created_at": "2026-08-14T06:26:12.123Z",
|
||||||
|
"started_at": "2026-08-14T06:26:18.000Z",
|
||||||
|
"finished_at": "2026-08-14T06:28:40.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target_id": 30102,
|
||||||
|
"source_material": {
|
||||||
|
"id": 2032,
|
||||||
|
"file_name": "IMG_8292.JPG",
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/2032_thumb.jpg"
|
||||||
|
},
|
||||||
|
"input_material_ids": [2032],
|
||||||
|
"output_type": "atmosphere",
|
||||||
|
"template": {
|
||||||
|
"id": 21,
|
||||||
|
"name": "暖阳氛围"
|
||||||
|
},
|
||||||
|
"status": "processing",
|
||||||
|
"result_asset": null,
|
||||||
|
"error": null,
|
||||||
|
"created_at": "2026-08-14T06:26:12.123Z",
|
||||||
|
"started_at": "2026-08-14T06:29:02.000Z",
|
||||||
|
"finished_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target_id": 30103,
|
||||||
|
"source_material": {
|
||||||
|
"id": 2033,
|
||||||
|
"file_name": "IMG_8293.JPG",
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/2033_thumb.jpg"
|
||||||
|
},
|
||||||
|
"input_material_ids": [2033],
|
||||||
|
"output_type": "refined",
|
||||||
|
"template": {
|
||||||
|
"id": 11,
|
||||||
|
"name": "自然通透"
|
||||||
|
},
|
||||||
|
"status": "failed",
|
||||||
|
"result_asset": null,
|
||||||
|
"error": {
|
||||||
|
"code": "AI_PROVIDER_TIMEOUT",
|
||||||
|
"message": "AI服务处理超时,请重新修图",
|
||||||
|
"retryable": true
|
||||||
|
},
|
||||||
|
"created_at": "2026-08-14T06:26:12.123Z",
|
||||||
|
"started_at": "2026-08-14T06:27:10.000Z",
|
||||||
|
"finished_at": "2026-08-14T06:29:30.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target_id": 30109,
|
||||||
|
"source_material": null,
|
||||||
|
"input_material_ids": [2031, 2032, 2033, 2034],
|
||||||
|
"output_type": "cover",
|
||||||
|
"template": {
|
||||||
|
"id": 31,
|
||||||
|
"name": "旅拍拼贴"
|
||||||
|
},
|
||||||
|
"status": "queued",
|
||||||
|
"result_asset": null,
|
||||||
|
"error": null,
|
||||||
|
"created_at": "2026-08-14T06:26:12.123Z",
|
||||||
|
"started_at": null,
|
||||||
|
"finished_at": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"estimated_finish_at": "2026-08-14T06:32:00.000Z",
|
||||||
|
"created_at": "2026-08-14T06:26:12.123Z",
|
||||||
|
"started_at": "2026-08-14T06:26:18.000Z",
|
||||||
|
"finished_at": null,
|
||||||
|
"duration_seconds": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 失败明细
|
||||||
|
|
||||||
|
失败子任务的 `error` 结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "AI_PROVIDER_TIMEOUT",
|
||||||
|
"message": "AI服务处理超时,请重新修图",
|
||||||
|
"retryable": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
约定:
|
||||||
|
|
||||||
|
- `status = failed` 时 `error` 和非空 `error.message` 必须返回,不能只返回错误码。
|
||||||
|
- `message` 必须是经过后端转换、可直接展示给用户的中文文案,建议不超过 120 个中文字符。
|
||||||
|
- `code` 用于客户端判断与联调排查,不直接展示。
|
||||||
|
- 不得返回供应商密钥、内部请求、堆栈、服务器路径或原始异常。
|
||||||
|
- `retryable` 只表达业务上是否允许重新提交,本期详情页不直接发起批量重试。
|
||||||
|
- `queued`、`processing`、`succeeded` 时 `error` 固定返回 `null`,不要返回空对象。
|
||||||
|
- 同一来源照片的精修和氛围感必须分别返回自己的 `error`,客户端不通过素材级公共错误猜测具体失败输出。
|
||||||
|
|
||||||
|
### 11.4 额度结算
|
||||||
|
|
||||||
|
| 状态 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `reserved` | 已预占,尚无子任务完成 |
|
||||||
|
| `partially_settled` | 部分预占已转为消耗或释放 |
|
||||||
|
| `settled` | 所有额度完成结算 |
|
||||||
|
|
||||||
|
必须满足:
|
||||||
|
|
||||||
|
```text
|
||||||
|
reserved_units >= consumed_units + released_units
|
||||||
|
```
|
||||||
|
|
||||||
|
终态时应满足:
|
||||||
|
|
||||||
|
```text
|
||||||
|
reserved_units = consumed_units + released_units
|
||||||
|
```
|
||||||
|
|
||||||
|
封面不消耗额度,`cover_units` 固定为 0。
|
||||||
|
|
||||||
|
## 12. 接口错误码
|
||||||
|
|
||||||
|
| HTTP | 业务码 | 场景 | 客户端行为 |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| 400 | `INVALID_RETOUCH_STATUS_GROUP` | 列表筛选参数错误 | 回退“全部”并记录联调日志 |
|
||||||
|
| 400 | `INVALID_CURSOR` | 游标无效或过期 | 清空游标并重新加载第一页 |
|
||||||
|
| 400 | `INVALID_RETOUCH_BATCH_ID` | 任务 ID 非法 | 进入任务列表 |
|
||||||
|
| 401 | `UNAUTHORIZED` | 登录态失效 | 走现有重新登录流程,登录后继续待处理路由 |
|
||||||
|
| 404 | `AI_RETOUCH_JOB_NOT_FOUND` | 不存在、已失效或无权访问 | 提示“任务不存在或已失效”,进入任务列表 |
|
||||||
|
| 429 | `TOO_MANY_REQUESTS` | 刷新过于频繁 | 停止本轮轮询,按服务端建议时间重试 |
|
||||||
|
| 500 | `AI_RETOUCH_JOB_QUERY_FAILED` | 服务端异常 | 保留已有数据并提供重试 |
|
||||||
|
|
||||||
|
权限不足建议统一返回 404,避免泄露其他账号任务是否存在。
|
||||||
|
|
||||||
|
## 13. AI 修图推送协议
|
||||||
|
|
||||||
|
### 13.1 发送条件
|
||||||
|
|
||||||
|
- 只在任务第一次从非终态进入 `succeeded`、`partially_succeeded` 或 `failed` 时发送。
|
||||||
|
- `canceled` 默认不发送通知。
|
||||||
|
- 同一 `ai_retouch_batch_id` 只发送一次终态推送。
|
||||||
|
- 后端应通过事务字段或唯一记录保证幂等,例如 `terminal_push_sent_at`。
|
||||||
|
- 设备没有有效极光 Registration ID 时不影响任务结算;用户仍可在任务中心查看结果。
|
||||||
|
|
||||||
|
### 13.2 统一业务结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "AI修图已完成",
|
||||||
|
"content": "「旅拍相册」的9张结果已生成,点击查看。",
|
||||||
|
"msg_id": 2014,
|
||||||
|
"type": 14,
|
||||||
|
"data": {
|
||||||
|
"ai_retouch_batch_id": 9521,
|
||||||
|
"user_equity_travel_id": 88,
|
||||||
|
"status": "succeeded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段约定:
|
||||||
|
|
||||||
|
| 字段 | 必有 | 类型 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `type` | 是 | int | 固定为 `14` |
|
||||||
|
| `msg_id` | 是 | int | 后端业务消息 ID,不是极光 `_j_msgid` |
|
||||||
|
| `data` | 是 | object | 不得发送为 JSON 字符串 |
|
||||||
|
| `ai_retouch_batch_id` | 是 | int | 大于 0;点击详情的主键 |
|
||||||
|
| `user_equity_travel_id` | 是 | int | 用于降级列表和联调排查 |
|
||||||
|
| `status` | 是 | string | 仅允许三个会推送的终态值 |
|
||||||
|
|
||||||
|
### 13.3 通知文案
|
||||||
|
|
||||||
|
| 状态 | 标题 | 正文模板 |
|
||||||
|
|---|---|---|
|
||||||
|
| `succeeded` | AI修图已完成 | `「{相册名}」的{成功数}张结果已生成,点击查看。` |
|
||||||
|
| `partially_succeeded` | AI修图部分完成 | `{成功数}张成功,{失败数}张失败,点击查看详情。` |
|
||||||
|
| `failed` | AI修图未完成 | `本次任务处理失败,点击查看原因。` |
|
||||||
|
|
||||||
|
相册名称为空时,正文使用“本次AI修图任务”,不得出现空书名号。
|
||||||
|
|
||||||
|
### 13.4 iOS 示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"aps": {
|
||||||
|
"alert": {
|
||||||
|
"title": "AI修图已完成",
|
||||||
|
"body": "「旅拍相册」的9张结果已生成,点击查看。"
|
||||||
|
},
|
||||||
|
"sound": "default"
|
||||||
|
},
|
||||||
|
"msg_id": 2014,
|
||||||
|
"type": 14,
|
||||||
|
"data": {
|
||||||
|
"ai_retouch_batch_id": 9521,
|
||||||
|
"user_equity_travel_id": 88,
|
||||||
|
"status": "succeeded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Android 的 `extras` 中放入同一业务对象;字段名、字段类型和业务含义必须与 iOS 一致。
|
||||||
|
|
||||||
|
## 14. 推送点击路由
|
||||||
|
|
||||||
|
### 14.1 路由规则
|
||||||
|
|
||||||
|
```text
|
||||||
|
收到通知点击
|
||||||
|
→ 解析业务 type
|
||||||
|
→ type != 14:继续走现有类型路由
|
||||||
|
→ type = 14:解析 data.ai_retouch_batch_id
|
||||||
|
→ 合法:打开 AI 修图任务详情
|
||||||
|
→ 缺失/非法:打开 AI 修图任务列表
|
||||||
|
→ 详情请求 404:提示后返回任务列表
|
||||||
|
```
|
||||||
|
|
||||||
|
### 14.2 生命周期
|
||||||
|
|
||||||
|
- 前台、后台点击和冷启动使用同一套 `type + data` 解析规则。
|
||||||
|
- 未登录时暂存 `ai_retouch_batch_id`,登录成功且主 Tab 建立后继续路由。
|
||||||
|
- 账号切换或退出登录时清除上一账号尚未执行的待处理路由。
|
||||||
|
- 同一系统通知的 request identifier 只处理一次。
|
||||||
|
- 客户端不得从标题或正文解析任务 ID。
|
||||||
|
|
||||||
|
### 14.3 向后兼容
|
||||||
|
|
||||||
|
- 旧客户端无法识别 `type = 14` 时按既有默认逻辑进入消息中心,不应崩溃。
|
||||||
|
- 新客户端收到 `type = 10` 时仍按人工修图处理,不进入 AI 修图任务中心。
|
||||||
|
- 对 `data` 中新增的未知字段,客户端应忽略。
|
||||||
|
|
||||||
|
### 14.4 站内消息详情入口
|
||||||
|
|
||||||
|
- `type = 14` 的站内消息详情页在消息正文下方展示品牌蓝主按钮“查看任务详情”。
|
||||||
|
- 消息 `extra_data.ai_retouch_batch_id` 合法时进入对应任务详情;兼容 `extra_data.data.ai_retouch_batch_id` 包装结构。
|
||||||
|
- 任务 ID 缺失、无法解析或小于等于 0 时,按钮仍保留,点击后降级进入 AI 修图任务列表。
|
||||||
|
- `type = 10` 人工修图及其他类型消息不展示该入口,原有消息详情行为保持不变。
|
||||||
|
- 底部“删除并返回”继续作为独立的破坏性操作,不承担业务跳转职责。
|
||||||
|
|
||||||
|
## 15. 数据、安全与一致性
|
||||||
|
|
||||||
|
- 列表和详情只能返回当前登录账号有权限查看的任务。
|
||||||
|
- 后端从登录态确定账号范围,不接受客户端传入用户 ID 扩大查询范围。
|
||||||
|
- 手机号仅用于业务识别,客户端统一脱敏展示。
|
||||||
|
- 缩略图和结果 URL 应使用受控 CDN 地址;如需签名,过期时间应覆盖合理浏览时长。
|
||||||
|
- 任务状态和所有子任务状态必须在同一份一致性快照中返回。
|
||||||
|
- 推送发送失败不得回滚已完成的 AI 任务或额度结算。
|
||||||
|
- 已删除且不再允许客户端展示的素材,不应继续通过任务详情暴露原图或结果 URL。
|
||||||
|
- 如果任务已整体不可见,详情统一返回 `AI_RETOUCH_JOB_NOT_FOUND`。
|
||||||
|
|
||||||
|
## 16. 验收标准
|
||||||
|
|
||||||
|
### 16.1 页面
|
||||||
|
|
||||||
|
- 能从相册管理页进入当前账号全部 AI 修图任务列表,新增相册页不展示该入口。
|
||||||
|
- 四种筛选与六种状态映射正确。
|
||||||
|
- 进行中任务显示真实完成数量;有 ETA 才显示预计完成时间。
|
||||||
|
- 详情可展示任务摘要、相册信息、额度和逐输出明细。
|
||||||
|
- 单照片、单输出失败时,失败原因展示在对应明细内部,不需要用户从任务级摘要猜测失败对象。
|
||||||
|
- 后端原因超过两行时可查看完整内容;原因缺失时有稳定兜底文案。
|
||||||
|
- 成功结果可进入对应照片结果,失败项可进入所属相册。
|
||||||
|
- 页面离开或任务终态后停止轮询。
|
||||||
|
|
||||||
|
### 16.2 接口
|
||||||
|
|
||||||
|
- 两个提交接口返回稳定且可跳转的 `ai_retouch_batch_id`。
|
||||||
|
- 游标分页在任务新增和状态更新期间不重复、不漏项。
|
||||||
|
- 进度数量满足恒等式,任务终态与子任务状态一致。
|
||||||
|
- ETA 缺失时返回 `null`,不返回空字符串或 0 时间。
|
||||||
|
- `status = failed` 的子任务必有非空 `error.message`,且详情能够按照片和输出类型准确展示。
|
||||||
|
- 失败信息不包含供应商、堆栈、服务器路径等内部实现。
|
||||||
|
|
||||||
|
### 16.3 推送
|
||||||
|
|
||||||
|
- `type = 14` 未被其他业务占用,后端类型名称映射完整。
|
||||||
|
- `type = 10` 人工修图和 `type = 14` AI 修图互不影响。
|
||||||
|
- 成功、部分成功、失败各只发送一次终态通知。
|
||||||
|
- 前台、后台、冷启动、未登录和重复点击均符合路由规则。
|
||||||
|
- 缺少任务 ID、非法 ID、任务失效时均安全降级到任务列表。
|
||||||
|
|
||||||
|
## 17. 本期不做
|
||||||
|
|
||||||
|
- 从任务中心取消 AI 修图任务。
|
||||||
|
- 一键批量重试失败子任务。
|
||||||
|
- 展示 AI 供应商、内部错误堆栈或链路日志。
|
||||||
|
- 展示同一照片的历史修图版本。
|
||||||
|
- 为 ETA 提供 SLA 倒计时承诺。
|
||||||
|
- 修改 `type = 10` 人工修图协议。
|
||||||
|
|
||||||
|
## 18. 设计稿生成说明
|
||||||
|
|
||||||
|
两张设计稿使用内置 ImageGen 生成,参考现有 AI 修图 V2 的浅色相册管理页面、旅行摄影素材和状态视觉语言。
|
||||||
|
|
||||||
|
- 任务列表:853 × 1844,展示进行中、已完成、部分完成三种代表性卡片。
|
||||||
|
- 任务详情 V2:852 × 1846,展示处理中的任务、6/9 进度、ETA、消息通知说明,以及单照片“生成失败 + 失败原因”的内联状态。
|
||||||
|
- 设计稿用于产品和研发对齐;实际 UIKit 实现应使用项目内颜色、字体、SnapKit 和 SF Symbols,不把设计稿作为页面背景切图。
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
# AI 修图接口文档
|
||||||
|
|
||||||
|
## 接口列表
|
||||||
|
|
||||||
|
| # | 方法 | 路径 | 用途 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `GET` | `/api/yf-handset-app/photog/travel-album/material-list` | 获取相册网格素材和预览初始数据 |
|
||||||
|
| 2 | `GET` | `/api/yf-handset-app/photog/travel-album/material-info` | 获取/刷新当前原图及关联图 |
|
||||||
|
| 3 | `GET` | `/api/yf-handset-app/photog/travel-album/ai-retouch-options` | 获取 AI 模板、选择规则和剩余额度 |
|
||||||
|
| 4 | `POST` | `/api/yf-handset-app/photog/travel-album/ai-retouch` | 提交首次修图或重新修图任务 |
|
||||||
|
| 5 | `GET` | `/api/yf-handset-app/photog/travel-album/ai-retouch-job-info` | 查询 AI 修图任务进度和结果 |
|
||||||
|
| 6 | `POST` | `/api/yf-handset-app/photog/travel-album/delete-material` | 单张/批量删除素材及关联图 |
|
||||||
|
|
||||||
|
## 接口详情
|
||||||
|
|
||||||
|
### 获取相册素材列表
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/yf-handset-app/photog/travel-album/material-list?user_equity_travel_id=88&limit=30&cursor=opaque_cursor&sort=created_at_desc
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 必填 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `user_equity_travel_id` | 是 | 相册 ID |
|
||||||
|
| `limit` | 否 | 默认 30,最大 100 |
|
||||||
|
| `cursor` | 否 | 服务端返回的不透明游标,首页不传 |
|
||||||
|
| `sort` | 否 | `created_at_desc` 或 `created_at_asc` |
|
||||||
|
| `material_type` | 否 | `original`、`cover`;不传表示全部 |
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "2031",
|
||||||
|
"user_equity_travel_id": "88",
|
||||||
|
"material_type": "original",
|
||||||
|
"original_asset": {
|
||||||
|
"id": "asset_8001",
|
||||||
|
"url": "https://cdn.example.com/albums/88/IMG_1024.jpg",
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/IMG_1024_thumb.jpg",
|
||||||
|
"file_name": "IMG_1024.jpg",
|
||||||
|
"mime_type": "image/jpeg",
|
||||||
|
"file_size": 4821931,
|
||||||
|
"width": 4032,
|
||||||
|
"height": 3024,
|
||||||
|
"version": 1,
|
||||||
|
"created_at": "2026-08-09T01:00:00.000Z"
|
||||||
|
},
|
||||||
|
"display_status": "uploaded",
|
||||||
|
"variant_slots": [],
|
||||||
|
"revision": 7,
|
||||||
|
"created_at": "2026-08-09T01:00:00.000Z",
|
||||||
|
"updated_at": "2026-08-11T07:30:18.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"next_cursor": "next_opaque_cursor",
|
||||||
|
"has_more": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 只返回网格顶层素材:原图和独立封面。
|
||||||
|
- 原图如存在关联图,`variant_slots` 包含预览页 Tab 初始数据。
|
||||||
|
- 建议使用游标分页,因为 AI 任务完成时可能实时插入封面素材。
|
||||||
|
|
||||||
|
### 获取/刷新单个原图项目
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/yf-handset-app/photog/travel-album/material-info?user_equity_travel_id=88&material_id=2031
|
||||||
|
```
|
||||||
|
|
||||||
|
查询参数 `user_equity_travel_id` 和 `material_id` 均必填。响应的 `data` 直接返完整 `material_project`,字段与列表 Item 完全一致。该接口供预览页刷新和提交后定向更新使用。
|
||||||
|
|
||||||
|
如 `material_id` 已删除,返回 `404 MATERIAL_NOT_FOUND`。
|
||||||
|
|
||||||
|
### 获取 AI 修图选项
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_travel_id=88&scope=batch&source_count=4
|
||||||
|
```
|
||||||
|
|
||||||
|
| `scope` | 使用场景 |
|
||||||
|
|---|---|
|
||||||
|
| `batch` | 网格多选 AI 修图,或预览页尚无 AI 结果 Tab 时首次修图 |
|
||||||
|
| `all_variants` | 预览页已有 AI 结果 Tab;当前为原图、精修后或氛围感均使用此范围 |
|
||||||
|
| `refined_only` | 仅重修精修结果的接口能力,当前客户端无独立入口 |
|
||||||
|
| `atmosphere_only` | 仅重修氛围感结果的接口能力,当前客户端无独立入口 |
|
||||||
|
|
||||||
|
`user_equity_travel_id`、`scope` 和 `source_count` 均必填。`source_count` 用于计算封面模板是否显示和必选。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"scope": "batch",
|
||||||
|
"source_count": 4,
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"type": "refined",
|
||||||
|
"title": "原图精修",
|
||||||
|
"required": true,
|
||||||
|
"selection_mode": "single",
|
||||||
|
"quota_cost": {
|
||||||
|
"mode": "per_source",
|
||||||
|
"units": 1
|
||||||
|
},
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"id": "tpl_refined_12",
|
||||||
|
"name": "清透精修",
|
||||||
|
"preview_url": "https://cdn.example.com/templates/refined_12.jpg",
|
||||||
|
"before_url": "https://cdn.example.com/templates/refined_12_before.jpg",
|
||||||
|
"after_url": "https://cdn.example.com/templates/refined_12_after.jpg",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "atmosphere",
|
||||||
|
"title": "氛围感修图",
|
||||||
|
"required": false,
|
||||||
|
"selection_mode": "single",
|
||||||
|
"quota_cost": {
|
||||||
|
"mode": "per_source",
|
||||||
|
"units": 1
|
||||||
|
},
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"id": "tpl_atmosphere_06",
|
||||||
|
"name": "暖阳",
|
||||||
|
"preview_url": "https://cdn.example.com/templates/atmosphere_06.jpg",
|
||||||
|
"before_url": "https://cdn.example.com/templates/atmosphere_06_before.jpg",
|
||||||
|
"after_url": "https://cdn.example.com/templates/atmosphere_06_after.jpg",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "cover",
|
||||||
|
"title": "封面风格模板",
|
||||||
|
"required": true,
|
||||||
|
"selection_mode": "single",
|
||||||
|
"quota_cost": {
|
||||||
|
"mode": "per_job",
|
||||||
|
"units": 0
|
||||||
|
},
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"id": "tpl_cover_03",
|
||||||
|
"name": "旅行画册",
|
||||||
|
"preview_url": "https://cdn.example.com/templates/cover_03.jpg",
|
||||||
|
"before_url": "https://cdn.example.com/templates/cover_03_before.jpg",
|
||||||
|
"after_url": "https://cdn.example.com/templates/cover_03_after.jpg",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"quota": {
|
||||||
|
"unit": "retouch_time",
|
||||||
|
"remaining": 12,
|
||||||
|
"reserved": 3,
|
||||||
|
"display_text": "剩余 12 次",
|
||||||
|
"updated_at": "2026-08-11T07:29:58.000Z"
|
||||||
|
},
|
||||||
|
"limits": {
|
||||||
|
"minimum_source_count": 1,
|
||||||
|
"maximum_source_count": 50,
|
||||||
|
"cover_minimum_source_count": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
设计要点:
|
||||||
|
|
||||||
|
- 不再让 App 传 `scenic_id`,后端从相册归属景区获取可用模板。
|
||||||
|
- 由后端返回 `required` 和可见分组,避免多端各写一套“4 张显示封面”规则。
|
||||||
|
- `preview_url` 用于模板卡片缩略图;`before_url` 与 `after_url` 必须是同尺寸、同构图的配对图片,供客户端滑动对比。
|
||||||
|
- 客户端可默认选中必选分组的第一个可用模板,可选分组默认不选。
|
||||||
|
- 此接口用于 UI 配置;提交时后端仍必须根据真实素材 ID 重新校验。
|
||||||
|
- 同一响应返回当前用户的剩余可用额度和每类输出的额度单价,弹窗无需再发起第二个额度请求。
|
||||||
|
- `quota.remaining` 是扣除其他未完成任务已预占数量后、当前立即可用的额度;弹窗底部使用此值显示“剩余 N 次”。
|
||||||
|
- 额度文案位于模板选择弹窗的固定底部区域,不随中间模板列表滚动。
|
||||||
|
- `quota.reserved` 是当前用户其他未完成任务的预占额度,仅用于解释账户状态。
|
||||||
|
- 客户端预估本次消耗时,对已选输出求和:`per_source` 类型为 `source_count * units`,`per_job` 类型为 `units`。封面始终为 0。
|
||||||
|
- 本文档默认精修和氛围感每生成一张各消耗 1 次。如实际商业规则变更,后端只需调整 `quota_cost`,客户端不需改接口。
|
||||||
|
|
||||||
|
### 创建 AI 修图任务
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/yf-handset-app/photog/travel-album/ai-retouch
|
||||||
|
Idempotency-Key: 9A7820CE-78AB-45C6-9E0F-5DF268995379
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.4.1 网格批量修图
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_equity_travel_id": "88",
|
||||||
|
"scope": "batch",
|
||||||
|
"source_material_ids": ["2031", "2032", "2033", "2034"],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"type": "refined",
|
||||||
|
"template_id": "tpl_refined_12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "atmosphere",
|
||||||
|
"template_id": "tpl_atmosphere_06"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "cover",
|
||||||
|
"template_id": "tpl_cover_03"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.4.2 预览页已有 AI 结果 Tab
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_equity_travel_id": "88",
|
||||||
|
"scope": "all_variants",
|
||||||
|
"source_material_ids": ["2031"],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"type": "atmosphere",
|
||||||
|
"template_id": "tpl_atmosphere_09"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
已有精修或氛围感结果时,无论当前位于原图、精修后还是氛围感 Tab,精修和氛围感模板均为选填,但至少选择一种。`outputs` 只传本次选中的结果类型;缺失表示“本次不处理”,不表示删除或覆盖对应的已有结果。
|
||||||
|
|
||||||
|
#### 5.4.3 只重新精修
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_equity_travel_id": "88",
|
||||||
|
"scope": "refined_only",
|
||||||
|
"source_material_ids": ["2031"],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"type": "refined",
|
||||||
|
"template_id": "tpl_refined_18"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.4.4 只重新生成氛围感
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_equity_travel_id": "88",
|
||||||
|
"scope": "atmosphere_only",
|
||||||
|
"source_material_ids": ["2031"],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"type": "atmosphere",
|
||||||
|
"template_id": "tpl_atmosphere_11"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.4.5 提交校验矩阵
|
||||||
|
|
||||||
|
| `scope` | 原图数 | 精修 | 氛围感 | 封面 |
|
||||||
|
|---|---:|---|---|---|
|
||||||
|
| `batch` | 1 至 50 | 必选 | 可选 | 少于 4 张禁止;4 张及以上必选 |
|
||||||
|
| `all_variants` | 必须为 1 | 可选(与氛围感至少一项) | 可选(与精修至少一项) | 禁止 |
|
||||||
|
| `refined_only` | 必须为 1 | 必选 | 禁止 | 禁止 |
|
||||||
|
| `atmosphere_only` | 必须为 1 | 禁止 | 必选 | 禁止 |
|
||||||
|
|
||||||
|
另外:
|
||||||
|
|
||||||
|
- `source_material_ids` 必须去重,且全部为 `original` 类型。
|
||||||
|
- 模板必须启用、类型匹配,且适用于相册所属景区。
|
||||||
|
- 后端必须使用提交时的最新额度计费规则重新计算消耗,不信任客户端的预估数值。
|
||||||
|
- 封面输出不论首次生成、重试或任务结果如何,额度消耗始终为 0。
|
||||||
|
- 每个输出槽位均采用 `replace_on_success`:成功后替换旧版本,失败保留旧版本。
|
||||||
|
- 批量选中已有 AI 结果的原图时,允许重新生成,只覆盖本次 `outputs` 列出的类型。
|
||||||
|
- 同一原图、同一槽位已有 `queued/processing` 任务时,整次请求返回 `409 RETOUCH_TARGET_BUSY`,并列出冲突项。
|
||||||
|
|
||||||
|
#### 5.4.6 提交响应
|
||||||
|
|
||||||
|
HTTP 状态码:`202 Accepted`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "AI 修图任务已提交",
|
||||||
|
"data": {
|
||||||
|
"job": {
|
||||||
|
"id": "job_01K2E4",
|
||||||
|
"user_equity_travel_id": "88",
|
||||||
|
"scope": "all_variants",
|
||||||
|
"status": "queued",
|
||||||
|
"source_material_ids": ["2031"],
|
||||||
|
"progress": {
|
||||||
|
"total": 2,
|
||||||
|
"queued": 2,
|
||||||
|
"processing": 0,
|
||||||
|
"succeeded": 0,
|
||||||
|
"failed": 0
|
||||||
|
},
|
||||||
|
"created_at": "2026-08-11T07:30:15.123Z",
|
||||||
|
"started_at": null,
|
||||||
|
"finished_at": null
|
||||||
|
},
|
||||||
|
"quota_reservation": {
|
||||||
|
"before_remaining": 12,
|
||||||
|
"reserved_units": 2,
|
||||||
|
"remaining_after_reservation": 10,
|
||||||
|
"cover_units": 0
|
||||||
|
},
|
||||||
|
"affected_projects": [
|
||||||
|
{
|
||||||
|
"id": "2031",
|
||||||
|
"user_equity_travel_id": "88",
|
||||||
|
"material_type": "original",
|
||||||
|
"original_asset": {
|
||||||
|
"id": "asset_8001",
|
||||||
|
"url": "https://cdn.example.com/albums/88/IMG_1024.jpg",
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/IMG_1024_thumb.jpg",
|
||||||
|
"file_name": "IMG_1024.jpg",
|
||||||
|
"mime_type": "image/jpeg",
|
||||||
|
"file_size": 4821931,
|
||||||
|
"width": 4032,
|
||||||
|
"height": 3024,
|
||||||
|
"version": 1,
|
||||||
|
"created_at": "2026-08-09T01:00:00.000Z"
|
||||||
|
},
|
||||||
|
"display_status": "pending",
|
||||||
|
"variant_slots": [
|
||||||
|
{
|
||||||
|
"type": "refined",
|
||||||
|
"asset": null,
|
||||||
|
"latest_generation": {
|
||||||
|
"job_id": "job_01K2E4",
|
||||||
|
"status": "queued",
|
||||||
|
"is_replacement": false,
|
||||||
|
"template": {
|
||||||
|
"id": "tpl_refined_15",
|
||||||
|
"name": "自然精修"
|
||||||
|
},
|
||||||
|
"requested_at": "2026-08-11T07:30:15.123Z",
|
||||||
|
"started_at": null,
|
||||||
|
"finished_at": null,
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "atmosphere",
|
||||||
|
"asset": null,
|
||||||
|
"latest_generation": {
|
||||||
|
"job_id": "job_01K2E4",
|
||||||
|
"status": "queued",
|
||||||
|
"is_replacement": false,
|
||||||
|
"template": {
|
||||||
|
"id": "tpl_atmosphere_09",
|
||||||
|
"name": "暖阳"
|
||||||
|
},
|
||||||
|
"requested_at": "2026-08-11T07:30:15.123Z",
|
||||||
|
"started_at": null,
|
||||||
|
"finished_at": null,
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"revision": 8,
|
||||||
|
"created_at": "2026-08-09T01:00:00.000Z",
|
||||||
|
"updated_at": "2026-08-11T07:30:15.123Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
必须返回 `job.id`、`quota_reservation` 和更新后的 `affected_projects`。客户端用 `remaining_after_reservation` 立即更新剩余额度,并更新 Cell 状态;已有 `asset` 的重修 Tab 可同步显示处理态,首次生成且 `asset = null` 的槽位不显示 Tab。
|
||||||
|
|
||||||
|
### 查询 AI 修图任务
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/yf-handset-app/photog/travel-album/ai-retouch-job-info?user_equity_travel_id=88&job_id=job_01K2E4
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"id": "job_01K2E4",
|
||||||
|
"user_equity_travel_id": "88",
|
||||||
|
"scope": "batch",
|
||||||
|
"status": "partially_succeeded",
|
||||||
|
"source_material_ids": ["2031", "2032", "2033", "2034"],
|
||||||
|
"progress": {
|
||||||
|
"total": 9,
|
||||||
|
"queued": 0,
|
||||||
|
"processing": 0,
|
||||||
|
"succeeded": 8,
|
||||||
|
"failed": 1
|
||||||
|
},
|
||||||
|
"quota_settlement": {
|
||||||
|
"status": "settled",
|
||||||
|
"reserved_units": 8,
|
||||||
|
"consumed_units": 7,
|
||||||
|
"released_units": 1,
|
||||||
|
"cover_units": 0
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"source_material_id": "2031",
|
||||||
|
"output_type": "refined",
|
||||||
|
"status": "succeeded",
|
||||||
|
"result_asset_id": "asset_9201",
|
||||||
|
"error": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source_material_id": "2032",
|
||||||
|
"output_type": "atmosphere",
|
||||||
|
"status": "failed",
|
||||||
|
"result_asset_id": null,
|
||||||
|
"error": {
|
||||||
|
"code": "AI_PROVIDER_TIMEOUT",
|
||||||
|
"message": "AI 服务处理超时,请重试",
|
||||||
|
"retryable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source_material_id": null,
|
||||||
|
"output_type": "cover",
|
||||||
|
"status": "succeeded",
|
||||||
|
"result_material_id": "cover_301",
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_at": "2026-08-11T07:30:15.123Z",
|
||||||
|
"started_at": "2026-08-11T07:30:18.000Z",
|
||||||
|
"finished_at": "2026-08-11T07:33:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 整体状态 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `queued` | 已受理,尚无子任务开始 |
|
||||||
|
| `processing` | 至少一个子任务正在执行,且尚未全部完成 |
|
||||||
|
| `succeeded` | 所有子任务成功 |
|
||||||
|
| `partially_succeeded` | 子任务全部结束,且既有成功也有失败 |
|
||||||
|
| `failed` | 子任务全部结束,没有任何成功结果 |
|
||||||
|
| `canceled` | 任务因素材删除或管理操作被取消 |
|
||||||
|
|
||||||
|
额度结算规则:
|
||||||
|
|
||||||
|
- 任务提交成功时先预占所有非封面子任务的额度,封面不进入预占。
|
||||||
|
- 每个精修/氛围感子任务成功后,将对应预占转为实际消耗。
|
||||||
|
- 子任务失败或取消时释放对应预占。因此部分成功示例中,预占 8 次、成功消耗 7 次、失败退回 1 次。
|
||||||
|
- `quota_settlement.status` 取值为 `reserved | partially_settled | settled`。
|
||||||
|
|
||||||
|
客户端如只关心当前预览图,优先调用单素材接口;本接口主要用于批量进度、问题排查和任务详情。
|
||||||
|
|
||||||
|
### 批量删除素材
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/yf-handset-app/photog/travel-album/delete-material
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_equity_travel_id": "88",
|
||||||
|
"material_ids": ["2031", "2032", "cover_301"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
为兼容旧客户端,原单张请求体继续有效:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "2031"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
新请求使用 `user_equity_travel_id + material_ids`,旧请求使用 `id`,两组参数不同时传入。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "删除成功",
|
||||||
|
"data": {
|
||||||
|
"deleted_material_ids": ["2031", "2032", "cover_301"],
|
||||||
|
"deleted_variant_asset_ids": ["asset_9101", "asset_9102"],
|
||||||
|
"canceled_job_ids": ["job_01K2E4"],
|
||||||
|
"oss_cleanup_status": "scheduled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
语义:
|
||||||
|
|
||||||
|
- 传入原图 ID:级联软删除精修图、氛围感图,并取消该原图的未完成子任务。
|
||||||
|
- 传入独立封面 ID:只删除该封面素材。
|
||||||
|
- 删除原图不级联删除以它为输入之一的已生成封面。
|
||||||
|
- 数据库业务删除采用一个事务,默认全部成功或全部失败。
|
||||||
|
- 任一 ID 不属于当前相册或无权删除时,整次请求失败。已删除的 ID 可视为幂等成功。
|
||||||
|
- OSS 文件清理异步执行,不影响业务删除成功。
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# AI 修图需求文档
|
||||||
|
|
||||||
|
> 本文档描述旅拍相册中 AI 修图的产品需求和业务规则。后端接口建议见同目录《AI 修图接口文档》。
|
||||||
|
|
||||||
|
## 1. 目标与范围
|
||||||
|
|
||||||
|
- 相册管理页面展示用户已在其他流程上传到 OSS、并已在后端登记的图片素材。
|
||||||
|
- 用户可从网格页批量发起 AI 修图,也可从单图预览页首次修图或重新修图。
|
||||||
|
- 精修图和氛围感图是某张原图的“关联图”;封面图是相册内的独立素材,不归属于任何一张原图。
|
||||||
|
- AI 生成为异步长流程,页面必须能分辨已提交、排队、处理、成功和失败。
|
||||||
|
|
||||||
|
## 2. 核心概念
|
||||||
|
|
||||||
|
### 2.1 原图项目
|
||||||
|
|
||||||
|
一张原图与它的精修图、氛围感图共同组成一个原图项目。原图的素材 ID 是项目的稳定标识,OSS URL 不作为业务标识。
|
||||||
|
|
||||||
|
### 2.2 关联图
|
||||||
|
|
||||||
|
- `refined`:精修图,一张原图最多保留一个当前有效版本。
|
||||||
|
- `atmosphere`:氛围感图,一张原图最多保留一个当前有效版本。
|
||||||
|
- 关联图不作为独立 Cell 出现在相册网格中,只在该原图的预览页 Tab 中展示。
|
||||||
|
|
||||||
|
### 2.3 封面图
|
||||||
|
|
||||||
|
- 一次选择不少于 4 张原图时,必须选择封面模板。
|
||||||
|
- 封面模板使用本次选中的多张原图生成封面图。
|
||||||
|
- 封面图不关联某张原图,生成成功后作为 `cover` 类型的独立素材出现在相册网格中。
|
||||||
|
- 删除参与生成封面的原图时,不级联删除已生成的独立封面图。
|
||||||
|
|
||||||
|
## 3. 相册管理页
|
||||||
|
|
||||||
|
### 3.1 网格列表
|
||||||
|
|
||||||
|
- 网格展示原图和独立封面图,不直接展示精修图、氛围感图。
|
||||||
|
- 列表需支持稳定分页。AI 任务执行期间可能插入新的封面素材,不应造成重复或漏项。
|
||||||
|
- 点击原图 Cell 进入图片预览页,可横向滑动切换其他原图项目。
|
||||||
|
|
||||||
|
### 3.2 Cell 修图状态
|
||||||
|
|
||||||
|
Cell 左上角展示稳定的修图状态:
|
||||||
|
|
||||||
|
| 状态 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| 已上传 | 原图已登记,从未提交 AI 修图,当前也没有任务 |
|
||||||
|
| 待处理 | AI 任务已提交,正在排队 |
|
||||||
|
| 修图中 | AI 任务已开始执行 |
|
||||||
|
| AI 已修 | 最新任务已成功,存在有效 AI 结果 |
|
||||||
|
| 失败 | 最新任务执行失败,可能仍保留上一版成功结果 |
|
||||||
|
|
||||||
|
状态以后端返回的枚举代码为准,客户端自行映射中文文案,不依赖可变的中文状态名。
|
||||||
|
|
||||||
|
### 3.3 批量 AI 修图
|
||||||
|
|
||||||
|
1. 用户进入多选模式并选择一张或多张原图。
|
||||||
|
2. 点击底部“AI 修图”,弹出模板选择页。
|
||||||
|
3. 精修模板必选且单选。
|
||||||
|
4. 氛围感模板可选且单选,已选时可取消。
|
||||||
|
5. 选中原图数量小于 4 时不展示封面模板。
|
||||||
|
6. 选中原图数量大于等于 4 时展示封面模板,且必须单选一个。
|
||||||
|
7. 提交后,每张原图分别生成精修图,选了氛围感模板时再分别生成氛围感图。
|
||||||
|
8. 选了封面模板时,整个批次额外生成独立封面图。
|
||||||
|
9. 提交成功指后端已受理任务,不代表图片已生成。
|
||||||
|
|
||||||
|
### 3.4 修图额度
|
||||||
|
|
||||||
|
- 模板选择弹窗的底部固定显示当前用户可用的 AI 修图剩余额度,文案例如“剩余 12 次”,不随模板列表滚动消失。
|
||||||
|
- 精修和氛围感生成会消耗修图额度;封面模板生成不消耗任何修图额度。
|
||||||
|
- 本需求默认按生成结果张数计费:每张原图的精修结果消耗 1 次,每张原图的氛围感结果消耗 1 次,封面结果消耗 0 次。最终单价由后端返回,客户端不硬编码。
|
||||||
|
- 重新修图与首次修图使用相同的额度规则。
|
||||||
|
- 后端受理任务时预占本次所需额度,避免用户并发提交造成超额。
|
||||||
|
- 只对最终生成成功的精修/氛围感结果扣减额度;失败或取消的子任务释放对应预占额度。
|
||||||
|
- 如剩余可用额度不足,提交按钮禁用并显示明确提示;后端提交接口仍必须做最终校验。
|
||||||
|
|
||||||
|
### 3.5 批量删除
|
||||||
|
|
||||||
|
- 用户多选原图后点击“删除”,删除选中原图及各自的精修图、氛围感图,并取消关联的未完成任务。已有任务记录保留用于审计和排查,但不再对客户端展示已删除素材。
|
||||||
|
- 已生成的独立封面图不在级联删除范围内,除非用户明确选中该封面素材本身。
|
||||||
|
- 批量删除应只调用一次后端接口,后端应返回整体或逐项结果,不由客户端循环调用单删接口。
|
||||||
|
|
||||||
|
## 4. 图片预览页
|
||||||
|
|
||||||
|
### 4.1 横向切换
|
||||||
|
|
||||||
|
- 从网格点击某张原图进入预览页。
|
||||||
|
- 横向滑动在相册内的不同原图项目之间切换。
|
||||||
|
- 切换到新的原图项目时,默认显示其“原图”。
|
||||||
|
|
||||||
|
### 4.2 底部操作
|
||||||
|
|
||||||
|
页面底部固定显示三个按钮:
|
||||||
|
|
||||||
|
- AI 修图
|
||||||
|
- 删除
|
||||||
|
- 刷新
|
||||||
|
|
||||||
|
### 4.3 关联图 Tab
|
||||||
|
|
||||||
|
- 如果当前原图没有任何已生成的关联图,不显示 Tab;首次生成任务排队或处理期间仍不显示。
|
||||||
|
- 存在至少一张已成功的关联图时显示 Tab。
|
||||||
|
- Tab 最多包含“原图”、“精修后”、“氛围感”;关联图 Tab 只在对应图片已生成时显示。
|
||||||
|
- 点击 Tab 切换当前原图项目内的展示图片,不切换原图项目。
|
||||||
|
- 某个已有结果的 Tab 重新提交修图后,仍可展示上一版图片,同时通过该结果槽位的任务状态展示“待处理/修图中/失败”。
|
||||||
|
- 首次生成尚无图片 URL 时,通过原图 Cell 状态和项目任务状态表达进度,不创建空 Tab。
|
||||||
|
|
||||||
|
### 4.4 预览页删除
|
||||||
|
|
||||||
|
- 删除的对象始终是当前原图项目,而不是当前 Tab 上的单张关联图。
|
||||||
|
- 删除原图及其所有关联图,并取消关联的未完成任务;历史任务记录保留用于审计。
|
||||||
|
- 删除成功后显示下一个原图项目;如果删除的是末项,则回退上一项;无剩余项时关闭预览页。
|
||||||
|
|
||||||
|
### 4.5 预览页 AI 修图
|
||||||
|
|
||||||
|
| 当前情况 | 模板要求 | 生成与覆盖规则 |
|
||||||
|
|---|---|---|
|
||||||
|
| 不显示 Tab | 精修必选,氛围感可选 | 首次生成关联的精修图,可选生成氛围感图 |
|
||||||
|
| 已显示 Tab,当前为“原图” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 |
|
||||||
|
| 已显示 Tab,当前为“精修后” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 |
|
||||||
|
| 已显示 Tab,当前为“氛围感” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 |
|
||||||
|
|
||||||
|
重新修图期间应保留上一版成功图片可见;只有新结果成功时才原子替换当前版本。失败时继续保留旧版本,并返回可展示的错误信息。
|
||||||
|
|
||||||
|
### 4.6 刷新
|
||||||
|
|
||||||
|
- 点击“刷新”只获取当前显示原图项目的最新数据,包括原图、关联图、各结果槽位的任务状态和整体修图状态。
|
||||||
|
- 不应为刷新一张图重新拉取整个分页列表。
|
||||||
|
- 服务端对已替换的图片应返回新的资源版本或新 URL,避免 CDN/客户端缓存继续显示旧图。
|
||||||
|
|
||||||
|
## 5. 异步任务规则
|
||||||
|
|
||||||
|
- 每次提交返回唯一 `job_id`、服务端接收时间和当前状态。
|
||||||
|
- 每次提交同时返回额度预占数、提交前剩余额度和预占后可用额度。
|
||||||
|
- 任务状态至少包含:`queued`、`processing`、`succeeded`、`partially_succeeded`、`failed`、`canceled`。
|
||||||
|
- 精修、氛围感和封面子任务可以独立成功或失败,服务端需保留逐原图、逐结果类型的状态。
|
||||||
|
- 客户端重试同一次提交时不得创建重复任务,由幂等键保证。
|
||||||
|
- 同一原图、同一结果类型存在未完成任务时,服务端拒绝再次提交,避免旧任务晚完成后覆盖新结果。
|
||||||
|
|
||||||
|
## 6. 删除与数据一致性
|
||||||
|
|
||||||
|
- 删除原图项目需在一个后端业务操作中完成:标记原图删除、标记关联图删除、取消未完成任务。
|
||||||
|
- 数据库状态成功后再异步清理 OSS 文件,不应因 OSS 删除失败导致客户端删除失败。
|
||||||
|
- AI 回调必须检查任务和素材是否已删除或已被新版本替代,过期回调不得恢复已删除素材或覆盖新结果。
|
||||||
|
|
||||||
|
## 7. 异常与提示
|
||||||
|
|
||||||
|
- 模板加载失败:保留页面并允许重试。
|
||||||
|
- 模板已下线:提交时返回明确错误码,客户端刷新模板列表。
|
||||||
|
- 素材不存在、不属于当前相册或不是原图:整次提交不受理,并返回问题素材 ID。
|
||||||
|
- 数量不满足封面规则:返回稳定错误码和最小数量。
|
||||||
|
- 任务部分成功:保留已成功结果,失败槽位提供错误码、错误信息和是否可重试。
|
||||||
|
- 网络超时:客户端使用原幂等键重试,不可产生两个任务。
|
||||||
|
|
||||||
|
## 8. 权限与校验
|
||||||
|
|
||||||
|
- 后端必须从登录态校验用户对相册、原图和模板的访问权,不信任客户端传入的用户 ID、景区 ID 或批次 ID。
|
||||||
|
- 模板是否适用于相册所属景区,由后端根据相册 ID 推导和校验。
|
||||||
|
- 后端负责校验精修、氛围感和封面模板的必选/可选规则,客户端校验只用于交互提示。
|
||||||
|
|
||||||
|
## 9. 默认产品决策
|
||||||
|
|
||||||
|
为使接口和异步覆盖行为可实现,本文档采用以下默认决策:
|
||||||
|
|
||||||
|
1. 重新修图不先删旧图;新图成功后才原子切换,失败仍保留旧图。
|
||||||
|
2. 在“原图” Tab 重新修图时,若用户未选氛围感模板,保留现有氛围感图,只替换精修图。
|
||||||
|
3. 封面图只在生成成功后插入网格;生成前由所选原图的修图状态和任务详情表达进度。
|
||||||
|
4. 每个关联图类型只对外暴露一个当前有效版本;历史版本是否长期保留属于后端存储策略,不在当前客户端功能中展示。
|
||||||
|
|
||||||
|
> 第 2 条是对“氛围感模板可选”的安全解释:未选不等于删除旧结果。如产品希望未选时删除旧氛围感图,应增加明确的“移除氛围感”操作,不建议让后端根据字段缺失隐式删除。
|
||||||
@@ -29,6 +29,9 @@ protocol TravelAlbumServing {
|
|||||||
isPurchased: Int?
|
isPurchased: Int?
|
||||||
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial>
|
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial>
|
||||||
|
|
||||||
|
/// 拉取单个相册素材及其最新关联图片信息。
|
||||||
|
func materialInfo(userEquityTravelId: Int, materialId: Int) async throws -> TravelAlbumMaterial
|
||||||
|
|
||||||
/// 上传并登记旅拍相册素材。
|
/// 上传并登记旅拍相册素材。
|
||||||
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial
|
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial
|
||||||
|
|
||||||
@@ -38,8 +41,8 @@ protocol TravelAlbumServing {
|
|||||||
/// 删除旅拍相册。
|
/// 删除旅拍相册。
|
||||||
func deleteAlbum(id: Int) async throws
|
func deleteAlbum(id: Int) async throws
|
||||||
|
|
||||||
/// 删除旅拍相册素材。
|
/// 批量删除旅拍相册素材。
|
||||||
func deleteMaterial(id: Int) async throws
|
func batchDeleteMaterials(ids: [Int]) async throws
|
||||||
|
|
||||||
/// 拉取相册小程序码。
|
/// 拉取相册小程序码。
|
||||||
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse
|
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse
|
||||||
@@ -48,7 +51,20 @@ protocol TravelAlbumServing {
|
|||||||
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse
|
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse
|
||||||
|
|
||||||
/// 提交相册素材 AI 修图任务。
|
/// 提交相册素材 AI 修图任务。
|
||||||
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws
|
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission
|
||||||
|
|
||||||
|
/// 提交单张素材重新修图任务。
|
||||||
|
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws -> TravelAlbumAIJobSubmission
|
||||||
|
|
||||||
|
/// 拉取当前账号的 AI 修图任务列表。
|
||||||
|
func aiRetouchJobList(
|
||||||
|
statusGroup: TravelAlbumAIJobFilter,
|
||||||
|
limit: Int,
|
||||||
|
cursor: String?
|
||||||
|
) async throws -> TravelAlbumAIJobListResponse
|
||||||
|
|
||||||
|
/// 拉取指定 AI 修图任务详情。
|
||||||
|
func aiRetouchJobInfo(batchId: Int) async throws -> TravelAlbumAIJobDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -119,6 +135,20 @@ final class TravelAlbumAPI: TravelAlbumServing {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 拉取单个相册素材及其最新关联图片信息。
|
||||||
|
func materialInfo(userEquityTravelId: Int, materialId: Int) async throws -> TravelAlbumMaterial {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "\(basePath)/material-info",
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "user_equity_travel_id", value: String(userEquityTravelId)),
|
||||||
|
URLQueryItem(name: "material_id", value: String(materialId)),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// 上传并登记旅拍相册素材。
|
/// 上传并登记旅拍相册素材。
|
||||||
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
|
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
|
||||||
try await client.send(APIRequest(method: .post, path: "\(basePath)/upload-material", body: request))
|
try await client.send(APIRequest(method: .post, path: "\(basePath)/upload-material", body: request))
|
||||||
@@ -144,10 +174,14 @@ final class TravelAlbumAPI: TravelAlbumServing {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除旅拍相册素材。
|
/// 批量删除旅拍相册素材。
|
||||||
func deleteMaterial(id: Int) async throws {
|
func batchDeleteMaterials(ids: [Int]) async throws {
|
||||||
let _: EmptyPayload = try await client.send(
|
let _: EmptyPayload = try await client.send(
|
||||||
APIRequest(method: .post, path: "\(basePath)/delete-material", body: TravelAlbumIDRequest(id: id))
|
APIRequest(
|
||||||
|
method: .post,
|
||||||
|
path: "\(basePath)/batch-delete-material",
|
||||||
|
body: TravelAlbumBatchDeleteMaterialRequest(ids: ids)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,10 +207,42 @@ final class TravelAlbumAPI: TravelAlbumServing {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 提交相册素材 AI 修图任务;服务端 data 内容无需客户端消费。
|
/// 提交相册素材 AI 修图任务并返回任务摘要。
|
||||||
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws {
|
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission {
|
||||||
let _: EmptyPayload = try await client.send(
|
try await client.send(APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request))
|
||||||
APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request)
|
}
|
||||||
|
|
||||||
|
/// 提交重新修图任务并返回任务摘要。
|
||||||
|
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws -> TravelAlbumAIJobSubmission {
|
||||||
|
try await client.send(APIRequest(method: .post, path: "\(basePath)/ai-reretouch", body: request))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取当前账号的 AI 修图任务列表。
|
||||||
|
func aiRetouchJobList(
|
||||||
|
statusGroup: TravelAlbumAIJobFilter,
|
||||||
|
limit: Int = 20,
|
||||||
|
cursor: String? = nil
|
||||||
|
) async throws -> TravelAlbumAIJobListResponse {
|
||||||
|
var queryItems = [
|
||||||
|
URLQueryItem(name: "status_group", value: statusGroup.rawValue),
|
||||||
|
URLQueryItem(name: "limit", value: String(min(50, max(1, limit)))),
|
||||||
|
]
|
||||||
|
if let cursor = cursor?.trimmingCharacters(in: .whitespacesAndNewlines), !cursor.isEmpty {
|
||||||
|
queryItems.append(URLQueryItem(name: "cursor", value: cursor))
|
||||||
|
}
|
||||||
|
return try await client.send(
|
||||||
|
APIRequest(method: .get, path: "\(basePath)/ai-retouch-job-list", queryItems: queryItems)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取指定 AI 修图任务详情。
|
||||||
|
func aiRetouchJobInfo(batchId: Int) async throws -> TravelAlbumAIJobDetail {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "\(basePath)/ai-retouch-job-info",
|
||||||
|
queryItems: [URLQueryItem(name: "ai_retouch_batch_id", value: String(batchId))]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,3 +251,8 @@ final class TravelAlbumAPI: TravelAlbumServing {
|
|||||||
private struct TravelAlbumIDRequest: Encodable, Sendable {
|
private struct TravelAlbumIDRequest: Encodable, Sendable {
|
||||||
let id: Int
|
let id: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 旅拍相册素材批量删除请求体。
|
||||||
|
private struct TravelAlbumBatchDeleteMaterialRequest: Encodable, Sendable {
|
||||||
|
let ids: [Int]
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// AI 修图任务筛选分组,对应任务列表接口的 `status_group`。
|
||||||
|
enum TravelAlbumAIJobFilter: String, CaseIterable, Sendable, Hashable {
|
||||||
|
case all
|
||||||
|
case inProgress = "in_progress"
|
||||||
|
case completed
|
||||||
|
case failed
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .all: "全部"
|
||||||
|
case .inProgress: "进行中"
|
||||||
|
case .completed: "已完成"
|
||||||
|
case .failed: "失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务及子任务状态;未知值安全降级,避免新增后端状态导致整页解码失败。
|
||||||
|
enum TravelAlbumAIJobStatus: Sendable, Hashable {
|
||||||
|
case queued
|
||||||
|
case processing
|
||||||
|
case succeeded
|
||||||
|
case partiallySucceeded
|
||||||
|
case failed
|
||||||
|
case canceled
|
||||||
|
case unknown(String)
|
||||||
|
|
||||||
|
init(rawValue: String) {
|
||||||
|
switch rawValue {
|
||||||
|
case "queued": self = .queued
|
||||||
|
case "processing": self = .processing
|
||||||
|
case "succeeded": self = .succeeded
|
||||||
|
case "partially_succeeded": self = .partiallySucceeded
|
||||||
|
case "failed": self = .failed
|
||||||
|
case "canceled": self = .canceled
|
||||||
|
default: self = .unknown(rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawValue: String {
|
||||||
|
switch self {
|
||||||
|
case .queued: "queued"
|
||||||
|
case .processing: "processing"
|
||||||
|
case .succeeded: "succeeded"
|
||||||
|
case .partiallySucceeded: "partially_succeeded"
|
||||||
|
case .failed: "failed"
|
||||||
|
case .canceled: "canceled"
|
||||||
|
case .unknown(let value): value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isInProgress: Bool { self == .queued || self == .processing }
|
||||||
|
var isTerminal: Bool { !isInProgress && !isUnknown }
|
||||||
|
private var isUnknown: Bool { if case .unknown = self { true } else { false } }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .queued: "排队中"
|
||||||
|
case .processing: "修图中"
|
||||||
|
case .succeeded: "已完成"
|
||||||
|
case .partiallySucceeded: "部分完成"
|
||||||
|
case .failed: "失败"
|
||||||
|
case .canceled: "已取消"
|
||||||
|
case .unknown: "状态更新中"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TravelAlbumAIJobStatus: Decodable {
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
self.init(rawValue: (try? decoder.singleValueContainer().decode(String.self)) ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图输出类型。
|
||||||
|
enum TravelAlbumAIJobOutputType: Sendable, Hashable {
|
||||||
|
case refined
|
||||||
|
case atmosphere
|
||||||
|
case cover
|
||||||
|
case unknown(String)
|
||||||
|
|
||||||
|
init(rawValue: String) {
|
||||||
|
switch rawValue {
|
||||||
|
case "refined": self = .refined
|
||||||
|
case "atmosphere": self = .atmosphere
|
||||||
|
case "cover": self = .cover
|
||||||
|
default: self = .unknown(rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .refined: "原图精修"
|
||||||
|
case .atmosphere: "氛围感"
|
||||||
|
case .cover: "封面"
|
||||||
|
case .unknown: "其他结果"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var previewKind: TravelAlbumPreviewAssetKind? {
|
||||||
|
switch self {
|
||||||
|
case .refined: .retouched
|
||||||
|
case .atmosphere: .atmosphere
|
||||||
|
case .cover: .cover
|
||||||
|
case .unknown: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TravelAlbumAIJobOutputType: Decodable {
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
self.init(rawValue: (try? decoder.singleValueContainer().decode(String.self)) ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图子任务数量进度。
|
||||||
|
struct TravelAlbumAIJobProgress: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let total: Int
|
||||||
|
let queued: Int
|
||||||
|
let processing: Int
|
||||||
|
let succeeded: Int
|
||||||
|
let failed: Int
|
||||||
|
let canceled: Int
|
||||||
|
|
||||||
|
var completed: Int { min(total, succeeded + failed + canceled) }
|
||||||
|
var fraction: Double { total > 0 ? min(1, Double(completed) / Double(total)) : 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图提交成功后返回的任务摘要。
|
||||||
|
struct TravelAlbumAIJobSubmission: Decodable, Sendable, Equatable {
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
|
let userEquityTravelId: Int
|
||||||
|
let status: TravelAlbumAIJobStatus
|
||||||
|
let progress: TravelAlbumAIJobProgress
|
||||||
|
let createdAt: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||||
|
case userEquityTravelId = "user_equity_travel_id"
|
||||||
|
case status, progress
|
||||||
|
case createdAt = "created_at"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务所属相册摘要。
|
||||||
|
struct TravelAlbumAIJobAlbum: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let userPhone: String
|
||||||
|
let coverURL: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name
|
||||||
|
case userPhone = "user_phone"
|
||||||
|
case coverURL = "cover_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务计划输出数量。
|
||||||
|
struct TravelAlbumAIJobOutput: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let type: TravelAlbumAIJobOutputType
|
||||||
|
let count: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务列表缩略图。
|
||||||
|
struct TravelAlbumAIJobPreviewImage: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let materialId: Int
|
||||||
|
let thumbnailURL: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case materialId = "material_id"
|
||||||
|
case thumbnailURL = "thumbnail_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务列表项。
|
||||||
|
struct TravelAlbumAIJobSummary: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||||
|
var id: Int { aiRetouchBatchId }
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
|
let userEquityTravelId: Int
|
||||||
|
let scope: String
|
||||||
|
let status: TravelAlbumAIJobStatus
|
||||||
|
let album: TravelAlbumAIJobAlbum
|
||||||
|
let sourceCount: Int
|
||||||
|
let outputs: [TravelAlbumAIJobOutput]
|
||||||
|
let previewImages: [TravelAlbumAIJobPreviewImage]
|
||||||
|
let progress: TravelAlbumAIJobProgress
|
||||||
|
let estimatedFinishAt: String?
|
||||||
|
let failureSummary: String?
|
||||||
|
let createdAt: String
|
||||||
|
let startedAt: String?
|
||||||
|
let finishedAt: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||||
|
case userEquityTravelId = "user_equity_travel_id"
|
||||||
|
case scope, status, album, outputs, progress
|
||||||
|
case sourceCount = "source_count"
|
||||||
|
case previewImages = "preview_images"
|
||||||
|
case estimatedFinishAt = "estimated_finish_at"
|
||||||
|
case failureSummary = "failure_summary"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case startedAt = "started_at"
|
||||||
|
case finishedAt = "finished_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayFailureSummary: String? {
|
||||||
|
guard status == .failed || status == .partiallySucceeded || progress.failed > 0 else { return nil }
|
||||||
|
let value = failureSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return value.isEmpty ? "部分照片处理失败,点击查看原因" : value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务游标分页响应。
|
||||||
|
struct TravelAlbumAIJobListResponse: Decodable, Sendable, Equatable {
|
||||||
|
let items: [TravelAlbumAIJobSummary]
|
||||||
|
let nextCursor: String?
|
||||||
|
let hasMore: Bool
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case items
|
||||||
|
case nextCursor = "next_cursor"
|
||||||
|
case hasMore = "has_more"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图额度结算信息。
|
||||||
|
struct TravelAlbumAIJobQuotaSettlement: Decodable, Sendable, Equatable {
|
||||||
|
let status: String
|
||||||
|
let reservedUnits: Int
|
||||||
|
let consumedUnits: Int
|
||||||
|
let releasedUnits: Int
|
||||||
|
let coverUnits: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case status
|
||||||
|
case reservedUnits = "reserved_units"
|
||||||
|
case consumedUnits = "consumed_units"
|
||||||
|
case releasedUnits = "released_units"
|
||||||
|
case coverUnits = "cover_units"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图目标的来源素材。
|
||||||
|
struct TravelAlbumAIJobSourceMaterial: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let fileName: String
|
||||||
|
let thumbnailURL: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case fileName = "file_name"
|
||||||
|
case thumbnailURL = "thumbnail_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图目标使用的模板摘要。
|
||||||
|
struct TravelAlbumAIJobTemplate: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图成功结果资源。
|
||||||
|
struct TravelAlbumAIJobResultAsset: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let materialId: Int
|
||||||
|
let url: String
|
||||||
|
let thumbnailURL: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case materialId = "material_id"
|
||||||
|
case url
|
||||||
|
case thumbnailURL = "thumbnail_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 可直接展示给用户的 AI 修图失败信息。
|
||||||
|
struct TravelAlbumAIJobError: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let code: String
|
||||||
|
let message: String
|
||||||
|
let retryable: Bool
|
||||||
|
|
||||||
|
var displayMessage: String {
|
||||||
|
let value = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return value.isEmpty ? "处理失败,请前往相册重新修图" : value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单个输出目标的处理明细。
|
||||||
|
struct TravelAlbumAIJobTarget: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||||
|
var id: Int { targetId }
|
||||||
|
let targetId: Int
|
||||||
|
let sourceMaterial: TravelAlbumAIJobSourceMaterial?
|
||||||
|
let inputMaterialIds: [Int]
|
||||||
|
let outputType: TravelAlbumAIJobOutputType
|
||||||
|
let template: TravelAlbumAIJobTemplate?
|
||||||
|
let status: TravelAlbumAIJobStatus
|
||||||
|
let resultAsset: TravelAlbumAIJobResultAsset?
|
||||||
|
let error: TravelAlbumAIJobError?
|
||||||
|
let createdAt: String
|
||||||
|
let startedAt: String?
|
||||||
|
let finishedAt: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case targetId = "target_id"
|
||||||
|
case sourceMaterial = "source_material"
|
||||||
|
case inputMaterialIds = "input_material_ids"
|
||||||
|
case outputType = "output_type"
|
||||||
|
case template, status, error
|
||||||
|
case resultAsset = "result_asset"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case startedAt = "started_at"
|
||||||
|
case finishedAt = "finished_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayFailureMessage: String? {
|
||||||
|
guard status == .failed else { return nil }
|
||||||
|
return error?.displayMessage ?? "处理失败,请前往相册重新修图"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务完整详情。
|
||||||
|
struct TravelAlbumAIJobDetail: Decodable, Sendable, Equatable {
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
|
let userEquityTravelId: Int
|
||||||
|
let scope: String
|
||||||
|
let status: TravelAlbumAIJobStatus
|
||||||
|
let album: TravelAlbumAIJobAlbum
|
||||||
|
let sourceCount: Int
|
||||||
|
let outputs: [TravelAlbumAIJobOutput]
|
||||||
|
let progress: TravelAlbumAIJobProgress
|
||||||
|
let quotaSettlement: TravelAlbumAIJobQuotaSettlement
|
||||||
|
let targets: [TravelAlbumAIJobTarget]
|
||||||
|
let estimatedFinishAt: String?
|
||||||
|
let createdAt: String
|
||||||
|
let startedAt: String?
|
||||||
|
let finishedAt: String?
|
||||||
|
let durationSeconds: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||||
|
case userEquityTravelId = "user_equity_travel_id"
|
||||||
|
case scope, status, album, outputs, progress, targets
|
||||||
|
case sourceCount = "source_count"
|
||||||
|
case quotaSettlement = "quota_settlement"
|
||||||
|
case estimatedFinishAt = "estimated_finish_at"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case startedAt = "started_at"
|
||||||
|
case finishedAt = "finished_at"
|
||||||
|
case durationSeconds = "duration_seconds"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务日期展示工具。
|
||||||
|
enum TravelAlbumAIJobDateFormatter {
|
||||||
|
private static let internetFormatter = ISO8601DateFormatter()
|
||||||
|
private static let preciseFormatter: ISO8601DateFormatter = {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func date(_ value: String?) -> Date? {
|
||||||
|
guard let value, !value.isEmpty else { return nil }
|
||||||
|
return preciseFormatter.date(from: value) ?? internetFormatter.date(from: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func display(_ value: String?) -> String {
|
||||||
|
guard let date = date(value) else { return "--" }
|
||||||
|
return date.formatted(.dateTime.month().day().hour().minute())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func time(_ value: String?) -> String {
|
||||||
|
guard let date = date(value) else { return "--" }
|
||||||
|
return date.formatted(.dateTime.hour().minute())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -129,6 +129,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
let isPurchased: Bool
|
let isPurchased: Bool
|
||||||
let aiRetouchStatus: Int
|
let aiRetouchStatus: Int
|
||||||
let aiRetouchStatusName: String
|
let aiRetouchStatusName: String
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
let aiRefinedURL: String
|
let aiRefinedURL: String
|
||||||
let aiAtmosphereURL: String
|
let aiAtmosphereURL: String
|
||||||
let createdAt: String
|
let createdAt: String
|
||||||
@@ -148,6 +149,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
case isPurchased = "is_purchased"
|
case isPurchased = "is_purchased"
|
||||||
case aiRetouchStatus = "ai_retouch_status"
|
case aiRetouchStatus = "ai_retouch_status"
|
||||||
case aiRetouchStatusName = "ai_retouch_status_name"
|
case aiRetouchStatusName = "ai_retouch_status_name"
|
||||||
|
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||||
case aiRefinedURL = "ai_refined_url"
|
case aiRefinedURL = "ai_refined_url"
|
||||||
case aiAtmosphereURL = "ai_atmosphere_url"
|
case aiAtmosphereURL = "ai_atmosphere_url"
|
||||||
case createdAt = "created_at"
|
case createdAt = "created_at"
|
||||||
@@ -172,6 +174,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
aiRetouchStatusName = (
|
aiRetouchStatusName = (
|
||||||
try? container.decodeIfPresent(String.self, forKey: .aiRetouchStatusName)
|
try? container.decodeIfPresent(String.self, forKey: .aiRetouchStatusName)
|
||||||
) ?? ""
|
) ?? ""
|
||||||
|
aiRetouchBatchId = (try? container.decodeIfPresent(Int.self, forKey: .aiRetouchBatchId)) ?? 0
|
||||||
aiRefinedURL = (try? container.decodeIfPresent(String.self, forKey: .aiRefinedURL)) ?? ""
|
aiRefinedURL = (try? container.decodeIfPresent(String.self, forKey: .aiRefinedURL)) ?? ""
|
||||||
aiAtmosphereURL = (
|
aiAtmosphereURL = (
|
||||||
try? container.decodeIfPresent(String.self, forKey: .aiAtmosphereURL)
|
try? container.decodeIfPresent(String.self, forKey: .aiAtmosphereURL)
|
||||||
@@ -194,6 +197,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
isPurchased: Bool = false,
|
isPurchased: Bool = false,
|
||||||
aiRetouchStatus: Int = 0,
|
aiRetouchStatus: Int = 0,
|
||||||
aiRetouchStatusName: String = "",
|
aiRetouchStatusName: String = "",
|
||||||
|
aiRetouchBatchId: Int = 0,
|
||||||
aiRefinedURL: String = "",
|
aiRefinedURL: String = "",
|
||||||
aiAtmosphereURL: String = "",
|
aiAtmosphereURL: String = "",
|
||||||
createdAt: String = "",
|
createdAt: String = "",
|
||||||
@@ -212,6 +216,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
self.isPurchased = isPurchased
|
self.isPurchased = isPurchased
|
||||||
self.aiRetouchStatus = aiRetouchStatus
|
self.aiRetouchStatus = aiRetouchStatus
|
||||||
self.aiRetouchStatusName = aiRetouchStatusName
|
self.aiRetouchStatusName = aiRetouchStatusName
|
||||||
|
self.aiRetouchBatchId = aiRetouchBatchId
|
||||||
self.aiRefinedURL = aiRefinedURL
|
self.aiRefinedURL = aiRefinedURL
|
||||||
self.aiAtmosphereURL = aiAtmosphereURL
|
self.aiAtmosphereURL = aiAtmosphereURL
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
@@ -219,7 +224,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 相册素材网格角标类别,用于稳定映射文案优先级和语义颜色。
|
/// 相册素材网格角标类别,用于稳定映射独立业务状态和语义颜色。
|
||||||
enum TravelAlbumMaterialBadgeKind: Sendable, Equatable {
|
enum TravelAlbumMaterialBadgeKind: Sendable, Equatable {
|
||||||
case purchased
|
case purchased
|
||||||
case pending
|
case pending
|
||||||
@@ -236,14 +241,15 @@ struct TravelAlbumMaterialBadgePresentation: Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension TravelAlbumMaterial {
|
extension TravelAlbumMaterial {
|
||||||
/// 按 AI 修图状态和购买状态生成网格角标;返回 nil 时隐藏角标。
|
/// 生成购买状态角标;购买状态与修图状态互不覆盖。
|
||||||
var badgePresentation: TravelAlbumMaterialBadgePresentation? {
|
var purchaseBadgePresentation: TravelAlbumMaterialBadgePresentation? {
|
||||||
if aiRetouchStatus == 0 {
|
isPurchased
|
||||||
return isPurchased
|
? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
||||||
? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
: nil
|
||||||
: nil
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
/// 生成 AI 修图状态角标;未进入修图流程时返回 nil。
|
||||||
|
var aiRetouchBadgePresentation: TravelAlbumMaterialBadgePresentation? {
|
||||||
let statusName = aiRetouchStatusName.trimmingCharacters(in: .whitespacesAndNewlines)
|
let statusName = aiRetouchStatusName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
switch aiRetouchStatus {
|
switch aiRetouchStatus {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -342,23 +348,107 @@ struct TravelAlbumMpCodeResponse: Decodable, Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AI 修图模板,包含业务 ID、展示名称和预览图地址。
|
/// AI 修图模板类别,决定页面分组、选择规则和提交字段。
|
||||||
|
enum TravelAlbumAIRetouchTemplateCategory: Int, Sendable, Hashable {
|
||||||
|
case refined
|
||||||
|
case atmosphere
|
||||||
|
case cover
|
||||||
|
|
||||||
|
/// 模板分组展示标题。
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .refined: "原图精修"
|
||||||
|
case .atmosphere: "氛围感修图"
|
||||||
|
case .cover: "封面风格"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图模板页工作流,明确区分首次批量修图与单张结果覆盖重修。
|
||||||
|
enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable {
|
||||||
|
/// 对一个相册内的原始素材发起首次 AI 修图。
|
||||||
|
case initial(albumId: Int, materialIds: [Int])
|
||||||
|
/// 对单张素材的既有 AI 结果发起覆盖重修。
|
||||||
|
case reretouch(materialId: Int, batchId: Int, type: TravelAlbumAIReretouchType)
|
||||||
|
|
||||||
|
/// 当前页面需要展示的模板分组。
|
||||||
|
var visibleCategories: [TravelAlbumAIRetouchTemplateCategory] {
|
||||||
|
switch self {
|
||||||
|
case .initial(_, let materialIds):
|
||||||
|
var categories: [TravelAlbumAIRetouchTemplateCategory] = [.refined, .atmosphere]
|
||||||
|
if materialIds.count >= 4 { categories.append(.cover) }
|
||||||
|
return categories
|
||||||
|
case .reretouch(_, _, .refined):
|
||||||
|
return [.refined]
|
||||||
|
case .reretouch(_, _, .atmosphere):
|
||||||
|
return [.atmosphere]
|
||||||
|
case .reretouch(_, _, .all):
|
||||||
|
return [.refined, .atmosphere]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前分组是否允许不选择;首次修图仅氛围感选填,已有 AI 结果的预览重修两类模板均选填。
|
||||||
|
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
|
||||||
|
switch self {
|
||||||
|
case .initial:
|
||||||
|
return category == .atmosphere
|
||||||
|
case .reretouch(_, _, .all):
|
||||||
|
return category == .refined || category == .atmosphere
|
||||||
|
case .reretouch:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 工作流目标是否满足接口的最小参数要求。
|
||||||
|
var isValid: Bool {
|
||||||
|
switch self {
|
||||||
|
case .initial(let albumId, let materialIds):
|
||||||
|
return albumId > 0 && !materialIds.isEmpty
|
||||||
|
case .reretouch(let materialId, let batchId, _):
|
||||||
|
return materialId > 0 && batchId > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图模板,包含业务 ID、展示名称、卡片预览图及前后对比图片地址。
|
||||||
struct TravelAlbumAIRetouchTemplate: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
struct TravelAlbumAIRetouchTemplate: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let name: String
|
let name: String
|
||||||
let previewURL: String
|
let previewURL: String
|
||||||
|
let beforeURL: String
|
||||||
|
let afterURL: String
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case name
|
case name
|
||||||
case previewURL = "preview_url"
|
case previewURL = "preview_url"
|
||||||
|
case beforeURL = "before_url"
|
||||||
|
case afterURL = "after_url"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建 AI 修图模板。
|
/// 创建 AI 修图模板。
|
||||||
init(id: Int, name: String, previewURL: String) {
|
init(
|
||||||
|
id: Int,
|
||||||
|
name: String,
|
||||||
|
previewURL: String,
|
||||||
|
beforeURL: String = "",
|
||||||
|
afterURL: String = ""
|
||||||
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
self.previewURL = previewURL
|
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)) ?? ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,22 +457,35 @@ struct TravelAlbumAIRetouchTemplatesResponse: Decodable, Sendable, Equatable {
|
|||||||
let refinedTemplates: [TravelAlbumAIRetouchTemplate]
|
let refinedTemplates: [TravelAlbumAIRetouchTemplate]
|
||||||
let atmosphereTemplates: [TravelAlbumAIRetouchTemplate]
|
let atmosphereTemplates: [TravelAlbumAIRetouchTemplate]
|
||||||
let coverTemplates: [TravelAlbumAIRetouchTemplate]
|
let coverTemplates: [TravelAlbumAIRetouchTemplate]
|
||||||
|
let remainingQuota: Int
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case refinedTemplates = "refined_templates"
|
case refinedTemplates = "refined_templates"
|
||||||
case atmosphereTemplates = "atmosphere_templates"
|
case atmosphereTemplates = "atmosphere_templates"
|
||||||
case coverTemplates = "cover_templates"
|
case coverTemplates = "cover_templates"
|
||||||
|
case remainingQuota = "remaining_quota"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建分组模板响应,默认各组为空。
|
/// 创建分组模板响应,默认各组为空。
|
||||||
init(
|
init(
|
||||||
refinedTemplates: [TravelAlbumAIRetouchTemplate] = [],
|
refinedTemplates: [TravelAlbumAIRetouchTemplate] = [],
|
||||||
atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = [],
|
atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = [],
|
||||||
coverTemplates: [TravelAlbumAIRetouchTemplate] = []
|
coverTemplates: [TravelAlbumAIRetouchTemplate] = [],
|
||||||
|
remainingQuota: Int = 0
|
||||||
) {
|
) {
|
||||||
self.refinedTemplates = refinedTemplates
|
self.refinedTemplates = refinedTemplates
|
||||||
self.atmosphereTemplates = atmosphereTemplates
|
self.atmosphereTemplates = atmosphereTemplates
|
||||||
self.coverTemplates = coverTemplates
|
self.coverTemplates = coverTemplates
|
||||||
|
self.remainingQuota = max(0, remainingQuota)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解码模板和剩余额度;旧响应缺少额度时按零处理,避免误提交付费任务。
|
||||||
|
init(from decoder: any Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
refinedTemplates = try container.decode([TravelAlbumAIRetouchTemplate].self, forKey: .refinedTemplates)
|
||||||
|
atmosphereTemplates = try container.decode([TravelAlbumAIRetouchTemplate].self, forKey: .atmosphereTemplates)
|
||||||
|
coverTemplates = try container.decode([TravelAlbumAIRetouchTemplate].self, forKey: .coverTemplates)
|
||||||
|
remainingQuota = max(0, try container.decodeIfPresent(Int.self, forKey: .remainingQuota) ?? 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,14 +495,38 @@ struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable {
|
|||||||
let materialIds: [Int]
|
let materialIds: [Int]
|
||||||
let refinedTemplateId: Int
|
let refinedTemplateId: Int
|
||||||
let atmosphereTemplateId: Int?
|
let atmosphereTemplateId: Int?
|
||||||
let aiPreviewTabs: Int?
|
let coverTemplateId: Int?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case userEquityTravelId = "user_equity_travel_id"
|
case userEquityTravelId = "user_equity_travel_id"
|
||||||
case materialIds = "material_ids"
|
case materialIds = "material_ids"
|
||||||
case refinedTemplateId = "refined_template_id"
|
case refinedTemplateId = "refined_template_id"
|
||||||
case atmosphereTemplateId = "atmosphere_template_id"
|
case atmosphereTemplateId = "atmosphere_template_id"
|
||||||
case aiPreviewTabs = "ai_preview_tabs"
|
case coverTemplateId = "cover_template_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重新修图类型,决定后端覆盖的 AI 结果及必需模板字段。
|
||||||
|
enum TravelAlbumAIReretouchType: Int, Encodable, Sendable, Equatable {
|
||||||
|
case refined = 1
|
||||||
|
case atmosphere = 2
|
||||||
|
case all = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交单张素材重新修图的最小请求参数。
|
||||||
|
struct TravelAlbumAIReretouchRequest: Encodable, Sendable, Equatable {
|
||||||
|
let id: Int
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
|
let type: TravelAlbumAIReretouchType
|
||||||
|
let refinedTemplateId: Int?
|
||||||
|
let atmosphereTemplateId: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||||
|
case type
|
||||||
|
case refinedTemplateId = "refined_template_id"
|
||||||
|
case atmosphereTemplateId = "atmosphere_template_id"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,32 @@
|
|||||||
|
|
||||||
import Foundation
|
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 {
|
enum TravelAlbumPreviewSwipeMode: Sendable, Equatable {
|
||||||
/// 横滑只切换原图项目,关联图通过 Tab 切换。
|
/// 横滑只切换原图项目,关联图通过 Tab 切换。
|
||||||
@@ -65,6 +91,7 @@ struct TravelAlbumPreviewAsset: Identifiable, Sendable, Hashable {
|
|||||||
/// 一张原图及其所有关联图片组成的预览项目。
|
/// 一张原图及其所有关联图片组成的预览项目。
|
||||||
struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
||||||
let originalMaterialId: Int
|
let originalMaterialId: Int
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
let assets: [TravelAlbumPreviewAsset]
|
let assets: [TravelAlbumPreviewAsset]
|
||||||
|
|
||||||
var id: Int { originalMaterialId }
|
var id: Int { originalMaterialId }
|
||||||
@@ -84,9 +111,23 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
|||||||
assets.first { $0.kind == kind }
|
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 精修、氛围感结果图。
|
/// 将素材映射为原图及实际存在的 AI 精修、氛围感结果图。
|
||||||
init(material: TravelAlbumMaterial) {
|
init(material: TravelAlbumMaterial) {
|
||||||
originalMaterialId = material.id
|
originalMaterialId = material.id
|
||||||
|
aiRetouchBatchId = material.aiRetouchBatchId
|
||||||
var mappedAssets = [
|
var mappedAssets = [
|
||||||
TravelAlbumPreviewAsset(
|
TravelAlbumPreviewAsset(
|
||||||
id: "original-\(material.id)",
|
id: "original-\(material.id)",
|
||||||
@@ -127,11 +168,32 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 创建包含关联图片的项目,主要供适配器和测试使用。
|
/// 创建包含关联图片的项目,主要供适配器和测试使用。
|
||||||
init(originalMaterialId: Int, assets: [TravelAlbumPreviewAsset]) {
|
init(originalMaterialId: Int, aiRetouchBatchId: Int = 0, assets: [TravelAlbumPreviewAsset]) {
|
||||||
self.originalMaterialId = originalMaterialId
|
self.originalMaterialId = originalMaterialId
|
||||||
|
self.aiRetouchBatchId = aiRetouchBatchId
|
||||||
var seenKinds = Set<TravelAlbumPreviewAssetKind>()
|
var seenKinds = Set<TravelAlbumPreviewAssetKind>()
|
||||||
self.assets = assets.filter { seenKinds.insert($0.kind).inserted }
|
self.assets = assets.filter { seenKinds.insert($0.kind).inserted }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 根据是否已有 AI 结果生成首次修图或双模板覆盖重修工作流。
|
||||||
|
func aiRetouchWorkflow(
|
||||||
|
albumId: Int,
|
||||||
|
selectedKind: TravelAlbumPreviewAssetKind
|
||||||
|
) -> TravelAlbumAIRetouchWorkflow? {
|
||||||
|
guard hasVariants else {
|
||||||
|
return .initial(albumId: albumId, materialIds: [originalMaterialId])
|
||||||
|
}
|
||||||
|
guard selectedKind != .cover else { return nil }
|
||||||
|
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TravelAlbumAIRetouchTemplate {
|
||||||
|
/// 使用接口返回的模板示例前后图构建对比页内容。
|
||||||
|
var comparisonContent: BeforeAfterComparisonContent? {
|
||||||
|
let content = BeforeAfterComparisonContent(beforeURL: beforeURL, afterURL: afterURL)
|
||||||
|
return content.isValid ? content : nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 预览分页节点,记录当前图片所属项目及类型。
|
/// 预览分页节点,记录当前图片所属项目及类型。
|
||||||
@@ -180,31 +242,21 @@ enum TravelAlbumPreviewNavigator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 预览操作执行结果,便于后续替换真实业务接口。
|
/// 预览删除操作执行结果,统一表达成功、失败与暂不可用状态。
|
||||||
enum TravelAlbumPreviewActionResult: Sendable, Equatable {
|
enum TravelAlbumPreviewActionResult: Sendable, Equatable {
|
||||||
case success(String?)
|
case success(String?)
|
||||||
case failure(String)
|
case failure(String)
|
||||||
case unavailable(String)
|
case unavailable(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 预览页底部操作协议,所有操作均以原素材项目 ID 为目标。
|
/// 预览页删除操作协议,以原素材项目 ID 为目标。
|
||||||
protocol TravelAlbumPreviewActionHandling: AnyObject {
|
protocol TravelAlbumPreviewActionHandling: AnyObject {
|
||||||
func requestAIRetouch(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult
|
|
||||||
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult
|
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult
|
||||||
func refreshProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 新接口接入前的占位操作实现,不修改任何业务数据。
|
/// 预览删除接口接入前的占位操作实现,不修改任何业务数据。
|
||||||
final class PlaceholderTravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
|
final class PlaceholderTravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
|
||||||
func requestAIRetouch(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
|
||||||
.unavailable("AI修图功能待接口接入")
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
||||||
.unavailable("项目删除接口待接入")
|
.unavailable("项目删除接口待接入")
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
|
||||||
.unavailable("关联图片刷新接口待接入")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//
|
||||||
|
// BeforeAfterComparisonViewModel.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 前后对比分隔线的共享运动参数与计算规则。
|
||||||
|
enum BeforeAfterComparisonMotion {
|
||||||
|
static let automaticCenter: CGFloat = 0.5
|
||||||
|
static let automaticAmplitude: CGFloat = 0.3
|
||||||
|
static let automaticSpeed: Double = 0.8
|
||||||
|
static let manualRange: ClosedRange<CGFloat> = 0.05 ... 0.95
|
||||||
|
|
||||||
|
/// 返回指定时间点的自动往返位置,运动范围固定为 20% 至 80%。
|
||||||
|
static func automaticFraction(elapsed: TimeInterval) -> CGFloat {
|
||||||
|
automaticCenter + CGFloat(sin(elapsed * automaticSpeed)) * automaticAmplitude
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将手动拖动位置限制在两侧 5% 的安全边界内。
|
||||||
|
static func clampedManualFraction(_ fraction: CGFloat) -> CGFloat {
|
||||||
|
min(manualRange.upperBound, max(manualRange.lowerBound, fraction))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算从当前位置恢复正向自动运动所需的正弦相位时间。
|
||||||
|
static func phaseTime(for fraction: CGFloat) -> TimeInterval {
|
||||||
|
let normalized = Double(
|
||||||
|
min(1, max(-1, (fraction - automaticCenter) / automaticAmplitude))
|
||||||
|
)
|
||||||
|
return asin(normalized) / automaticSpeed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 前后对比图片在可用区域内完整展示时的等比布局计算。
|
||||||
|
enum BeforeAfterComparisonLayout {
|
||||||
|
static let fallbackAspectRatio: CGFloat = 3.0 / 4.0
|
||||||
|
|
||||||
|
/// 从图片尺寸提取有效宽高比,异常尺寸回退为 3:4。
|
||||||
|
static func aspectRatio(for imageSize: CGSize) -> CGFloat {
|
||||||
|
guard imageSize.width > 0, imageSize.height > 0 else { return fallbackAspectRatio }
|
||||||
|
let ratio = imageSize.width / imageSize.height
|
||||||
|
return ratio.isFinite && ratio > 0 ? ratio : fallbackAspectRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在最大宽高内返回不裁剪、不拉伸的最大显示尺寸。
|
||||||
|
static func fittedSize(aspectRatio: CGFloat, maximumSize: CGSize) -> CGSize {
|
||||||
|
guard maximumSize.width > 0, maximumSize.height > 0 else { return .zero }
|
||||||
|
let ratio = aspectRatio.isFinite && aspectRatio > 0 ? aspectRatio : fallbackAspectRatio
|
||||||
|
if maximumSize.width / maximumSize.height > ratio {
|
||||||
|
return CGSize(width: maximumSize.height * ratio, height: maximumSize.height)
|
||||||
|
}
|
||||||
|
return CGSize(width: maximumSize.width, height: maximumSize.width / ratio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 前后图片对比页的只读状态,负责校验并解析两张远程图片地址。
|
||||||
|
final class BeforeAfterComparisonViewModel {
|
||||||
|
let content: BeforeAfterComparisonContent
|
||||||
|
|
||||||
|
/// 创建指定内容的前后对比页状态。
|
||||||
|
init(content: BeforeAfterComparisonContent) {
|
||||||
|
self.content = content
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 原图远程地址。
|
||||||
|
var beforeImageURL: URL? {
|
||||||
|
URL(string: content.beforeURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 效果图远程地址。
|
||||||
|
var afterImageURL: URL? {
|
||||||
|
URL(string: content.afterURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 两张图片地址都可供页面加载时返回 true。
|
||||||
|
var isValid: Bool {
|
||||||
|
content.isValid && beforeImageURL != nil && afterImageURL != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// AI 修图任务列表状态,负责筛选、游标分页、去重和静默刷新。
|
||||||
|
final class TravelAlbumAIJobListViewModel {
|
||||||
|
private(set) var items: [TravelAlbumAIJobSummary] = []
|
||||||
|
private(set) var selectedFilter: TravelAlbumAIJobFilter = .all
|
||||||
|
private(set) var isLoading = false
|
||||||
|
private(set) var isRefreshing = false
|
||||||
|
private(set) var isLoadingMore = false
|
||||||
|
private(set) var errorMessage: String?
|
||||||
|
private var nextCursor: String?
|
||||||
|
private var hasMore = false
|
||||||
|
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
|
||||||
|
var containsInProgressJobs: Bool { items.contains { $0.status.isInProgress } }
|
||||||
|
var canLoadMore: Bool { hasMore && !isLoadingMore }
|
||||||
|
|
||||||
|
/// 选择筛选项并重新加载第一页。
|
||||||
|
func selectFilter(_ filter: TravelAlbumAIJobFilter, api: any TravelAlbumServing) async {
|
||||||
|
guard selectedFilter != filter else { return }
|
||||||
|
selectedFilter = filter
|
||||||
|
items = []
|
||||||
|
notify()
|
||||||
|
await loadFirstPage(api: api)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首次加载或下拉刷新第一页。
|
||||||
|
func loadFirstPage(api: any TravelAlbumServing, refreshing: Bool = false, silent: Bool = false) async {
|
||||||
|
guard !isLoading && !isRefreshing else { return }
|
||||||
|
if refreshing { isRefreshing = true } else if !silent { isLoading = true }
|
||||||
|
errorMessage = nil
|
||||||
|
notify()
|
||||||
|
defer {
|
||||||
|
isLoading = false
|
||||||
|
isRefreshing = false
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
await request(cursor: nil, reset: true, api: api, allowCursorRecovery: false, silent: silent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载下一页。
|
||||||
|
func loadMore(api: any TravelAlbumServing) async {
|
||||||
|
guard canLoadMore, let cursor = nextCursor else { return }
|
||||||
|
isLoadingMore = true
|
||||||
|
notify()
|
||||||
|
defer { isLoadingMore = false; notify() }
|
||||||
|
await request(cursor: cursor, reset: false, api: api, allowCursorRecovery: true, silent: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func request(
|
||||||
|
cursor: String?,
|
||||||
|
reset: Bool,
|
||||||
|
api: any TravelAlbumServing,
|
||||||
|
allowCursorRecovery: Bool,
|
||||||
|
silent: Bool
|
||||||
|
) async {
|
||||||
|
do {
|
||||||
|
let response = try await api.aiRetouchJobList(
|
||||||
|
statusGroup: selectedFilter,
|
||||||
|
limit: 20,
|
||||||
|
cursor: cursor
|
||||||
|
)
|
||||||
|
if reset {
|
||||||
|
items = response.items
|
||||||
|
} else {
|
||||||
|
var known = Set(items.map(\.id))
|
||||||
|
items += response.items.filter { known.insert($0.id).inserted }
|
||||||
|
}
|
||||||
|
nextCursor = response.nextCursor
|
||||||
|
hasMore = response.hasMore && response.nextCursor?.isEmpty == false
|
||||||
|
errorMessage = nil
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
let message = error.localizedDescription
|
||||||
|
if allowCursorRecovery && message.localizedCaseInsensitiveContains("cursor") {
|
||||||
|
nextCursor = nil
|
||||||
|
hasMore = false
|
||||||
|
await request(cursor: nil, reset: true, api: api, allowCursorRecovery: false, silent: silent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if items.isEmpty { errorMessage = message.isEmpty ? "任务加载失败" : message }
|
||||||
|
if !silent { onShowMessage?(message.isEmpty ? "任务加载失败" : message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notify() { onStateChange?() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务详情状态,负责刷新、404 识别和终态判断。
|
||||||
|
final class TravelAlbumAIJobDetailViewModel {
|
||||||
|
private(set) var detail: TravelAlbumAIJobDetail?
|
||||||
|
private(set) var isLoading = false
|
||||||
|
private(set) var isRefreshing = false
|
||||||
|
private(set) var errorMessage: String?
|
||||||
|
private(set) var isNotFound = false
|
||||||
|
|
||||||
|
let batchId: Int
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
var onNotFound: (() -> Void)?
|
||||||
|
|
||||||
|
init(batchId: Int) { self.batchId = batchId }
|
||||||
|
|
||||||
|
var shouldPoll: Bool { detail?.status.isInProgress == true }
|
||||||
|
|
||||||
|
/// 拉取任务详情;静默刷新失败时保留现有内容。
|
||||||
|
func load(api: any TravelAlbumServing, refreshing: Bool = false, silent: Bool = false) async {
|
||||||
|
guard batchId > 0, !isLoading && !isRefreshing else {
|
||||||
|
if batchId <= 0 { markNotFound() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if refreshing { isRefreshing = true } else if detail == nil && !silent { isLoading = true }
|
||||||
|
errorMessage = nil
|
||||||
|
notify()
|
||||||
|
defer {
|
||||||
|
isLoading = false
|
||||||
|
isRefreshing = false
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
detail = try await api.aiRetouchJobInfo(batchId: batchId)
|
||||||
|
isNotFound = false
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch APIError.httpStatus(let status, _) where status == 404 {
|
||||||
|
markNotFound()
|
||||||
|
} catch {
|
||||||
|
let message = error.localizedDescription.isEmpty ? "任务详情加载失败" : error.localizedDescription
|
||||||
|
if detail == nil { errorMessage = message }
|
||||||
|
if !silent { onShowMessage?(message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func markNotFound() {
|
||||||
|
guard !isNotFound else { return }
|
||||||
|
isNotFound = true
|
||||||
|
onNotFound?()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notify() { onStateChange?() }
|
||||||
|
}
|
||||||
@@ -5,22 +5,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// AI 修图模板类别,决定页面分组、选择规则和提交字段。
|
/// AI 修图模板选择状态,负责实时加载、按工作流单选、校验和任务提交。
|
||||||
enum TravelAlbumAIRetouchTemplateCategory: Int, Sendable, Hashable {
|
|
||||||
case refined
|
|
||||||
case atmosphere
|
|
||||||
case cover
|
|
||||||
|
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case .refined: "原图精修"
|
|
||||||
case .atmosphere: "氛围感修图"
|
|
||||||
case .cover: "封面风格模板"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// AI 修图模板选择状态,负责实时加载、单选规则、校验和任务提交。
|
|
||||||
final class TravelAlbumAIRetouchTemplateViewModel {
|
final class TravelAlbumAIRetouchTemplateViewModel {
|
||||||
private(set) var refinedTemplates: [TravelAlbumAIRetouchTemplate] = []
|
private(set) var refinedTemplates: [TravelAlbumAIRetouchTemplate] = []
|
||||||
private(set) var atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = []
|
private(set) var atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = []
|
||||||
@@ -28,38 +13,113 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
private(set) var selectedRefinedTemplateId: Int?
|
private(set) var selectedRefinedTemplateId: Int?
|
||||||
private(set) var selectedAtmosphereTemplateId: Int?
|
private(set) var selectedAtmosphereTemplateId: Int?
|
||||||
private(set) var selectedCoverTemplateId: Int?
|
private(set) var selectedCoverTemplateId: Int?
|
||||||
|
private(set) var remainingQuota: Int?
|
||||||
private(set) var isLoading = false
|
private(set) var isLoading = false
|
||||||
private(set) var isSubmitting = false
|
private(set) var isSubmitting = false
|
||||||
private(set) var loadErrorMessage: String?
|
private(set) var loadErrorMessage: String?
|
||||||
|
|
||||||
let albumId: Int
|
|
||||||
let scenicId: Int
|
let scenicId: Int
|
||||||
let materialIds: [Int]
|
let workflow: TravelAlbumAIRetouchWorkflow
|
||||||
|
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
var onShowMessage: ((String) -> Void)?
|
var onShowMessage: ((String) -> Void)?
|
||||||
var onSubmitted: (() -> Void)?
|
var onSubmitted: ((TravelAlbumAIJobSubmission) -> Void)?
|
||||||
|
|
||||||
/// 创建模板选择状态;素材 ID 会排序并去重,确保提交稳定。
|
/// 创建首次 AI 修图模板状态;素材 ID 会排序并去重,确保提交稳定。
|
||||||
init(albumId: Int, scenicId: Int, materialIds: [Int]) {
|
convenience init(albumId: Int, scenicId: Int, materialIds: [Int]) {
|
||||||
self.albumId = albumId
|
self.init(
|
||||||
|
scenicId: scenicId,
|
||||||
|
workflow: .initial(albumId: albumId, materialIds: materialIds)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用明确工作流创建模板选择状态。
|
||||||
|
init(scenicId: Int, workflow: TravelAlbumAIRetouchWorkflow) {
|
||||||
self.scenicId = scenicId
|
self.scenicId = scenicId
|
||||||
self.materialIds = Array(Set(materialIds)).sorted()
|
switch workflow {
|
||||||
|
case .initial(let albumId, let materialIds):
|
||||||
|
self.workflow = .initial(
|
||||||
|
albumId: albumId,
|
||||||
|
materialIds: Array(Set(materialIds)).sorted()
|
||||||
|
)
|
||||||
|
case .reretouch:
|
||||||
|
self.workflow = workflow
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 选中不少于四张素材时才展示并要求选择封面模板。
|
/// 当前工作流需要展示的模板分组。
|
||||||
var showsCoverTemplates: Bool {
|
var visibleCategories: [TravelAlbumAIRetouchTemplateCategory] {
|
||||||
materialIds.count >= 4
|
workflow.visibleCategories
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 当前必选模板缺失时用于底部提示的文案。
|
/// 当前工作流选择的原始照片数量。
|
||||||
|
var selectedPhotoCount: Int {
|
||||||
|
switch workflow {
|
||||||
|
case .initial(_, let materialIds): materialIds.count
|
||||||
|
case .reretouch: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否展示首次修图的氛围感与封面生成说明。
|
||||||
|
var shouldShowInitialTips: Bool {
|
||||||
|
if case .initial = workflow { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首次修图页按照当前选图数量展示氛围感与封面生成规则。
|
||||||
|
var initialTipsText: String {
|
||||||
|
if visibleCategories.contains(.cover) {
|
||||||
|
return "Tips:氛围感修图为选填;封面风格为必选,将使用第一张照片另生成封面。"
|
||||||
|
}
|
||||||
|
return "Tips:氛围感修图为选填,选中后每张照片会额外生成1个独立结果。"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前分组是否为选填。
|
||||||
|
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
|
||||||
|
workflow.isOptional(category)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前选择预计消耗的修图次数;封面模板免费,不计入额度。
|
||||||
|
var requiredQuota: Int {
|
||||||
|
switch workflow {
|
||||||
|
case .initial(_, let materialIds):
|
||||||
|
let outputCount = 1 + (selectedAtmosphereTemplateId == nil ? 0 : 1)
|
||||||
|
return materialIds.count * outputCount
|
||||||
|
case .reretouch(_, _, let type):
|
||||||
|
switch type {
|
||||||
|
case .refined, .atmosphere:
|
||||||
|
return 1
|
||||||
|
case .all:
|
||||||
|
return (selectedRefinedTemplateId == nil ? 0 : 1)
|
||||||
|
+ (selectedAtmosphereTemplateId == nil ? 0 : 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前必选模板或业务参数缺失时用于底部提示的文案。
|
||||||
var validationMessage: String? {
|
var validationMessage: String? {
|
||||||
guard !isLoading, loadErrorMessage == nil else { return nil }
|
guard !isLoading, loadErrorMessage == nil else { return nil }
|
||||||
if refinedTemplates.isEmpty || selectedRefinedTemplateId == nil {
|
guard workflow.isValid else {
|
||||||
return "暂无可用的原图精修模板"
|
if case .reretouch = workflow {
|
||||||
|
return "当前图片缺少修图批次,请刷新后重试"
|
||||||
|
}
|
||||||
|
return "请选择要修图的照片"
|
||||||
}
|
}
|
||||||
if showsCoverTemplates, coverTemplates.isEmpty || selectedCoverTemplateId == nil {
|
for category in visibleCategories where !isOptional(category) {
|
||||||
return "暂无可用的封面风格模板"
|
if templates(for: category).isEmpty || selectedTemplateId(for: category) == nil {
|
||||||
|
return unavailableMessage(for: category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if case .reretouch(_, _, .all) = workflow,
|
||||||
|
selectedRefinedTemplateId == nil,
|
||||||
|
selectedAtmosphereTemplateId == nil {
|
||||||
|
return "请至少选择一个修图模板"
|
||||||
|
}
|
||||||
|
guard let remainingQuota else {
|
||||||
|
return "剩余修图次数获取失败,请刷新后重试"
|
||||||
|
}
|
||||||
|
if requiredQuota > remainingQuota {
|
||||||
|
return "剩余修图次数不足,需要\(requiredQuota)次,当前剩余\(remainingQuota)次"
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -69,12 +129,11 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
!isLoading
|
!isLoading
|
||||||
&& !isSubmitting
|
&& !isSubmitting
|
||||||
&& loadErrorMessage == nil
|
&& loadErrorMessage == nil
|
||||||
&& !materialIds.isEmpty
|
&& workflow.isValid
|
||||||
&& selectedRefinedTemplateId != nil
|
&& validationMessage == nil
|
||||||
&& (!showsCoverTemplates || selectedCoverTemplateId != nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 实时拉取当前景区模板并按规则设置默认选择。
|
/// 实时拉取当前景区模板并按工作流设置默认选择。
|
||||||
func loadTemplates(api: any TravelAlbumServing) async {
|
func loadTemplates(api: any TravelAlbumServing) async {
|
||||||
guard scenicId > 0 else {
|
guard scenicId > 0 else {
|
||||||
loadErrorMessage = "请先选择景区"
|
loadErrorMessage = "请先选择景区"
|
||||||
@@ -84,6 +143,7 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
guard !isLoading else { return }
|
guard !isLoading else { return }
|
||||||
isLoading = true
|
isLoading = true
|
||||||
loadErrorMessage = nil
|
loadErrorMessage = nil
|
||||||
|
remainingQuota = nil
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -91,9 +151,14 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
refinedTemplates = response.refinedTemplates
|
refinedTemplates = response.refinedTemplates
|
||||||
atmosphereTemplates = response.atmosphereTemplates
|
atmosphereTemplates = response.atmosphereTemplates
|
||||||
coverTemplates = response.coverTemplates
|
coverTemplates = response.coverTemplates
|
||||||
selectedRefinedTemplateId = refinedTemplates.first?.id
|
remainingQuota = response.remainingQuota
|
||||||
selectedAtmosphereTemplateId = nil
|
selectedRefinedTemplateId = visibleCategories.contains(.refined) && !isOptional(.refined)
|
||||||
selectedCoverTemplateId = showsCoverTemplates ? coverTemplates.first?.id : nil
|
? refinedTemplates.first?.id
|
||||||
|
: nil
|
||||||
|
selectedAtmosphereTemplateId = visibleCategories.contains(.atmosphere) && !isOptional(.atmosphere)
|
||||||
|
? atmosphereTemplates.first?.id
|
||||||
|
: nil
|
||||||
|
selectedCoverTemplateId = visibleCategories.contains(.cover) ? coverTemplates.first?.id : nil
|
||||||
isLoading = false
|
isLoading = false
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
@@ -106,6 +171,7 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
selectedRefinedTemplateId = nil
|
selectedRefinedTemplateId = nil
|
||||||
selectedAtmosphereTemplateId = nil
|
selectedAtmosphereTemplateId = nil
|
||||||
selectedCoverTemplateId = nil
|
selectedCoverTemplateId = nil
|
||||||
|
remainingQuota = nil
|
||||||
isLoading = false
|
isLoading = false
|
||||||
loadErrorMessage = error.localizedDescription.isEmpty ? "模板加载失败" : error.localizedDescription
|
loadErrorMessage = error.localizedDescription.isEmpty ? "模板加载失败" : error.localizedDescription
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
@@ -130,34 +196,27 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 选择模板;氛围感模板再次点击时取消,必选分组保持单选。
|
/// 选择模板;选填分组允许再次点击取消,必选分组保持单选。
|
||||||
func toggleTemplate(id: Int, category: TravelAlbumAIRetouchTemplateCategory) {
|
func toggleTemplate(id: Int, category: TravelAlbumAIRetouchTemplateCategory) {
|
||||||
guard templates(for: category).contains(where: { $0.id == id }) else { return }
|
guard visibleCategories.contains(category),
|
||||||
|
templates(for: category).contains(where: { $0.id == id })
|
||||||
|
else { return }
|
||||||
switch category {
|
switch category {
|
||||||
case .refined:
|
case .refined:
|
||||||
selectedRefinedTemplateId = id
|
selectedRefinedTemplateId = isOptional(category) && selectedRefinedTemplateId == id ? nil : id
|
||||||
case .atmosphere:
|
case .atmosphere:
|
||||||
selectedAtmosphereTemplateId = selectedAtmosphereTemplateId == id ? nil : id
|
selectedAtmosphereTemplateId = isOptional(category) && selectedAtmosphereTemplateId == id ? nil : id
|
||||||
case .cover:
|
case .cover:
|
||||||
guard showsCoverTemplates else { return }
|
|
||||||
selectedCoverTemplateId = id
|
selectedCoverTemplateId = id
|
||||||
}
|
}
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 提交 AI 修图任务,成功后通知页面退出选择态。
|
/// 按当前工作流提交首次修图或覆盖重修任务。
|
||||||
func submit(api: any TravelAlbumServing) async {
|
func submit(api: any TravelAlbumServing) async {
|
||||||
guard !isSubmitting else { return }
|
guard !isSubmitting else { return }
|
||||||
guard let refinedTemplateId = selectedRefinedTemplateId else {
|
guard canSubmit else {
|
||||||
onShowMessage?("请选择原图精修模板")
|
onShowMessage?(validationMessage ?? "当前无法提交AI修图")
|
||||||
return
|
|
||||||
}
|
|
||||||
guard !showsCoverTemplates || selectedCoverTemplateId != nil else {
|
|
||||||
onShowMessage?("请选择封面风格模板")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard !materialIds.isEmpty else {
|
|
||||||
onShowMessage?("请选择要修图的照片")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,16 +228,34 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await api.submitAIRetouch(
|
let submission: TravelAlbumAIJobSubmission
|
||||||
TravelAlbumAIRetouchRequest(
|
switch workflow {
|
||||||
userEquityTravelId: albumId,
|
case .initial(let albumId, let materialIds):
|
||||||
materialIds: materialIds,
|
guard let refinedTemplateId = selectedRefinedTemplateId else {
|
||||||
refinedTemplateId: refinedTemplateId,
|
onShowMessage?("请选择原图精修模板")
|
||||||
atmosphereTemplateId: selectedAtmosphereTemplateId,
|
return
|
||||||
aiPreviewTabs: showsCoverTemplates ? selectedCoverTemplateId : nil
|
}
|
||||||
|
submission = try await api.submitAIRetouch(
|
||||||
|
TravelAlbumAIRetouchRequest(
|
||||||
|
userEquityTravelId: albumId,
|
||||||
|
materialIds: materialIds,
|
||||||
|
refinedTemplateId: refinedTemplateId,
|
||||||
|
atmosphereTemplateId: selectedAtmosphereTemplateId,
|
||||||
|
coverTemplateId: selectedCoverTemplateId
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
case .reretouch(let materialId, let batchId, let type):
|
||||||
onSubmitted?()
|
submission = try await api.submitAIReretouch(
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: materialId,
|
||||||
|
aiRetouchBatchId: batchId,
|
||||||
|
type: type,
|
||||||
|
refinedTemplateId: type == .atmosphere ? nil : selectedRefinedTemplateId,
|
||||||
|
atmosphereTemplateId: type == .refined ? nil : selectedAtmosphereTemplateId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onSubmitted?(submission)
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
@@ -186,6 +263,14 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func unavailableMessage(for category: TravelAlbumAIRetouchTemplateCategory) -> String {
|
||||||
|
switch category {
|
||||||
|
case .refined: "暂无可用的原图精修模板"
|
||||||
|
case .atmosphere: "暂无可用的氛围感修图模板"
|
||||||
|
case .cover: "暂无可用的封面风格模板"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func notifyStateChange() {
|
private func notifyStateChange() {
|
||||||
onStateChange?()
|
onStateChange?()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ final class TravelAlbumDetailViewModel {
|
|||||||
private(set) var isRefreshing = false
|
private(set) var isRefreshing = false
|
||||||
private(set) var isSelectionMode = false
|
private(set) var isSelectionMode = false
|
||||||
private(set) var selectedMaterialIds: Set<Int> = []
|
private(set) var selectedMaterialIds: Set<Int> = []
|
||||||
|
private var selectedMaterialPurchaseStates: [Int: Bool] = [:]
|
||||||
|
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
var onShowMessage: ((String) -> Void)?
|
var onShowMessage: ((String) -> Void)?
|
||||||
@@ -142,12 +143,29 @@ final class TravelAlbumDetailViewModel {
|
|||||||
selectedTab == .all ? allPhotoCount : purchasedPhotoCount
|
selectedTab == .all ? allPhotoCount : purchasedPhotoCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 当前选中素材中的已购买数量。
|
||||||
|
var selectedPurchasedMaterialCount: Int {
|
||||||
|
selectedMaterialIds.count - selectedDeletableMaterialIds.count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除确认弹窗文案;选中已购素材时明确说明保护规则与实际删除数量。
|
||||||
|
var deleteSelectedConfirmationMessage: String {
|
||||||
|
let selectedCount = selectedMaterialIds.count
|
||||||
|
let purchasedCount = selectedPurchasedMaterialCount
|
||||||
|
guard purchasedCount > 0 else {
|
||||||
|
return "确定删除选中的 \(selectedCount) 张素材吗?"
|
||||||
|
}
|
||||||
|
let deletableCount = selectedCount - purchasedCount
|
||||||
|
return "已选择 \(selectedCount) 张素材,其中 \(purchasedCount) 张已购买。"
|
||||||
|
+ "不会删除已购买的项目,只会删除 \(deletableCount) 张未购买的项目。"
|
||||||
|
}
|
||||||
|
|
||||||
/// 切换素材 tab。
|
/// 切换素材 tab。
|
||||||
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
|
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
|
||||||
guard selectedTab != tab else { return }
|
guard selectedTab != tab else { return }
|
||||||
selectedTab = tab
|
selectedTab = tab
|
||||||
isSelectionMode = false
|
isSelectionMode = false
|
||||||
selectedMaterialIds = []
|
clearMaterialSelection()
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
await loadMaterials(reset: true, api: api)
|
await loadMaterials(reset: true, api: api)
|
||||||
}
|
}
|
||||||
@@ -207,12 +225,25 @@ final class TravelAlbumDetailViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 定向刷新单个素材,并同步替换网格列表中的对应缓存。
|
||||||
|
func refreshMaterial(id: Int, api: any TravelAlbumServing) async throws -> TravelAlbumMaterial {
|
||||||
|
let material = try await api.materialInfo(
|
||||||
|
userEquityTravelId: albumId,
|
||||||
|
materialId: id
|
||||||
|
)
|
||||||
|
guard material.id == id else { throw APIError.invalidResponse }
|
||||||
|
if let index = materials.firstIndex(where: { $0.id == id }) {
|
||||||
|
materials[index] = material
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
return material
|
||||||
|
}
|
||||||
|
|
||||||
/// 切换选择模式。
|
/// 切换选择模式。
|
||||||
func toggleSelectionMode() {
|
func toggleSelectionMode() {
|
||||||
guard selectedTab == .all else { return }
|
|
||||||
isSelectionMode.toggle()
|
isSelectionMode.toggle()
|
||||||
if !isSelectionMode {
|
if !isSelectionMode {
|
||||||
selectedMaterialIds = []
|
clearMaterialSelection()
|
||||||
}
|
}
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
@@ -220,14 +251,12 @@ final class TravelAlbumDetailViewModel {
|
|||||||
/// 切换素材选中状态。
|
/// 切换素材选中状态。
|
||||||
func toggleMaterialSelection(_ material: TravelAlbumMaterial) {
|
func toggleMaterialSelection(_ material: TravelAlbumMaterial) {
|
||||||
guard isSelectionMode else { return }
|
guard isSelectionMode else { return }
|
||||||
guard material.status == 1 else {
|
|
||||||
onShowMessage?("仅未购买素材可删除")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if selectedMaterialIds.contains(material.id) {
|
if selectedMaterialIds.contains(material.id) {
|
||||||
selectedMaterialIds.remove(material.id)
|
selectedMaterialIds.remove(material.id)
|
||||||
|
selectedMaterialPurchaseStates.removeValue(forKey: material.id)
|
||||||
} else {
|
} else {
|
||||||
selectedMaterialIds.insert(material.id)
|
selectedMaterialIds.insert(material.id)
|
||||||
|
selectedMaterialPurchaseStates[material.id] = material.isPurchased
|
||||||
}
|
}
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
@@ -235,15 +264,22 @@ final class TravelAlbumDetailViewModel {
|
|||||||
/// AI 修图任务提交成功后退出选择模式并清空当前选择。
|
/// AI 修图任务提交成功后退出选择模式并清空当前选择。
|
||||||
func completeAIRetouchSubmission() {
|
func completeAIRetouchSubmission() {
|
||||||
isSelectionMode = false
|
isSelectionMode = false
|
||||||
selectedMaterialIds = []
|
clearMaterialSelection()
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AI 修图提交成功后重置选择状态,并刷新相册摘要、数量与当前素材列表。
|
||||||
|
func refreshAfterAIRetouchSubmission(api: any TravelAlbumServing) async {
|
||||||
|
completeAIRetouchSubmission()
|
||||||
|
await refreshAll(api: api)
|
||||||
|
}
|
||||||
|
|
||||||
/// 预览页删除成功后移除对应素材,并同步全部/已购计数。
|
/// 预览页删除成功后移除对应素材,并同步全部/已购计数。
|
||||||
func removeMaterialAfterPreviewDeletion(id: Int) {
|
func removeMaterialAfterPreviewDeletion(id: Int) {
|
||||||
guard let index = materials.firstIndex(where: { $0.id == id }) else { return }
|
guard let index = materials.firstIndex(where: { $0.id == id }) else { return }
|
||||||
let material = materials.remove(at: index)
|
let material = materials.remove(at: index)
|
||||||
selectedMaterialIds.remove(id)
|
selectedMaterialIds.remove(id)
|
||||||
|
selectedMaterialPurchaseStates.removeValue(forKey: id)
|
||||||
allPhotoCount = max(0, allPhotoCount - 1)
|
allPhotoCount = max(0, allPhotoCount - 1)
|
||||||
if material.isPurchased {
|
if material.isPurchased {
|
||||||
purchasedPhotoCount = max(0, purchasedPhotoCount - 1)
|
purchasedPhotoCount = max(0, purchasedPhotoCount - 1)
|
||||||
@@ -253,18 +289,20 @@ final class TravelAlbumDetailViewModel {
|
|||||||
|
|
||||||
/// 删除已选素材。
|
/// 删除已选素材。
|
||||||
func deleteSelectedMaterials(api: any TravelAlbumServing) async {
|
func deleteSelectedMaterials(api: any TravelAlbumServing) async {
|
||||||
let ids = Array(selectedMaterialIds)
|
guard !selectedMaterialIds.isEmpty else {
|
||||||
guard !ids.isEmpty else {
|
|
||||||
onShowMessage?("请选择要删除的素材")
|
onShowMessage?("请选择要删除的素材")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let ids = selectedDeletableMaterialIds
|
||||||
|
guard !ids.isEmpty else {
|
||||||
|
onShowMessage?("选中的素材均已购买,未删除任何项目")
|
||||||
|
return
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
for id in ids {
|
try await api.batchDeleteMaterials(ids: ids)
|
||||||
try await api.deleteMaterial(id: id)
|
|
||||||
}
|
|
||||||
onShowMessage?("删除成功")
|
onShowMessage?("删除成功")
|
||||||
isSelectionMode = false
|
isSelectionMode = false
|
||||||
selectedMaterialIds = []
|
clearMaterialSelection()
|
||||||
await refreshAll(api: api)
|
await refreshAll(api: api)
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
return
|
||||||
@@ -289,4 +327,18 @@ final class TravelAlbumDetailViewModel {
|
|||||||
private func notifyStateChange() {
|
private func notifyStateChange() {
|
||||||
onStateChange?()
|
onStateChange?()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func clearMaterialSelection() {
|
||||||
|
selectedMaterialIds = []
|
||||||
|
selectedMaterialPurchaseStates = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectedDeletableMaterialIds: [Int] {
|
||||||
|
selectedMaterialIds
|
||||||
|
.filter { id in
|
||||||
|
guard selectedMaterialPurchaseStates[id] == false else { return false }
|
||||||
|
return materials.first(where: { $0.id == id })?.isPurchased != true
|
||||||
|
}
|
||||||
|
.sorted()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ final class TravelAlbumEntryViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 重新拉取相册列表。
|
/// 重新拉取相册列表。
|
||||||
func loadAlbums(api: any TravelAlbumServing) async {
|
func loadAlbums(
|
||||||
|
api: any TravelAlbumServing,
|
||||||
|
preservingContentOnFailure: Bool = false
|
||||||
|
) async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
defer {
|
defer {
|
||||||
@@ -67,12 +70,20 @@ final class TravelAlbumEntryViewModel {
|
|||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
albums = []
|
if !preservingContentOnFailure {
|
||||||
albumTotal = 0
|
albums = []
|
||||||
|
albumTotal = 0
|
||||||
|
}
|
||||||
onShowMessage?(error.localizedDescription)
|
onShowMessage?(error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 用户下拉刷新相册列表;失败时保留当前内容,避免页面瞬间清空。
|
||||||
|
func refreshAlbums(api: any TravelAlbumServing) async {
|
||||||
|
guard !isLoading else { return }
|
||||||
|
await loadAlbums(api: api, preservingContentOnFailure: true)
|
||||||
|
}
|
||||||
|
|
||||||
/// 打开创建相册弹窗。
|
/// 打开创建相册弹窗。
|
||||||
func openCreateSheet(api: any TravelAlbumServing) async {
|
func openCreateSheet(api: any TravelAlbumServing) async {
|
||||||
guard !isCreating else { return }
|
guard !isCreating else { return }
|
||||||
|
|||||||
@@ -14,15 +14,10 @@ final class TravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
|
|||||||
self.api = api
|
self.api = api
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AI 修图仍由相册选择态的模板流程发起。
|
|
||||||
func requestAIRetouch(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
|
||||||
.unavailable("请在相册中选择照片后使用AI修图")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 调用素材删除接口,仅在服务端确认成功后返回成功结果。
|
/// 调用素材删除接口,仅在服务端确认成功后返回成功结果。
|
||||||
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
||||||
do {
|
do {
|
||||||
try await api.deleteMaterial(id: originalMaterialId)
|
try await api.batchDeleteMaterials(ids: [originalMaterialId])
|
||||||
return .success("删除成功")
|
return .success("删除成功")
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return .failure("删除已取消")
|
return .failure("删除已取消")
|
||||||
@@ -31,9 +26,4 @@ final class TravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
|
|||||||
return .failure(message.isEmpty ? "删除失败" : message)
|
return .failure(message.isEmpty ? "删除失败" : message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 刷新关联结果图接口尚未提供。
|
|
||||||
func refreshProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
|
||||||
.unavailable("关联图片刷新接口待接入")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ final class MainTabBarController: UITabBarController {
|
|||||||
controller = PaymentCollectionDetailsViewController()
|
controller = PaymentCollectionDetailsViewController()
|
||||||
case .messageCenter:
|
case .messageCenter:
|
||||||
controller = MessageCenterViewController()
|
controller = MessageCenterViewController()
|
||||||
|
case .aiRetouchTaskList:
|
||||||
|
controller = TravelAlbumAIJobListViewController()
|
||||||
|
case .aiRetouchTaskDetail(let batchId):
|
||||||
|
controller = TravelAlbumAIJobDetailViewController(batchId: batchId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let controller {
|
if let controller {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ final class MessageDetailViewController: BaseViewController {
|
|||||||
|
|
||||||
private let viewModel: MessageDetailViewModel
|
private let viewModel: MessageDetailViewModel
|
||||||
private let api: any MessageCenterServing
|
private let api: any MessageCenterServing
|
||||||
|
private let travelAlbumAPI: any TravelAlbumServing
|
||||||
|
|
||||||
private let scrollView = UIScrollView()
|
private let scrollView = UIScrollView()
|
||||||
private let contentView = UIView()
|
private let contentView = UIView()
|
||||||
@@ -20,13 +21,19 @@ final class MessageDetailViewController: BaseViewController {
|
|||||||
private let timeContainer = UIView()
|
private let timeContainer = UIView()
|
||||||
private let timeLabel = UILabel()
|
private let timeLabel = UILabel()
|
||||||
private let bodyLabel = UILabel()
|
private let bodyLabel = UILabel()
|
||||||
|
private let taskDetailButton = UIButton(type: .system)
|
||||||
private let bottomBar = UIView()
|
private let bottomBar = UIView()
|
||||||
private let deleteButton = UIButton(type: .system)
|
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)
|
viewModel = MessageDetailViewModel(message: message)
|
||||||
self.api = api ?? NetworkServices.shared.messageCenterAPI
|
self.api = api ?? NetworkServices.shared.messageCenterAPI
|
||||||
|
self.travelAlbumAPI = travelAlbumAPI ?? NetworkServices.shared.travelAlbumAPI
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +74,15 @@ final class MessageDetailViewController: BaseViewController {
|
|||||||
bodyLabel.numberOfLines = 0
|
bodyLabel.numberOfLines = 0
|
||||||
bodyLabel.textAlignment = .left
|
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
|
bottomBar.backgroundColor = .white
|
||||||
deleteButton.setTitle("删除并返回", for: .normal)
|
deleteButton.setTitle("删除并返回", for: .normal)
|
||||||
deleteButton.setTitleColor(.white, for: .normal)
|
deleteButton.setTitleColor(.white, for: .normal)
|
||||||
@@ -77,7 +93,7 @@ final class MessageDetailViewController: BaseViewController {
|
|||||||
|
|
||||||
view.addSubview(scrollView)
|
view.addSubview(scrollView)
|
||||||
scrollView.addSubview(contentView)
|
scrollView.addSubview(contentView)
|
||||||
[typeImageView, titleLabel, timeContainer, bodyLabel].forEach(contentView.addSubview)
|
[typeImageView, titleLabel, timeContainer, bodyLabel, taskDetailButton].forEach(contentView.addSubview)
|
||||||
timeContainer.addSubview(timeLabel)
|
timeContainer.addSubview(timeLabel)
|
||||||
view.addSubview(bottomBar)
|
view.addSubview(bottomBar)
|
||||||
bottomBar.addSubview(deleteButton)
|
bottomBar.addSubview(deleteButton)
|
||||||
@@ -121,12 +137,23 @@ final class MessageDetailViewController: BaseViewController {
|
|||||||
bodyLabel.snp.makeConstraints { make in
|
bodyLabel.snp.makeConstraints { make in
|
||||||
make.top.equalTo(timeContainer.snp.bottom).offset(24)
|
make.top.equalTo(timeContainer.snp.bottom).offset(24)
|
||||||
make.leading.trailing.equalToSuperview().inset(32)
|
make.leading.trailing.equalToSuperview().inset(32)
|
||||||
make.bottom.lessThanOrEqualToSuperview().inset(24)
|
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() {
|
override func bindActions() {
|
||||||
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
||||||
|
taskDetailButton.addTarget(self, action: #selector(taskDetailTapped), for: .touchUpInside)
|
||||||
viewModel.onStateChange = { [weak self] in
|
viewModel.onStateChange = { [weak self] in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.applyState()
|
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() {
|
@objc private func deleteTapped() {
|
||||||
let alert = UIAlertController(title: "删除消息", message: "确定要删除这条消息吗?", preferredStyle: .alert)
|
let alert = UIAlertController(title: "删除消息", message: "确定要删除这条消息吗?", preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
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?() }
|
||||||
|
}
|
||||||
@@ -7,25 +7,26 @@ import Kingfisher
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// AI 修图模板选择 Sheet,展示三类横向模板列表、修图模式与固定底部操作。
|
/// AI 修图模板选择 Sheet,按工作流展示所需横向模板列表与固定底部操作。
|
||||||
final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||||
/// 页面使用的 collection section。
|
/// 页面使用的 collection section。
|
||||||
private enum Section: Hashable {
|
private enum Section: Hashable {
|
||||||
|
case overview
|
||||||
case templates(TravelAlbumAIRetouchTemplateCategory)
|
case templates(TravelAlbumAIRetouchTemplateCategory)
|
||||||
case mode
|
case mode
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 页面使用的 diffable item。
|
/// 页面使用的 diffable item。
|
||||||
private enum Item: Hashable {
|
private enum Item: Hashable {
|
||||||
|
case overview
|
||||||
case template(TravelAlbumAIRetouchTemplateCategory, TravelAlbumAIRetouchTemplate)
|
case template(TravelAlbumAIRetouchTemplateCategory, TravelAlbumAIRetouchTemplate)
|
||||||
case mode
|
case mode
|
||||||
}
|
}
|
||||||
|
|
||||||
private let viewModel: TravelAlbumAIRetouchTemplateViewModel
|
private let viewModel: TravelAlbumAIRetouchTemplateViewModel
|
||||||
private let api: any TravelAlbumServing
|
private let api: any TravelAlbumServing
|
||||||
private let onSubmitted: () -> Void
|
private let onSubmitted: (TravelAlbumAIJobSubmission) -> Void
|
||||||
|
|
||||||
private let titleLabel = UILabel()
|
|
||||||
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
||||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
private let statusContainer = UIView()
|
private let statusContainer = UIView()
|
||||||
@@ -38,13 +39,13 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
|||||||
private let validationLabel = UILabel()
|
private let validationLabel = UILabel()
|
||||||
private let actionStack = UIStackView()
|
private let actionStack = UIStackView()
|
||||||
private let cancelButton = UIButton(type: .system)
|
private let cancelButton = UIButton(type: .system)
|
||||||
private let confirmButton = UIButton(type: .system)
|
private let confirmButton = AIRetouchGradientButton(type: .system)
|
||||||
|
|
||||||
/// 创建 AI 修图模板选择 Sheet。
|
/// 创建 AI 修图模板选择 Sheet。
|
||||||
init(
|
init(
|
||||||
viewModel: TravelAlbumAIRetouchTemplateViewModel,
|
viewModel: TravelAlbumAIRetouchTemplateViewModel,
|
||||||
api: any TravelAlbumServing,
|
api: any TravelAlbumServing,
|
||||||
onSubmitted: @escaping () -> Void
|
onSubmitted: @escaping (TravelAlbumAIJobSubmission) -> Void
|
||||||
) {
|
) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.api = api
|
self.api = api
|
||||||
@@ -63,12 +64,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
|||||||
view.backgroundColor = AIRetouchTemplateStyle.pageBackground
|
view.backgroundColor = AIRetouchTemplateStyle.pageBackground
|
||||||
view.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateSheet"
|
view.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateSheet"
|
||||||
|
|
||||||
titleLabel.text = "AI修图"
|
|
||||||
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
|
||||||
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
|
||||||
titleLabel.textAlignment = .center
|
|
||||||
titleLabel.accessibilityTraits = .header
|
|
||||||
|
|
||||||
collectionView.backgroundColor = .clear
|
collectionView.backgroundColor = .clear
|
||||||
collectionView.showsVerticalScrollIndicator = false
|
collectionView.showsVerticalScrollIndicator = false
|
||||||
collectionView.alwaysBounceVertical = true
|
collectionView.alwaysBounceVertical = true
|
||||||
@@ -78,6 +73,10 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
|||||||
TravelAlbumAIRetouchTemplateCell.self,
|
TravelAlbumAIRetouchTemplateCell.self,
|
||||||
forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier
|
forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier
|
||||||
)
|
)
|
||||||
|
collectionView.register(
|
||||||
|
TravelAlbumAIRetouchOverviewCell.self,
|
||||||
|
forCellWithReuseIdentifier: TravelAlbumAIRetouchOverviewCell.reuseIdentifier
|
||||||
|
)
|
||||||
collectionView.register(
|
collectionView.register(
|
||||||
TravelAlbumAIRetouchModeCell.self,
|
TravelAlbumAIRetouchModeCell.self,
|
||||||
forCellWithReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier
|
forCellWithReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier
|
||||||
@@ -111,7 +110,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
|||||||
footerStack.alignment = .fill
|
footerStack.alignment = .fill
|
||||||
actionStack.axis = .horizontal
|
actionStack.axis = .horizontal
|
||||||
actionStack.spacing = 12
|
actionStack.spacing = 12
|
||||||
actionStack.distribution = .fillEqually
|
actionStack.distribution = .fill
|
||||||
actionStack.alignment = .fill
|
actionStack.alignment = .fill
|
||||||
configureCancelButton()
|
configureCancelButton()
|
||||||
configureConfirmButton()
|
configureConfirmButton()
|
||||||
@@ -120,7 +119,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
|||||||
footerStack.addArrangedSubview(validationLabel)
|
footerStack.addArrangedSubview(validationLabel)
|
||||||
footerStack.addArrangedSubview(actionStack)
|
footerStack.addArrangedSubview(actionStack)
|
||||||
|
|
||||||
view.addSubview(titleLabel)
|
|
||||||
view.addSubview(collectionView)
|
view.addSubview(collectionView)
|
||||||
view.addSubview(statusContainer)
|
view.addSubview(statusContainer)
|
||||||
statusContainer.addSubview(statusIndicator)
|
statusContainer.addSubview(statusIndicator)
|
||||||
@@ -132,11 +130,6 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
titleLabel.snp.makeConstraints { make in
|
|
||||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
|
||||||
make.leading.trailing.equalToSuperview().inset(18)
|
|
||||||
make.height.equalTo(30)
|
|
||||||
}
|
|
||||||
bottomBar.snp.makeConstraints { make in
|
bottomBar.snp.makeConstraints { make in
|
||||||
make.leading.trailing.bottom.equalToSuperview()
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
}
|
}
|
||||||
@@ -150,10 +143,13 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
|||||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||||
}
|
}
|
||||||
actionStack.snp.makeConstraints { make in
|
actionStack.snp.makeConstraints { make in
|
||||||
make.height.equalTo(48)
|
make.height.equalTo(56)
|
||||||
|
}
|
||||||
|
cancelButton.snp.makeConstraints { make in
|
||||||
|
make.width.equalTo(confirmButton.snp.width).multipliedBy(0.78)
|
||||||
}
|
}
|
||||||
collectionView.snp.makeConstraints { make in
|
collectionView.snp.makeConstraints { make in
|
||||||
make.top.equalTo(titleLabel.snp.bottom).offset(8)
|
make.top.equalTo(view.safeAreaLayoutGuide)
|
||||||
make.leading.trailing.equalToSuperview()
|
make.leading.trailing.equalToSuperview()
|
||||||
make.bottom.equalTo(bottomBar.snp.top)
|
make.bottom.equalTo(bottomBar.snp.top)
|
||||||
}
|
}
|
||||||
@@ -186,10 +182,10 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
|||||||
viewModel.onShowMessage = { [weak self] message in
|
viewModel.onShowMessage = { [weak self] message in
|
||||||
Task { @MainActor in self?.showToast(message) }
|
Task { @MainActor in self?.showToast(message) }
|
||||||
}
|
}
|
||||||
viewModel.onSubmitted = { [weak self] in
|
viewModel.onSubmitted = { [weak self] submission in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.dismiss(animated: true, completion: self.onSubmitted)
|
self.dismiss(animated: true) { self.onSubmitted(submission) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,42 +205,40 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func configureCancelButton() {
|
private func configureCancelButton() {
|
||||||
var configuration = UIButton.Configuration.filled()
|
cancelButton.setTitle("取消", for: .normal)
|
||||||
configuration.title = "取消"
|
cancelButton.setTitleColor(AIRetouchTemplateStyle.primary, for: .normal)
|
||||||
configuration.baseBackgroundColor = .white
|
cancelButton.setTitleColor(AIRetouchTemplateStyle.primary.withAlphaComponent(0.45), for: .disabled)
|
||||||
configuration.baseForegroundColor = AIRetouchTemplateStyle.primary
|
cancelButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
configuration.background.cornerRadius = 14
|
cancelButton.backgroundColor = AIRetouchTemplateStyle.cardBackground
|
||||||
configuration.background.strokeColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.55)
|
cancelButton.layer.cornerRadius = 12
|
||||||
configuration.background.strokeWidth = 1
|
cancelButton.layer.borderColor = AIRetouchTemplateStyle.primary.cgColor
|
||||||
configuration.titleTextAttributesTransformer = buttonTitleTransformer
|
cancelButton.layer.borderWidth = 1
|
||||||
cancelButton.configuration = configuration
|
|
||||||
cancelButton.accessibilityIdentifier = "travelAlbum.aiRetouchCancelButton"
|
cancelButton.accessibilityIdentifier = "travelAlbum.aiRetouchCancelButton"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureConfirmButton() {
|
private func configureConfirmButton() {
|
||||||
var configuration = UIButton.Configuration.filled()
|
confirmButton.setTitle("确定", for: .normal)
|
||||||
configuration.title = "确定"
|
confirmButton.setTitleColor(.white, for: .normal)
|
||||||
configuration.baseBackgroundColor = AIRetouchTemplateStyle.primary
|
confirmButton.setTitleColor(.white, for: .disabled)
|
||||||
configuration.baseForegroundColor = .white
|
confirmButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
configuration.background.cornerRadius = 14
|
|
||||||
configuration.titleTextAttributesTransformer = buttonTitleTransformer
|
|
||||||
confirmButton.configuration = configuration
|
|
||||||
confirmButton.accessibilityIdentifier = "travelAlbum.aiRetouchConfirmButton"
|
confirmButton.accessibilityIdentifier = "travelAlbum.aiRetouchConfirmButton"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var buttonTitleTransformer: UIConfigurationTextAttributesTransformer {
|
|
||||||
UIConfigurationTextAttributesTransformer { attributes in
|
|
||||||
var attributes = attributes
|
|
||||||
attributes.font = .systemFont(ofSize: 16, weight: .semibold)
|
|
||||||
return attributes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func configureDataSource() {
|
private func configureDataSource() {
|
||||||
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) {
|
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) {
|
||||||
[weak self] collectionView, indexPath, item in
|
[weak self] collectionView, indexPath, item in
|
||||||
guard let self else { return nil }
|
guard let self else { return nil }
|
||||||
switch item {
|
switch item {
|
||||||
|
case .overview:
|
||||||
|
let cell = collectionView.dequeueReusableCell(
|
||||||
|
withReuseIdentifier: TravelAlbumAIRetouchOverviewCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as! TravelAlbumAIRetouchOverviewCell
|
||||||
|
cell.apply(
|
||||||
|
selectedPhotoCount: self.viewModel.selectedPhotoCount,
|
||||||
|
tips: self.viewModel.shouldShowInitialTips ? self.viewModel.initialTipsText : nil
|
||||||
|
)
|
||||||
|
return cell
|
||||||
case .template(let category, let template):
|
case .template(let category, let template):
|
||||||
let cell = collectionView.dequeueReusableCell(
|
let cell = collectionView.dequeueReusableCell(
|
||||||
withReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier,
|
withReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier,
|
||||||
@@ -254,52 +248,88 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
|||||||
template: template,
|
template: template,
|
||||||
selected: self.viewModel.selectedTemplateId(for: category) == template.id
|
selected: self.viewModel.selectedTemplateId(for: category) == template.id
|
||||||
)
|
)
|
||||||
|
cell.onPreviewTapped = { [weak self] in
|
||||||
|
self?.showPreview(for: template)
|
||||||
|
}
|
||||||
return cell
|
return cell
|
||||||
case .mode:
|
case .mode:
|
||||||
let cell = collectionView.dequeueReusableCell(
|
let cell = collectionView.dequeueReusableCell(
|
||||||
withReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier,
|
withReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier,
|
||||||
for: indexPath
|
for: indexPath
|
||||||
) as! TravelAlbumAIRetouchModeCell
|
) as! TravelAlbumAIRetouchModeCell
|
||||||
cell.apply()
|
cell.apply(
|
||||||
|
requiredQuota: self.viewModel.requiredQuota,
|
||||||
|
remainingQuota: self.viewModel.remainingQuota,
|
||||||
|
insufficient: self.viewModel.remainingQuota.map {
|
||||||
|
self.viewModel.requiredQuota > $0
|
||||||
|
} ?? false
|
||||||
|
)
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
|
dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
|
||||||
guard kind == UICollectionView.elementKindSectionHeader,
|
guard kind == UICollectionView.elementKindSectionHeader,
|
||||||
let self,
|
let self,
|
||||||
self.dataSource.snapshot().sectionIdentifiers.indices.contains(indexPath.section),
|
self.dataSource.snapshot().sectionIdentifiers.indices.contains(indexPath.section) else {
|
||||||
case .templates(let category) = self.dataSource.snapshot().sectionIdentifiers[indexPath.section] else {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section]
|
||||||
let header = collectionView.dequeueReusableSupplementaryView(
|
let header = collectionView.dequeueReusableSupplementaryView(
|
||||||
ofKind: kind,
|
ofKind: kind,
|
||||||
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier,
|
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier,
|
||||||
for: indexPath
|
for: indexPath
|
||||||
) as! TravelAlbumAIRetouchSectionHeader
|
) as! TravelAlbumAIRetouchSectionHeader
|
||||||
header.apply(title: category.title, optional: category == .atmosphere)
|
switch section {
|
||||||
|
case .templates(let category):
|
||||||
|
header.apply(
|
||||||
|
title: category.title,
|
||||||
|
badge: category == .cover
|
||||||
|
? .requiredGift
|
||||||
|
: (self.viewModel.isOptional(category) ? .optional : .required)
|
||||||
|
)
|
||||||
|
case .mode:
|
||||||
|
return nil
|
||||||
|
case .overview:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return header
|
return header
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeLayout() -> UICollectionViewCompositionalLayout {
|
private func makeLayout() -> UICollectionViewCompositionalLayout {
|
||||||
UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in
|
UICollectionViewCompositionalLayout { [weak self] sectionIndex, layoutEnvironment in
|
||||||
guard let self,
|
guard let self,
|
||||||
self.dataSource != nil,
|
self.dataSource != nil,
|
||||||
self.dataSource.snapshot().sectionIdentifiers.indices.contains(sectionIndex) else {
|
self.dataSource.snapshot().sectionIdentifiers.indices.contains(sectionIndex) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
switch self.dataSource.snapshot().sectionIdentifiers[sectionIndex] {
|
switch self.dataSource.snapshot().sectionIdentifiers[sectionIndex] {
|
||||||
|
case .overview:
|
||||||
|
let size = NSCollectionLayoutSize(
|
||||||
|
widthDimension: .fractionalWidth(1),
|
||||||
|
heightDimension: .absolute(self.viewModel.shouldShowInitialTips ? 150 : 82)
|
||||||
|
)
|
||||||
|
let item = NSCollectionLayoutItem(layoutSize: size)
|
||||||
|
let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item])
|
||||||
|
return NSCollectionLayoutSection(group: group)
|
||||||
case .templates:
|
case .templates:
|
||||||
|
let availableWidth = layoutEnvironment.container.effectiveContentSize.width
|
||||||
|
let cardWidth = min(118, floor((availableWidth - 56) / 3))
|
||||||
let itemSize = NSCollectionLayoutSize(
|
let itemSize = NSCollectionLayoutSize(
|
||||||
widthDimension: .absolute(118),
|
widthDimension: .absolute(cardWidth),
|
||||||
heightDimension: .absolute(154)
|
heightDimension: .absolute(156)
|
||||||
)
|
)
|
||||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
|
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
|
||||||
let section = NSCollectionLayoutSection(group: group)
|
let section = NSCollectionLayoutSection(group: group)
|
||||||
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
|
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
|
||||||
section.interGroupSpacing = 12
|
section.interGroupSpacing = 10
|
||||||
section.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 18, bottom: 12, trailing: 18)
|
section.contentInsets = NSDirectionalEdgeInsets(
|
||||||
|
top: 4,
|
||||||
|
leading: 18,
|
||||||
|
bottom: 14,
|
||||||
|
trailing: 18
|
||||||
|
)
|
||||||
section.boundarySupplementaryItems = [
|
section.boundarySupplementaryItems = [
|
||||||
NSCollectionLayoutBoundarySupplementaryItem(
|
NSCollectionLayoutBoundarySupplementaryItem(
|
||||||
layoutSize: NSCollectionLayoutSize(
|
layoutSize: NSCollectionLayoutSize(
|
||||||
@@ -314,12 +344,17 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
|||||||
case .mode:
|
case .mode:
|
||||||
let itemSize = NSCollectionLayoutSize(
|
let itemSize = NSCollectionLayoutSize(
|
||||||
widthDimension: .fractionalWidth(1),
|
widthDimension: .fractionalWidth(1),
|
||||||
heightDimension: .absolute(72)
|
heightDimension: .absolute(78)
|
||||||
)
|
)
|
||||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
|
let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item])
|
||||||
let section = NSCollectionLayoutSection(group: group)
|
let section = NSCollectionLayoutSection(group: group)
|
||||||
section.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 18, bottom: 22, trailing: 18)
|
section.contentInsets = NSDirectionalEdgeInsets(
|
||||||
|
top: 8,
|
||||||
|
leading: 18,
|
||||||
|
bottom: 18,
|
||||||
|
trailing: 18
|
||||||
|
)
|
||||||
return section
|
return section
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,20 +384,19 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
|||||||
cancelButton.isEnabled = !viewModel.isSubmitting
|
cancelButton.isEnabled = !viewModel.isSubmitting
|
||||||
confirmButton.isEnabled = viewModel.canSubmit
|
confirmButton.isEnabled = viewModel.canSubmit
|
||||||
confirmButton.alpha = viewModel.canSubmit ? 1 : 0.45
|
confirmButton.alpha = viewModel.canSubmit ? 1 : 0.45
|
||||||
var confirmConfiguration = confirmButton.configuration
|
confirmButton.setGradientEnabled(viewModel.canSubmit)
|
||||||
confirmConfiguration?.title = viewModel.isSubmitting ? "提交中" : "确定"
|
confirmButton.setTitle(viewModel.isSubmitting ? "提交中" : "确定", for: .normal)
|
||||||
confirmConfiguration?.showsActivityIndicator = viewModel.isSubmitting
|
confirmButton.setLoading(viewModel.isSubmitting)
|
||||||
confirmButton.configuration = confirmConfiguration
|
|
||||||
isModalInPresentation = viewModel.isSubmitting
|
isModalInPresentation = viewModel.isSubmitting
|
||||||
applySnapshot()
|
applySnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applySnapshot() {
|
private func applySnapshot() {
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
appendTemplates(.refined, to: &snapshot)
|
snapshot.appendSections([.overview])
|
||||||
appendTemplates(.atmosphere, to: &snapshot)
|
snapshot.appendItems([.overview], toSection: .overview)
|
||||||
if viewModel.showsCoverTemplates {
|
for category in viewModel.visibleCategories {
|
||||||
appendTemplates(.cover, to: &snapshot)
|
appendTemplates(category, to: &snapshot)
|
||||||
}
|
}
|
||||||
snapshot.appendSections([.mode])
|
snapshot.appendSections([.mode])
|
||||||
snapshot.appendItems([.mode], toSection: .mode)
|
snapshot.appendItems([.mode], toSection: .mode)
|
||||||
@@ -398,16 +432,136 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
|||||||
@objc private func retryTapped() {
|
@objc private func retryTapped() {
|
||||||
Task { await viewModel.loadTemplates(api: api) }
|
Task { await viewModel.loadTemplates(api: api) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func showPreview(for template: TravelAlbumAIRetouchTemplate) {
|
||||||
|
guard let content = template.comparisonContent else {
|
||||||
|
showToast("暂无对比预览")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let controller = BeforeAfterComparisonViewController(
|
||||||
|
viewModel: BeforeAfterComparisonViewModel(content: content)
|
||||||
|
)
|
||||||
|
present(controller, animated: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension TravelAlbumAIRetouchTemplateViewController: UICollectionViewDelegate {
|
extension TravelAlbumAIRetouchTemplateViewController: UICollectionViewDelegate {
|
||||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
guard let item = dataSource.itemIdentifier(for: indexPath),
|
guard let item = dataSource.itemIdentifier(for: indexPath),
|
||||||
case .template(let category, let template) = item else { return }
|
case .template(let category, let template) = item else { return }
|
||||||
|
collectionView.scrollToItem(
|
||||||
|
at: indexPath,
|
||||||
|
at: .centeredHorizontally,
|
||||||
|
animated: !UIAccessibility.isReduceMotionEnabled
|
||||||
|
)
|
||||||
viewModel.toggleTemplate(id: template.id, category: category)
|
viewModel.toggleTemplate(id: template.id, category: category)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AI 修图模板页顶部说明卡,随模板列表一起滚动。
|
||||||
|
final class TravelAlbumAIRetouchOverviewCell: UICollectionViewCell {
|
||||||
|
static let reuseIdentifier = "TravelAlbumAIRetouchOverviewCell"
|
||||||
|
|
||||||
|
private let stackView = UIStackView()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let selectionCountLabel = UILabel()
|
||||||
|
private let tipsContainer = UIView()
|
||||||
|
private let tipsIconContainer = UIView()
|
||||||
|
private let tipsIconView = UIImageView()
|
||||||
|
private let tipsLabel = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
titleLabel.text = "AI修图"
|
||||||
|
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||||||
|
titleLabel.font = .systemFont(ofSize: 22, weight: .bold)
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
titleLabel.accessibilityTraits = .header
|
||||||
|
|
||||||
|
selectionCountLabel.textColor = AIRetouchTemplateStyle.textSecondary
|
||||||
|
selectionCountLabel.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(
|
||||||
|
for: .systemFont(ofSize: 15, weight: .regular)
|
||||||
|
)
|
||||||
|
selectionCountLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
selectionCountLabel.textAlignment = .center
|
||||||
|
selectionCountLabel.accessibilityIdentifier = "travelAlbum.aiRetouchSelectionCountLabel"
|
||||||
|
|
||||||
|
tipsContainer.backgroundColor = AIRetouchTemplateStyle.tipsBackground
|
||||||
|
tipsContainer.layer.cornerRadius = 12
|
||||||
|
tipsContainer.layer.borderWidth = 1
|
||||||
|
tipsContainer.layer.borderColor = AIRetouchTemplateStyle.tipsBorder.cgColor
|
||||||
|
tipsContainer.accessibilityIdentifier = "travelAlbum.aiRetouchTipsContainer"
|
||||||
|
|
||||||
|
tipsIconContainer.backgroundColor = AIRetouchTemplateStyle.primary
|
||||||
|
tipsIconContainer.layer.cornerRadius = 8
|
||||||
|
tipsIconView.image = UIImage(systemName: "wand.and.stars")
|
||||||
|
tipsIconView.tintColor = .white
|
||||||
|
tipsIconView.contentMode = .scaleAspectFit
|
||||||
|
|
||||||
|
tipsLabel.textColor = AIRetouchTemplateStyle.tipsText
|
||||||
|
tipsLabel.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(
|
||||||
|
for: .systemFont(ofSize: 12, weight: .regular)
|
||||||
|
)
|
||||||
|
tipsLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
tipsLabel.numberOfLines = 2
|
||||||
|
tipsLabel.accessibilityIdentifier = "travelAlbum.aiRetouchTipsLabel"
|
||||||
|
|
||||||
|
stackView.axis = .vertical
|
||||||
|
stackView.alignment = .fill
|
||||||
|
stackView.spacing = 2
|
||||||
|
stackView.addArrangedSubview(titleLabel)
|
||||||
|
stackView.addArrangedSubview(selectionCountLabel)
|
||||||
|
stackView.addArrangedSubview(tipsContainer)
|
||||||
|
stackView.setCustomSpacing(14, after: selectionCountLabel)
|
||||||
|
|
||||||
|
contentView.addSubview(stackView)
|
||||||
|
tipsContainer.addSubview(tipsIconContainer)
|
||||||
|
tipsIconContainer.addSubview(tipsIconView)
|
||||||
|
tipsContainer.addSubview(tipsLabel)
|
||||||
|
stackView.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(8)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(18)
|
||||||
|
make.bottom.lessThanOrEqualToSuperview().offset(-8)
|
||||||
|
}
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(32)
|
||||||
|
}
|
||||||
|
selectionCountLabel.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(22)
|
||||||
|
}
|
||||||
|
tipsContainer.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(52)
|
||||||
|
}
|
||||||
|
tipsIconContainer.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(12)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.size.equalTo(32)
|
||||||
|
}
|
||||||
|
tipsIconView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(7)
|
||||||
|
}
|
||||||
|
tipsLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(tipsIconContainer.snp.trailing).offset(10)
|
||||||
|
make.trailing.equalToSuperview().offset(-12)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新选中照片数量和当前工作流说明。
|
||||||
|
func apply(selectedPhotoCount: Int, tips: String?) {
|
||||||
|
selectionCountLabel.text = "已选择 \(selectedPhotoCount) 张照片"
|
||||||
|
tipsLabel.text = tips
|
||||||
|
tipsContainer.isHidden = tips == nil
|
||||||
|
tipsIconContainer.isHidden = tips == nil
|
||||||
|
tipsLabel.isHidden = tips == nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// AI 修图模板卡片,展示模板预览、名称和明确选中态。
|
/// AI 修图模板卡片,展示模板预览、名称和明确选中态。
|
||||||
final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
|
final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
|
||||||
static let reuseIdentifier = "TravelAlbumAIRetouchTemplateCell"
|
static let reuseIdentifier = "TravelAlbumAIRetouchTemplateCell"
|
||||||
@@ -415,32 +569,57 @@ final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
|
|||||||
private let previewImageView = UIImageView()
|
private let previewImageView = UIImageView()
|
||||||
private let nameLabel = UILabel()
|
private let nameLabel = UILabel()
|
||||||
private let checkView = UIImageView()
|
private let checkView = UIImageView()
|
||||||
|
private let previewButton = AIRetouchTemplatePreviewButton(type: .system)
|
||||||
|
|
||||||
|
var onPreviewTapped: (() -> Void)?
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
contentView.backgroundColor = .white
|
contentView.backgroundColor = AIRetouchTemplateStyle.cardBackground
|
||||||
contentView.layer.cornerRadius = 12
|
contentView.layer.cornerRadius = 12
|
||||||
contentView.layer.borderWidth = 1
|
contentView.layer.borderWidth = 1
|
||||||
contentView.clipsToBounds = true
|
contentView.clipsToBounds = true
|
||||||
|
layer.shadowColor = UIColor(hex: 0x254A7A).cgColor
|
||||||
|
layer.shadowOpacity = 0.08
|
||||||
|
layer.shadowRadius = 7
|
||||||
|
layer.shadowOffset = CGSize(width: 0, height: 3)
|
||||||
|
layer.masksToBounds = false
|
||||||
|
|
||||||
previewImageView.contentMode = .scaleAspectFill
|
previewImageView.contentMode = .scaleAspectFill
|
||||||
previewImageView.clipsToBounds = true
|
previewImageView.clipsToBounds = true
|
||||||
previewImageView.backgroundColor = UIColor(hex: 0xEDF2F8)
|
previewImageView.backgroundColor = UIColor(hex: 0xEDF2F8)
|
||||||
nameLabel.font = .systemFont(ofSize: 13, weight: .medium)
|
previewImageView.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateImage"
|
||||||
|
nameLabel.font = .systemFont(ofSize: 13, weight: .semibold)
|
||||||
nameLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
nameLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||||||
nameLabel.textAlignment = .center
|
nameLabel.textAlignment = .center
|
||||||
nameLabel.lineBreakMode = .byTruncatingTail
|
nameLabel.lineBreakMode = .byTruncatingTail
|
||||||
checkView.image = UIImage(systemName: "checkmark.circle.fill")
|
checkView.image = UIImage(systemName: "checkmark.circle.fill")
|
||||||
checkView.tintColor = AIRetouchTemplateStyle.primary
|
checkView.tintColor = AIRetouchTemplateStyle.primary
|
||||||
checkView.backgroundColor = .white
|
checkView.backgroundColor = .white
|
||||||
checkView.layer.cornerRadius = 10
|
checkView.layer.cornerRadius = 11
|
||||||
|
var previewConfiguration = UIButton.Configuration.filled()
|
||||||
|
previewConfiguration.title = "预览"
|
||||||
|
previewConfiguration.baseForegroundColor = .white
|
||||||
|
previewConfiguration.baseBackgroundColor = UIColor.black.withAlphaComponent(0.58)
|
||||||
|
previewConfiguration.background.cornerRadius = 14
|
||||||
|
previewConfiguration.contentInsets = .zero
|
||||||
|
previewConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer {
|
||||||
|
attributes in
|
||||||
|
var attributes = attributes
|
||||||
|
attributes.font = .systemFont(ofSize: 12, weight: .regular)
|
||||||
|
return attributes
|
||||||
|
}
|
||||||
|
previewButton.configuration = previewConfiguration
|
||||||
|
previewButton.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreviewButton"
|
||||||
|
previewButton.addTarget(self, action: #selector(previewTapped), for: .touchUpInside)
|
||||||
|
|
||||||
contentView.addSubview(previewImageView)
|
contentView.addSubview(previewImageView)
|
||||||
contentView.addSubview(nameLabel)
|
contentView.addSubview(nameLabel)
|
||||||
contentView.addSubview(checkView)
|
contentView.addSubview(checkView)
|
||||||
|
contentView.addSubview(previewButton)
|
||||||
previewImageView.snp.makeConstraints { make in
|
previewImageView.snp.makeConstraints { make in
|
||||||
make.top.leading.trailing.equalToSuperview()
|
make.top.leading.trailing.equalToSuperview()
|
||||||
make.height.equalTo(116)
|
make.height.equalTo(118)
|
||||||
}
|
}
|
||||||
nameLabel.snp.makeConstraints { make in
|
nameLabel.snp.makeConstraints { make in
|
||||||
make.top.equalTo(previewImageView.snp.bottom)
|
make.top.equalTo(previewImageView.snp.bottom)
|
||||||
@@ -448,8 +627,13 @@ final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
|
|||||||
make.bottom.equalToSuperview()
|
make.bottom.equalToSuperview()
|
||||||
}
|
}
|
||||||
checkView.snp.makeConstraints { make in
|
checkView.snp.makeConstraints { make in
|
||||||
make.top.trailing.equalToSuperview().inset(8)
|
make.top.trailing.equalToSuperview().inset(7)
|
||||||
make.size.equalTo(20)
|
make.size.equalTo(22)
|
||||||
|
}
|
||||||
|
previewButton.snp.makeConstraints { make in
|
||||||
|
make.trailing.bottom.equalTo(previewImageView).inset(4)
|
||||||
|
make.width.equalTo(48)
|
||||||
|
make.height.equalTo(28)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,69 +646,245 @@ final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
|
|||||||
super.prepareForReuse()
|
super.prepareForReuse()
|
||||||
previewImageView.kf.cancelDownloadTask()
|
previewImageView.kf.cancelDownloadTask()
|
||||||
previewImageView.image = nil
|
previewImageView.image = nil
|
||||||
|
onPreviewTapped = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新模板内容与选中状态。
|
/// 更新模板内容与选中状态。
|
||||||
func apply(template: TravelAlbumAIRetouchTemplate, selected: Bool) {
|
func apply(template: TravelAlbumAIRetouchTemplate, selected: Bool) {
|
||||||
nameLabel.text = template.name
|
nameLabel.text = template.name
|
||||||
|
let placeholder = UIImage(named: "ai_retouch_template_placeholder")
|
||||||
if let url = URL(string: template.previewURL), !template.previewURL.isEmpty {
|
if let url = URL(string: template.previewURL), !template.previewURL.isEmpty {
|
||||||
previewImageView.contentMode = .scaleAspectFill
|
previewImageView.contentMode = .scaleAspectFill
|
||||||
previewImageView.kf.setImage(
|
previewImageView.kf.setImage(
|
||||||
with: url,
|
with: url,
|
||||||
placeholder: UIImage(systemName: "photo")?.withTintColor(
|
placeholder: placeholder
|
||||||
AIRetouchTemplateStyle.textSecondary,
|
|
||||||
renderingMode: .alwaysOriginal
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
previewImageView.image = UIImage(systemName: "photo")
|
previewImageView.image = placeholder
|
||||||
previewImageView.tintColor = AIRetouchTemplateStyle.textSecondary
|
previewImageView.contentMode = .scaleAspectFill
|
||||||
previewImageView.contentMode = .scaleAspectFit
|
|
||||||
}
|
}
|
||||||
contentView.layer.borderColor = (
|
contentView.layer.borderColor = (
|
||||||
selected ? AIRetouchTemplateStyle.primary : AIRetouchTemplateStyle.border
|
selected ? AIRetouchTemplateStyle.primary : AIRetouchTemplateStyle.border
|
||||||
).cgColor
|
).cgColor
|
||||||
contentView.layer.borderWidth = selected ? 2 : 1
|
contentView.layer.borderWidth = selected ? 2 : 1
|
||||||
|
layer.shadowOpacity = selected ? 0.16 : 0.08
|
||||||
checkView.isHidden = !selected
|
checkView.isHidden = !selected
|
||||||
isSelected = selected
|
isSelected = selected
|
||||||
accessibilityLabel = template.name
|
accessibilityLabel = template.name
|
||||||
accessibilityValue = selected ? "已选择" : "未选择"
|
accessibilityValue = selected ? "已选择" : "未选择"
|
||||||
accessibilityTraits = selected ? [.button, .selected] : [.button]
|
accessibilityTraits = selected ? [.button, .selected] : [.button]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func previewTapped() {
|
||||||
|
onPreviewTapped?()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AI 修图模板分组标题,可附带“选填”标签。
|
/// 扩大模板预览按钮的触摸区域,同时保持截图中的胶囊尺寸。
|
||||||
|
private final class AIRetouchTemplatePreviewButton: UIButton {
|
||||||
|
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
||||||
|
bounds.insetBy(dx: -8, dy: -8).contains(point)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模板选择页主操作按钮,使用品牌蓝渐变并支持禁用态切换。
|
||||||
|
private final class AIRetouchGradientButton: UIButton {
|
||||||
|
private let gradientLayer = CAGradientLayer()
|
||||||
|
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
gradientLayer.colors = [
|
||||||
|
AIRetouchTemplateStyle.primary.cgColor,
|
||||||
|
AIRetouchTemplateStyle.primaryGradientEnd.cgColor,
|
||||||
|
]
|
||||||
|
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
|
||||||
|
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
|
||||||
|
gradientLayer.cornerRadius = 12
|
||||||
|
layer.insertSublayer(gradientLayer, at: 0)
|
||||||
|
activityIndicator.color = .white
|
||||||
|
activityIndicator.hidesWhenStopped = true
|
||||||
|
activityIndicator.isUserInteractionEnabled = false
|
||||||
|
addSubview(activityIndicator)
|
||||||
|
clipsToBounds = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
gradientLayer.frame = bounds
|
||||||
|
activityIndicator.center = CGPoint(x: bounds.midX - 44, y: bounds.midY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据提交条件切换品牌渐变和禁用灰色。
|
||||||
|
func setGradientEnabled(_ enabled: Bool) {
|
||||||
|
gradientLayer.colors = enabled
|
||||||
|
? [
|
||||||
|
AIRetouchTemplateStyle.primary.cgColor,
|
||||||
|
AIRetouchTemplateStyle.primaryGradientEnd.cgColor,
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
AIRetouchTemplateStyle.disabledStart.cgColor,
|
||||||
|
AIRetouchTemplateStyle.disabledEnd.cgColor,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换提交中状态,并在标题左侧展示活动指示器。
|
||||||
|
func setLoading(_ loading: Bool) {
|
||||||
|
if loading {
|
||||||
|
activityIndicator.startAnimating()
|
||||||
|
} else {
|
||||||
|
activityIndicator.stopAnimating()
|
||||||
|
}
|
||||||
|
setNeedsLayout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图计费模式卡,展示按张收费规则与剩余额度。
|
||||||
|
final class TravelAlbumAIRetouchModeCell: UICollectionViewCell {
|
||||||
|
static let reuseIdentifier = "TravelAlbumAIRetouchModeCell"
|
||||||
|
|
||||||
|
private let iconContainer = UIView()
|
||||||
|
private let iconView = UIImageView()
|
||||||
|
private let modeLabel = UILabel()
|
||||||
|
private let quotaStack = UIStackView()
|
||||||
|
private let requiredQuotaContainer = UIView()
|
||||||
|
private let requiredQuotaLabel = UILabel()
|
||||||
|
private let quotaLabel = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
contentView.backgroundColor = .white
|
||||||
|
contentView.layer.cornerRadius = 12
|
||||||
|
contentView.layer.borderWidth = 1
|
||||||
|
contentView.layer.borderColor = AIRetouchTemplateStyle.border.cgColor
|
||||||
|
contentView.layer.shadowColor = UIColor(hex: 0x254A7A).cgColor
|
||||||
|
contentView.layer.shadowOpacity = 0.06
|
||||||
|
contentView.layer.shadowRadius = 8
|
||||||
|
contentView.layer.shadowOffset = CGSize(width: 0, height: 3)
|
||||||
|
|
||||||
|
iconContainer.backgroundColor = AIRetouchTemplateStyle.primary
|
||||||
|
iconContainer.layer.cornerRadius = 10
|
||||||
|
iconView.image = UIImage(systemName: "wand.and.stars")
|
||||||
|
iconView.tintColor = .white
|
||||||
|
iconView.contentMode = .scaleAspectFit
|
||||||
|
modeLabel.text = "AI精修 · 按张收费"
|
||||||
|
modeLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||||||
|
modeLabel.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||||
|
|
||||||
|
quotaStack.axis = .vertical
|
||||||
|
quotaStack.alignment = .trailing
|
||||||
|
quotaStack.spacing = 3
|
||||||
|
requiredQuotaContainer.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
|
||||||
|
requiredQuotaContainer.layer.cornerRadius = 12
|
||||||
|
requiredQuotaLabel.font = .monospacedDigitSystemFont(ofSize: 12, weight: .semibold)
|
||||||
|
requiredQuotaLabel.textColor = AIRetouchTemplateStyle.primary
|
||||||
|
requiredQuotaLabel.textAlignment = .center
|
||||||
|
requiredQuotaLabel.accessibilityIdentifier = "travelAlbum.aiRetouchRequiredQuotaLabel"
|
||||||
|
quotaLabel.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular)
|
||||||
|
quotaLabel.textColor = AIRetouchTemplateStyle.textSecondary
|
||||||
|
quotaLabel.textAlignment = .right
|
||||||
|
quotaLabel.accessibilityIdentifier = "travelAlbum.aiRetouchRemainingQuotaLabel"
|
||||||
|
|
||||||
|
contentView.addSubview(iconContainer)
|
||||||
|
iconContainer.addSubview(iconView)
|
||||||
|
contentView.addSubview(modeLabel)
|
||||||
|
contentView.addSubview(quotaStack)
|
||||||
|
quotaStack.addArrangedSubview(requiredQuotaContainer)
|
||||||
|
requiredQuotaContainer.addSubview(requiredQuotaLabel)
|
||||||
|
quotaStack.addArrangedSubview(quotaLabel)
|
||||||
|
iconContainer.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(14)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.size.equalTo(38)
|
||||||
|
}
|
||||||
|
iconView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(9)
|
||||||
|
}
|
||||||
|
modeLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(iconContainer.snp.trailing).offset(10)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.trailing.lessThanOrEqualTo(quotaStack.snp.leading).offset(-10)
|
||||||
|
}
|
||||||
|
quotaStack.snp.makeConstraints { make in
|
||||||
|
make.trailing.equalToSuperview().offset(-14)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
requiredQuotaContainer.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(24)
|
||||||
|
make.width.greaterThanOrEqualTo(108)
|
||||||
|
}
|
||||||
|
requiredQuotaLabel.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新预计消耗和剩余精修张数,并在额度不足时切换为警示色。
|
||||||
|
func apply(requiredQuota: Int, remainingQuota: Int?, insufficient: Bool) {
|
||||||
|
requiredQuotaLabel.text = "预计消耗 \(requiredQuota) 张"
|
||||||
|
requiredQuotaLabel.accessibilityLabel = "预计消耗\(requiredQuota)张修图额度"
|
||||||
|
quotaLabel.text = remainingQuota.map { "剩余 \($0) 张" } ?? "剩余 -- 张"
|
||||||
|
quotaLabel.accessibilityLabel = remainingQuota.map { "剩余\($0)张修图额度" }
|
||||||
|
?? "剩余修图额度暂不可用"
|
||||||
|
requiredQuotaLabel.textColor = insufficient
|
||||||
|
? AIRetouchTemplateStyle.danger
|
||||||
|
: AIRetouchTemplateStyle.primary
|
||||||
|
requiredQuotaContainer.backgroundColor = requiredQuotaLabel.textColor.withAlphaComponent(0.1)
|
||||||
|
quotaLabel.textColor = insufficient ? AIRetouchTemplateStyle.danger : AIRetouchTemplateStyle.textSecondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模板分组标题右侧的业务标记。
|
||||||
|
fileprivate enum AIRetouchTemplateSectionBadge {
|
||||||
|
case required
|
||||||
|
case optional
|
||||||
|
case requiredGift
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图模板分组标题,展示必选、选填或封面赠送规则。
|
||||||
final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
|
final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
|
||||||
static let reuseIdentifier = "TravelAlbumAIRetouchSectionHeader"
|
static let reuseIdentifier = "TravelAlbumAIRetouchSectionHeader"
|
||||||
|
|
||||||
private let titleLabel = UILabel()
|
private let titleLabel = UILabel()
|
||||||
private let optionalLabel = UILabel()
|
private let badgeContainer = UIView()
|
||||||
|
private let badgeLabel = UILabel()
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||||||
optionalLabel.text = "选填"
|
badgeContainer.layer.cornerRadius = 10
|
||||||
optionalLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
badgeContainer.clipsToBounds = true
|
||||||
optionalLabel.textColor = AIRetouchTemplateStyle.primary
|
badgeLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
||||||
optionalLabel.textAlignment = .center
|
badgeLabel.textAlignment = .center
|
||||||
optionalLabel.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
|
badgeLabel.numberOfLines = 1
|
||||||
optionalLabel.layer.cornerRadius = 8
|
|
||||||
optionalLabel.clipsToBounds = true
|
|
||||||
|
|
||||||
addSubview(titleLabel)
|
addSubview(titleLabel)
|
||||||
addSubview(optionalLabel)
|
addSubview(badgeContainer)
|
||||||
|
badgeContainer.addSubview(badgeLabel)
|
||||||
titleLabel.snp.makeConstraints { make in
|
titleLabel.snp.makeConstraints { make in
|
||||||
make.leading.equalToSuperview().offset(18)
|
make.leading.equalToSuperview().offset(18)
|
||||||
make.centerY.equalToSuperview()
|
make.centerY.equalToSuperview()
|
||||||
}
|
}
|
||||||
optionalLabel.snp.makeConstraints { make in
|
badgeContainer.snp.makeConstraints { make in
|
||||||
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
|
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
|
||||||
make.centerY.equalTo(titleLabel)
|
make.centerY.equalTo(titleLabel)
|
||||||
make.width.equalTo(38)
|
|
||||||
make.height.equalTo(20)
|
make.height.equalTo(20)
|
||||||
make.trailing.lessThanOrEqualToSuperview().offset(-18)
|
make.trailing.lessThanOrEqualToSuperview().offset(-18)
|
||||||
}
|
}
|
||||||
|
badgeLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.equalToSuperview().inset(9)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
@@ -532,68 +892,47 @@ final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新分组标题与选填标签。
|
/// 更新分组标题与业务标记。
|
||||||
func apply(title: String, optional: Bool) {
|
fileprivate func apply(title: String, badge: AIRetouchTemplateSectionBadge?) {
|
||||||
titleLabel.text = title
|
titleLabel.text = title
|
||||||
optionalLabel.isHidden = !optional
|
badgeContainer.isHidden = badge == nil
|
||||||
accessibilityLabel = optional ? "\(title),选填" : title
|
switch badge {
|
||||||
|
case .required:
|
||||||
|
badgeLabel.text = "必选"
|
||||||
|
badgeLabel.textColor = AIRetouchTemplateStyle.danger
|
||||||
|
badgeContainer.backgroundColor = AIRetouchTemplateStyle.danger.withAlphaComponent(0.1)
|
||||||
|
case .optional:
|
||||||
|
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:
|
||||||
|
badgeLabel.text = nil
|
||||||
|
}
|
||||||
|
let badgeText = badgeLabel.text
|
||||||
|
accessibilityLabel = badgeText.map { "\(title),\($0)" } ?? title
|
||||||
accessibilityTraits = .header
|
accessibilityTraits = .header
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AI 修图模式卡片,展示计费方式与剩余张数占位。
|
|
||||||
final class TravelAlbumAIRetouchModeCell: UICollectionViewCell {
|
|
||||||
static let reuseIdentifier = "TravelAlbumAIRetouchModeCell"
|
|
||||||
|
|
||||||
private let titleLabel = UILabel()
|
|
||||||
private let remainingLabel = UILabel()
|
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
|
||||||
super.init(frame: frame)
|
|
||||||
contentView.backgroundColor = .white
|
|
||||||
contentView.layer.cornerRadius = 14
|
|
||||||
contentView.layer.borderWidth = 1
|
|
||||||
contentView.layer.borderColor = AIRetouchTemplateStyle.border.cgColor
|
|
||||||
|
|
||||||
titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
|
|
||||||
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
|
||||||
remainingLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium)
|
|
||||||
remainingLabel.textColor = AIRetouchTemplateStyle.primary
|
|
||||||
remainingLabel.textAlignment = .right
|
|
||||||
|
|
||||||
contentView.addSubview(titleLabel)
|
|
||||||
contentView.addSubview(remainingLabel)
|
|
||||||
titleLabel.snp.makeConstraints { make in
|
|
||||||
make.leading.equalToSuperview().offset(16)
|
|
||||||
make.centerY.equalToSuperview()
|
|
||||||
}
|
|
||||||
remainingLabel.snp.makeConstraints { make in
|
|
||||||
make.trailing.equalToSuperview().offset(-16)
|
|
||||||
make.centerY.equalToSuperview()
|
|
||||||
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 应用当前固定修图模式与额度占位文案。
|
|
||||||
func apply() {
|
|
||||||
titleLabel.text = "AI精修 按张收费"
|
|
||||||
remainingLabel.text = "剩余--张"
|
|
||||||
accessibilityIdentifier = "travelAlbum.aiRetouchModeCell"
|
|
||||||
accessibilityLabel = "AI精修,按张收费,剩余张数暂不可用"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// AI 修图模板页视觉常量。
|
/// AI 修图模板页视觉常量。
|
||||||
private enum AIRetouchTemplateStyle {
|
private enum AIRetouchTemplateStyle {
|
||||||
static let primary = UIColor(hex: 0x1677FF)
|
static let primary = UIColor(hex: 0x1677FF)
|
||||||
static let pageBackground = UIColor(hex: 0xF7F9FC)
|
static let primaryGradientEnd = UIColor(hex: 0x4B9BFF)
|
||||||
|
static let pageBackground = UIColor(hex: 0xF7FAFF)
|
||||||
|
static let cardBackground = UIColor.white
|
||||||
static let textPrimary = UIColor(hex: 0x172033)
|
static let textPrimary = UIColor(hex: 0x172033)
|
||||||
static let textSecondary = UIColor(hex: 0x7F8A9E)
|
static let textSecondary = UIColor(hex: 0x7D8799)
|
||||||
|
static let tipsBackground = UIColor(hex: 0xF4F8FF)
|
||||||
|
static let tipsBorder = UIColor(hex: 0xD9E7FA)
|
||||||
|
static let tipsText = UIColor(hex: 0x385778)
|
||||||
|
static let gift = UIColor(hex: 0xFF9418)
|
||||||
|
static let giftBackground = UIColor(hex: 0xFFF1D9)
|
||||||
static let border = UIColor(hex: 0xDCE4EF)
|
static let border = UIColor(hex: 0xDCE4EF)
|
||||||
static let danger = UIColor(hex: 0xE53935)
|
static let danger = UIColor(hex: 0xE53935)
|
||||||
|
static let disabledStart = UIColor(hex: 0xA9C8EE)
|
||||||
|
static let disabledEnd = UIColor(hex: 0xC6DAF3)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
private let aiRetouchButton = UIButton(type: .system)
|
private let aiRetouchButton = UIButton(type: .system)
|
||||||
private let deleteSelectedButton = UIButton(type: .system)
|
private let deleteSelectedButton = UIButton(type: .system)
|
||||||
private let uploadButton = UIButton(type: .system)
|
private let uploadButton = UIButton(type: .system)
|
||||||
|
private var refreshState = TravelAlbumReturnRefreshState()
|
||||||
|
|
||||||
init(
|
init(
|
||||||
albumId: Int,
|
albumId: Int,
|
||||||
@@ -65,7 +66,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
navigationItem.scrollEdgeAppearance = appearance
|
navigationItem.scrollEdgeAppearance = appearance
|
||||||
navigationItem.compactAppearance = appearance
|
navigationItem.compactAppearance = appearance
|
||||||
|
|
||||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
let moreItem = UIBarButtonItem(
|
||||||
image: UIImage(systemName: "ellipsis"),
|
image: UIImage(systemName: "ellipsis"),
|
||||||
menu: UIMenu(children: [
|
menu: UIMenu(children: [
|
||||||
UIAction(title: "删除相册", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
|
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() {
|
override func setupUI() {
|
||||||
@@ -250,6 +263,18 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
Task { await viewModel.refreshAll(api: api) }
|
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
|
@MainActor
|
||||||
private func applyViewModel() {
|
private func applyViewModel() {
|
||||||
if let album = viewModel.album {
|
if let album = viewModel.album {
|
||||||
@@ -268,14 +293,11 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
sortButton.accessibilityValue = viewModel.sortOption.title
|
sortButton.accessibilityValue = viewModel.sortOption.title
|
||||||
sortButton.menu = makeSortMenu()
|
sortButton.menu = makeSortMenu()
|
||||||
|
|
||||||
let canSelectMaterials = viewModel.selectedTab == .all
|
|
||||||
selectButton.isHidden = false
|
selectButton.isHidden = false
|
||||||
selectButton.isEnabled = canSelectMaterials
|
selectButton.isEnabled = true
|
||||||
selectButton.alpha = canSelectMaterials ? 1 : 0.45
|
selectButton.alpha = 1
|
||||||
selectButton.setTitle(viewModel.isSelectionMode ? "完成" : "选择", for: .normal)
|
selectButton.setTitle(viewModel.isSelectionMode ? "完成" : "选择", for: .normal)
|
||||||
selectButton.accessibilityValue = canSelectMaterials
|
selectButton.accessibilityValue = viewModel.isSelectionMode ? "选择模式已开启" : "选择模式已关闭"
|
||||||
? (viewModel.isSelectionMode ? "选择模式已开启" : "选择模式已关闭")
|
|
||||||
: "已购照片不可删除"
|
|
||||||
|
|
||||||
let selectedCount = viewModel.selectedMaterialIds.count
|
let selectedCount = viewModel.selectedMaterialIds.count
|
||||||
let hasSelection = selectedCount > 0
|
let hasSelection = selectedCount > 0
|
||||||
@@ -294,7 +316,10 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
if !snapshot.itemIdentifiers.isEmpty {
|
if !snapshot.itemIdentifiers.isEmpty {
|
||||||
snapshot.reconfigureItems(snapshot.itemIdentifiers)
|
snapshot.reconfigureItems(snapshot.itemIdentifiers)
|
||||||
}
|
}
|
||||||
dataSource.apply(snapshot, animatingDifferences: true)
|
dataSource.apply(
|
||||||
|
snapshot,
|
||||||
|
animatingDifferences: !refreshState.suppressesSnapshotAnimations
|
||||||
|
)
|
||||||
|
|
||||||
if !viewModel.isRefreshing {
|
if !viewModel.isRefreshing {
|
||||||
refreshControl.endRefreshing()
|
refreshControl.endRefreshing()
|
||||||
@@ -446,19 +471,24 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
materialIds: materialIds
|
materialIds: materialIds
|
||||||
),
|
),
|
||||||
api: api,
|
api: api,
|
||||||
onSubmitted: { [weak self] in
|
onSubmitted: { [weak self] _ in self?.handleAIRetouchSubmitted() }
|
||||||
guard let self else { return }
|
|
||||||
self.viewModel.completeAIRetouchSubmission()
|
|
||||||
self.showToast("AI修图任务已提交")
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
present(controller, animated: true)
|
present(controller, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleAIRetouchSubmitted() {
|
||||||
|
showToast("AI修图任务已提交,完成后将通过消息通知")
|
||||||
|
Task { await viewModel.refreshAfterAIRetouchSubmission(api: api) }
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func deleteSelectedTapped() {
|
@objc private func deleteSelectedTapped() {
|
||||||
let count = viewModel.selectedMaterialIds.count
|
let count = viewModel.selectedMaterialIds.count
|
||||||
guard count > 0 else { return }
|
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: .cancel))
|
||||||
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
|
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -469,6 +499,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
|
|
||||||
@objc private func uploadTapped() {
|
@objc private func uploadTapped() {
|
||||||
let album = viewModel.album
|
let album = viewModel.album
|
||||||
|
refreshState.markRefreshNeeded()
|
||||||
let controller = WiredCameraTransferViewController(
|
let controller = WiredCameraTransferViewController(
|
||||||
viewModel: WiredCameraTransferViewModel(
|
viewModel: WiredCameraTransferViewModel(
|
||||||
albumId: album?.id ?? viewModel.albumId,
|
albumId: album?.id ?? viewModel.albumId,
|
||||||
@@ -489,6 +520,9 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
startProjectIndex: startIndex,
|
startProjectIndex: startIndex,
|
||||||
configuration: previewConfiguration,
|
configuration: previewConfiguration,
|
||||||
actionHandler: TravelAlbumPreviewActionHandler(api: previewAPI),
|
actionHandler: TravelAlbumPreviewActionHandler(api: previewAPI),
|
||||||
|
albumId: viewModel.albumId,
|
||||||
|
scenicIdProvider: scenicIdProvider,
|
||||||
|
aiRetouchAPI: previewAPI,
|
||||||
loadMore: {
|
loadMore: {
|
||||||
await previewViewModel.loadMaterials(reset: false, api: previewAPI)
|
await previewViewModel.loadMaterials(reset: false, api: previewAPI)
|
||||||
return (
|
return (
|
||||||
@@ -496,9 +530,14 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
previewViewModel.currentPhotoCount
|
previewViewModel.currentPhotoCount
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
reload: { materialId in
|
||||||
|
let material = try await previewViewModel.refreshMaterial(id: materialId, api: previewAPI)
|
||||||
|
return TravelAlbumPreviewProject(material: material)
|
||||||
|
},
|
||||||
onProjectDeleted: { materialId in
|
onProjectDeleted: { materialId in
|
||||||
previewViewModel.removeMaterialAfterPreviewDeletion(id: materialId)
|
previewViewModel.removeMaterialAfterPreviewDeletion(id: materialId)
|
||||||
}
|
},
|
||||||
|
onAIRetouchSubmitted: { [weak self] in self?.handleAIRetouchSubmitted() }
|
||||||
)
|
)
|
||||||
present(controller, animated: true)
|
present(controller, animated: true)
|
||||||
}
|
}
|
||||||
@@ -656,14 +695,16 @@ private final class TravelAlbumInfoCard: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 旅拍相册素材网格单元,展示正方形缩略图、文件名、大小和选择状态。
|
/// 旅拍相册素材网格单元,独立展示购买、修图和选择状态。
|
||||||
final class TravelAlbumMaterialCell: UICollectionViewCell {
|
final class TravelAlbumMaterialCell: UICollectionViewCell {
|
||||||
static let reuseIdentifier = "TravelAlbumMaterialCell"
|
static let reuseIdentifier = "TravelAlbumMaterialCell"
|
||||||
|
|
||||||
private let imageView = UIImageView()
|
private let imageView = UIImageView()
|
||||||
private let checkImageView = UIImageView()
|
private let checkImageView = UIImageView()
|
||||||
private let badgeView = UIView()
|
private let purchaseBadgeView = UIView()
|
||||||
private let badgeLabel = UILabel()
|
private let purchaseBadgeLabel = UILabel()
|
||||||
|
private let aiRetouchBadgeView = UIView()
|
||||||
|
private let aiRetouchBadgeLabel = UILabel()
|
||||||
private let nameLabel = UILabel()
|
private let nameLabel = UILabel()
|
||||||
private let sizeLabel = UILabel()
|
private let sizeLabel = UILabel()
|
||||||
|
|
||||||
@@ -677,18 +718,18 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
|
|||||||
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||||
checkImageView.layer.cornerRadius = 11
|
checkImageView.layer.cornerRadius = 11
|
||||||
checkImageView.accessibilityIdentifier = "travelAlbum.materialSelectionCheck"
|
checkImageView.accessibilityIdentifier = "travelAlbum.materialSelectionCheck"
|
||||||
badgeView.layer.cornerRadius = 5
|
configureBadge(
|
||||||
badgeView.clipsToBounds = true
|
purchaseBadgeView,
|
||||||
badgeView.isHidden = true
|
label: purchaseBadgeLabel,
|
||||||
badgeView.isAccessibilityElement = false
|
viewIdentifier: "travelAlbum.materialPurchaseBadge",
|
||||||
badgeView.accessibilityIdentifier = "travelAlbum.materialStatusBadge"
|
labelIdentifier: "travelAlbum.materialPurchaseBadgeLabel"
|
||||||
badgeLabel.font = .systemFont(ofSize: 10, weight: .semibold)
|
)
|
||||||
badgeLabel.textColor = .white
|
configureBadge(
|
||||||
badgeLabel.textAlignment = .center
|
aiRetouchBadgeView,
|
||||||
badgeLabel.isAccessibilityElement = false
|
label: aiRetouchBadgeLabel,
|
||||||
badgeLabel.accessibilityIdentifier = "travelAlbum.materialStatusBadgeLabel"
|
viewIdentifier: "travelAlbum.materialAIRetouchBadge",
|
||||||
badgeLabel.setContentHuggingPriority(.required, for: .horizontal)
|
labelIdentifier: "travelAlbum.materialAIRetouchBadgeLabel"
|
||||||
badgeLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
)
|
||||||
nameLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
nameLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
nameLabel.textColor = TravelAlbumDetailStyle.textPrimary
|
nameLabel.textColor = TravelAlbumDetailStyle.textPrimary
|
||||||
nameLabel.lineBreakMode = .byTruncatingMiddle
|
nameLabel.lineBreakMode = .byTruncatingMiddle
|
||||||
@@ -696,8 +737,8 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
|
|||||||
sizeLabel.textColor = TravelAlbumDetailStyle.textSecondary
|
sizeLabel.textColor = TravelAlbumDetailStyle.textSecondary
|
||||||
|
|
||||||
contentView.addSubview(imageView)
|
contentView.addSubview(imageView)
|
||||||
imageView.addSubview(badgeView)
|
imageView.addSubview(purchaseBadgeView)
|
||||||
badgeView.addSubview(badgeLabel)
|
imageView.addSubview(aiRetouchBadgeView)
|
||||||
imageView.addSubview(checkImageView)
|
imageView.addSubview(checkImageView)
|
||||||
contentView.addSubview(nameLabel)
|
contentView.addSubview(nameLabel)
|
||||||
contentView.addSubview(sizeLabel)
|
contentView.addSubview(sizeLabel)
|
||||||
@@ -709,14 +750,13 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
|
|||||||
make.top.trailing.equalToSuperview().inset(6)
|
make.top.trailing.equalToSuperview().inset(6)
|
||||||
make.size.equalTo(22)
|
make.size.equalTo(22)
|
||||||
}
|
}
|
||||||
badgeView.snp.makeConstraints { make in
|
aiRetouchBadgeView.snp.makeConstraints { make in
|
||||||
make.top.leading.equalToSuperview().inset(6)
|
make.top.leading.equalToSuperview().inset(6)
|
||||||
make.trailing.lessThanOrEqualTo(checkImageView.snp.leading).offset(-4)
|
make.trailing.lessThanOrEqualTo(checkImageView.snp.leading).offset(-4)
|
||||||
}
|
}
|
||||||
badgeLabel.snp.makeConstraints { make in
|
purchaseBadgeView.snp.makeConstraints { make in
|
||||||
make.edges.equalToSuperview().inset(
|
make.leading.bottom.equalToSuperview().inset(6)
|
||||||
UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6)
|
make.trailing.lessThanOrEqualToSuperview().inset(6)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
nameLabel.snp.makeConstraints { make in
|
nameLabel.snp.makeConstraints { make in
|
||||||
make.top.equalTo(imageView.snp.bottom).offset(6)
|
make.top.equalTo(imageView.snp.bottom).offset(6)
|
||||||
@@ -746,14 +786,54 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
|
|||||||
checkImageView.isHidden = !selectionMode
|
checkImageView.isHidden = !selectionMode
|
||||||
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||||
checkImageView.tintColor = selected ? TravelAlbumDetailStyle.primary : .white
|
checkImageView.tintColor = selected ? TravelAlbumDetailStyle.primary : .white
|
||||||
let badge = material.badgePresentation
|
let purchaseBadge = material.purchaseBadgePresentation
|
||||||
badgeView.isHidden = badge == nil
|
let aiRetouchBadge = material.aiRetouchBadgePresentation
|
||||||
badgeLabel.text = badge?.text
|
applyBadge(purchaseBadge, to: purchaseBadgeView, label: purchaseBadgeLabel)
|
||||||
badgeView.backgroundColor = badge.map { TravelAlbumDetailStyle.badgeColor(for: $0.kind) }
|
applyBadge(aiRetouchBadge, to: aiRetouchBadgeView, label: aiRetouchBadgeLabel)
|
||||||
nameLabel.text = material.fileName.isEmpty ? "未命名照片" : material.fileName
|
nameLabel.text = material.fileName.isEmpty ? "未命名照片" : material.fileName
|
||||||
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
|
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
|
||||||
let badgeAccessibilityText = badge.map { ",状态:\($0.text)" } ?? ""
|
let purchaseAccessibilityText = purchaseBadge.map { ",购买状态:\($0.text)" } ?? ""
|
||||||
accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")\(badgeAccessibilityText)"
|
let aiRetouchAccessibilityText = aiRetouchBadge.map { ",修图状态:\($0.text)" } ?? ""
|
||||||
|
accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")"
|
||||||
|
+ purchaseAccessibilityText
|
||||||
|
+ aiRetouchAccessibilityText
|
||||||
accessibilityValue = selectionMode ? (selected ? "已选择" : "未选择") : nil
|
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 SnapKit
|
||||||
import UIKit
|
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`。
|
/// 新增相册入口页,对齐 Android `TravelAlbumEntryScreen`。
|
||||||
final class TravelAlbumEntryViewController: BaseViewController {
|
final class TravelAlbumEntryViewController: BaseViewController {
|
||||||
private let viewModel = TravelAlbumEntryViewModel()
|
private let viewModel = TravelAlbumEntryViewModel()
|
||||||
@@ -15,8 +36,10 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
|||||||
private let heroCard = TravelAlbumHeroCard()
|
private let heroCard = TravelAlbumHeroCard()
|
||||||
private let titleLabel = UILabel()
|
private let titleLabel = UILabel()
|
||||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||||
|
private let refreshControl = UIRefreshControl()
|
||||||
private let emptyView = TravelAlbumEmptyView()
|
private let emptyView = TravelAlbumEmptyView()
|
||||||
private var dataSource: UITableViewDiffableDataSource<Int, TravelAlbum>!
|
private var dataSource: UITableViewDiffableDataSource<Int, TravelAlbum>!
|
||||||
|
private var refreshState = TravelAlbumReturnRefreshState()
|
||||||
|
|
||||||
init(api: (any TravelAlbumServing)? = nil) {
|
init(api: (any TravelAlbumServing)? = nil) {
|
||||||
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
||||||
@@ -43,6 +66,8 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
|||||||
tableView.separatorStyle = .none
|
tableView.separatorStyle = .none
|
||||||
tableView.rowHeight = UITableView.automaticDimension
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
tableView.estimatedRowHeight = 148
|
tableView.estimatedRowHeight = 148
|
||||||
|
tableView.alwaysBounceVertical = true
|
||||||
|
tableView.refreshControl = refreshControl
|
||||||
tableView.delegate = self
|
tableView.delegate = self
|
||||||
tableView.register(TravelAlbumTaskCell.self, forCellReuseIdentifier: TravelAlbumTaskCell.reuseIdentifier)
|
tableView.register(TravelAlbumTaskCell.self, forCellReuseIdentifier: TravelAlbumTaskCell.reuseIdentifier)
|
||||||
|
|
||||||
@@ -62,6 +87,7 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
|||||||
view.addSubview(titleLabel)
|
view.addSubview(titleLabel)
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
view.addSubview(emptyView)
|
view.addSubview(emptyView)
|
||||||
|
emptyView.isUserInteractionEnabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
@@ -86,6 +112,7 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
|||||||
|
|
||||||
override func bindActions() {
|
override func bindActions() {
|
||||||
heroCard.addTarget(self, action: #selector(createTapped), for: .touchUpInside)
|
heroCard.addTarget(self, action: #selector(createTapped), for: .touchUpInside)
|
||||||
|
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||||
viewModel.onStateChange = { [weak self] in
|
viewModel.onStateChange = { [weak self] in
|
||||||
Task { @MainActor in self?.applyViewModel() }
|
Task { @MainActor in self?.applyViewModel() }
|
||||||
}
|
}
|
||||||
@@ -99,24 +126,49 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
|||||||
Task { await viewModel.loadAlbums(api: api) }
|
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
|
@MainActor
|
||||||
private func applyViewModel() {
|
private func applyViewModel() {
|
||||||
heroCard.isLoading = viewModel.isCreating
|
heroCard.isLoading = viewModel.isCreating
|
||||||
titleLabel.text = "我的任务(\(viewModel.albumTotal))"
|
titleLabel.text = "我的任务(\(viewModel.albumTotal))"
|
||||||
titleLabel.isHidden = viewModel.albums.isEmpty
|
titleLabel.isHidden = viewModel.albums.isEmpty
|
||||||
tableView.isHidden = viewModel.albums.isEmpty
|
emptyView.isHidden = !viewModel.albums.isEmpty
|
||||||
emptyView.isHidden = !viewModel.albums.isEmpty || viewModel.isLoading
|
|| (viewModel.isLoading && !refreshControl.isRefreshing)
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbum>()
|
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbum>()
|
||||||
snapshot.appendSections([0])
|
snapshot.appendSections([0])
|
||||||
snapshot.appendItems(viewModel.albums)
|
snapshot.appendItems(viewModel.albums)
|
||||||
dataSource.apply(snapshot, animatingDifferences: true)
|
dataSource.apply(
|
||||||
if viewModel.isLoading && viewModel.albums.isEmpty {
|
snapshot,
|
||||||
|
animatingDifferences: !refreshState.suppressesSnapshotAnimations
|
||||||
|
)
|
||||||
|
if viewModel.isLoading && viewModel.albums.isEmpty && !refreshControl.isRefreshing {
|
||||||
showLoading()
|
showLoading()
|
||||||
} else {
|
} else {
|
||||||
hideLoading()
|
hideLoading()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func refreshPulled() {
|
||||||
|
Task {
|
||||||
|
await viewModel.refreshAlbums(api: api)
|
||||||
|
await MainActor.run {
|
||||||
|
self.refreshControl.endRefreshing()
|
||||||
|
self.applyViewModel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func createTapped() {
|
@objc private func createTapped() {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.openCreateSheet(api: api)
|
await viewModel.openCreateSheet(api: api)
|
||||||
@@ -162,6 +214,7 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func pushWiredTransfer(album: TravelAlbum) {
|
private func pushWiredTransfer(album: TravelAlbum) {
|
||||||
|
refreshState.markRefreshNeeded()
|
||||||
let controller = WiredCameraTransferViewController(
|
let controller = WiredCameraTransferViewController(
|
||||||
viewModel: WiredCameraTransferViewModel(
|
viewModel: WiredCameraTransferViewModel(
|
||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
@@ -177,7 +230,11 @@ extension TravelAlbumEntryViewController: UITableViewDelegate {
|
|||||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
guard let album = dataSource.itemIdentifier(for: indexPath) else { return }
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,20 +10,30 @@ import UIKit
|
|||||||
/// 相册项目续页回调,返回当前筛选和排序下的完整已加载项目及总数。
|
/// 相册项目续页回调,返回当前筛选和排序下的完整已加载项目及总数。
|
||||||
typealias TravelAlbumPreviewLoadMore = () async -> (projects: [TravelAlbumPreviewProject], totalCount: Int)
|
typealias TravelAlbumPreviewLoadMore = () async -> (projects: [TravelAlbumPreviewProject], totalCount: Int)
|
||||||
|
|
||||||
|
/// 相册项目定向刷新回调,根据原素材 ID 返回该项目的最新数据。
|
||||||
|
typealias TravelAlbumPreviewReload = (_ materialId: Int) async throws -> TravelAlbumPreviewProject
|
||||||
|
|
||||||
/// 旅拍相册全屏图片预览页,支持项目分页、关联图 Tab、缩放和沉浸式工具栏。
|
/// 旅拍相册全屏图片预览页,支持项目分页、关联图 Tab、缩放和沉浸式工具栏。
|
||||||
final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||||
private var projects: [TravelAlbumPreviewProject]
|
private var projects: [TravelAlbumPreviewProject]
|
||||||
private var totalCount: Int
|
private var totalCount: Int
|
||||||
private let configuration: TravelAlbumPreviewConfiguration
|
private let configuration: TravelAlbumPreviewConfiguration
|
||||||
private let actionHandler: any TravelAlbumPreviewActionHandling
|
private let actionHandler: any TravelAlbumPreviewActionHandling
|
||||||
|
private let albumId: Int
|
||||||
|
private let scenicIdProvider: () -> Int
|
||||||
|
private let aiRetouchAPI: (any TravelAlbumServing)?
|
||||||
private let loadMore: TravelAlbumPreviewLoadMore?
|
private let loadMore: TravelAlbumPreviewLoadMore?
|
||||||
|
private let reload: TravelAlbumPreviewReload?
|
||||||
private let onProjectDeleted: ((Int) -> Void)?
|
private let onProjectDeleted: ((Int) -> Void)?
|
||||||
|
private let onAIRetouchSubmitted: (() -> Void)?
|
||||||
|
private let allowsActions: Bool
|
||||||
private var nodes: [TravelAlbumPreviewNode] = []
|
private var nodes: [TravelAlbumPreviewNode] = []
|
||||||
private var currentNodeIndex = 0
|
private var currentNodeIndex = 0
|
||||||
private var dragStartIndex = 0
|
private var dragStartIndex = 0
|
||||||
private var selectedKind: TravelAlbumPreviewAssetKind = .original
|
private var selectedKind: TravelAlbumPreviewAssetKind = .original
|
||||||
private var chromeVisible = true
|
private var chromeVisible = true
|
||||||
private var isLoadingMore = false
|
private var isLoadingMore = false
|
||||||
|
private var isRefreshingProject = false
|
||||||
private var isDeletingProject = false
|
private var isDeletingProject = false
|
||||||
private var didApplyInitialPosition = false
|
private var didApplyInitialPosition = false
|
||||||
private var lastCollectionSize: CGSize = .zero
|
private var lastCollectionSize: CGSize = .zero
|
||||||
@@ -33,33 +43,63 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
private let closeButton = UIButton(type: .system)
|
private let closeButton = UIButton(type: .system)
|
||||||
private let titleLabel = UILabel()
|
private let titleLabel = UILabel()
|
||||||
private let sizeLabel = UILabel()
|
private let sizeLabel = UILabel()
|
||||||
|
private let counterContainer = UIView()
|
||||||
private let counterLabel = UILabel()
|
private let counterLabel = UILabel()
|
||||||
private let bottomChrome = UIView()
|
private let bottomChrome = UIView()
|
||||||
private let divider = UIView()
|
private let divider = UIView()
|
||||||
private let tabStack = UIStackView()
|
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 actionStack = UIStackView()
|
||||||
private let deleteButton = UIButton(type: .system)
|
private let deleteButton = UIButton(type: .system)
|
||||||
private var tabHeightConstraint: Constraint?
|
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
|
||||||
|
|
||||||
/// 创建全屏预览页。
|
/// 创建全屏预览页。
|
||||||
init(
|
init(
|
||||||
projects: [TravelAlbumPreviewProject],
|
projects: [TravelAlbumPreviewProject],
|
||||||
totalCount: Int,
|
totalCount: Int,
|
||||||
startProjectIndex: Int,
|
startProjectIndex: Int,
|
||||||
|
startKind: TravelAlbumPreviewAssetKind = .original,
|
||||||
|
allowsActions: Bool = true,
|
||||||
configuration: TravelAlbumPreviewConfiguration = .init(),
|
configuration: TravelAlbumPreviewConfiguration = .init(),
|
||||||
actionHandler: any TravelAlbumPreviewActionHandling = PlaceholderTravelAlbumPreviewActionHandler(),
|
actionHandler: any TravelAlbumPreviewActionHandling = PlaceholderTravelAlbumPreviewActionHandler(),
|
||||||
|
albumId: Int = 0,
|
||||||
|
scenicIdProvider: @escaping () -> Int = { 0 },
|
||||||
|
aiRetouchAPI: (any TravelAlbumServing)? = nil,
|
||||||
loadMore: TravelAlbumPreviewLoadMore? = nil,
|
loadMore: TravelAlbumPreviewLoadMore? = nil,
|
||||||
onProjectDeleted: ((Int) -> Void)? = nil
|
reload: TravelAlbumPreviewReload? = nil,
|
||||||
|
onProjectDeleted: ((Int) -> Void)? = nil,
|
||||||
|
onAIRetouchSubmitted: (() -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
self.projects = Self.deduplicated(projects)
|
self.projects = Self.deduplicated(projects)
|
||||||
self.totalCount = max(totalCount, projects.count)
|
self.totalCount = max(totalCount, projects.count)
|
||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
self.actionHandler = actionHandler
|
self.actionHandler = actionHandler
|
||||||
|
self.albumId = albumId
|
||||||
|
self.scenicIdProvider = scenicIdProvider
|
||||||
|
self.aiRetouchAPI = aiRetouchAPI
|
||||||
self.loadMore = loadMore
|
self.loadMore = loadMore
|
||||||
|
self.reload = reload
|
||||||
self.onProjectDeleted = onProjectDeleted
|
self.onProjectDeleted = onProjectDeleted
|
||||||
|
self.onAIRetouchSubmitted = onAIRetouchSubmitted
|
||||||
|
self.allowsActions = allowsActions
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
modalPresentationStyle = .fullScreen
|
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)
|
@available(*, unavailable)
|
||||||
@@ -67,7 +107,8 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
override var prefersStatusBarHidden: Bool { true }
|
override var prefersStatusBarHidden: Bool { false }
|
||||||
|
override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent }
|
||||||
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { [.bottom] }
|
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { [.bottom] }
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
@@ -97,6 +138,8 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
collectionView.isPagingEnabled = true
|
collectionView.isPagingEnabled = true
|
||||||
collectionView.alwaysBounceHorizontal = nodes.count > 1
|
collectionView.alwaysBounceHorizontal = nodes.count > 1
|
||||||
collectionView.showsHorizontalScrollIndicator = false
|
collectionView.showsHorizontalScrollIndicator = false
|
||||||
|
collectionView.contentInsetAdjustmentBehavior = .never
|
||||||
|
collectionView.accessibilityIdentifier = "travelAlbum.previewCollectionView"
|
||||||
collectionView.delegate = self
|
collectionView.delegate = self
|
||||||
collectionView.dataSource = self
|
collectionView.dataSource = self
|
||||||
collectionView.register(
|
collectionView.register(
|
||||||
@@ -104,35 +147,72 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
forCellWithReuseIdentifier: TravelAlbumPreviewImageCell.reuseIdentifier
|
forCellWithReuseIdentifier: TravelAlbumPreviewImageCell.reuseIdentifier
|
||||||
)
|
)
|
||||||
|
|
||||||
topChrome.backgroundColor = UIColor.black.withAlphaComponent(0.72)
|
topChrome.backgroundColor = UIColor.black.withAlphaComponent(0.82)
|
||||||
bottomChrome.backgroundColor = UIColor.black.withAlphaComponent(0.78)
|
topChrome.accessibilityIdentifier = "travelAlbum.previewTopChrome"
|
||||||
divider.backgroundColor = UIColor.white.withAlphaComponent(0.2)
|
bottomChrome.backgroundColor = UIColor.black.withAlphaComponent(0.82)
|
||||||
|
bottomChrome.accessibilityIdentifier = "travelAlbum.previewBottomChrome"
|
||||||
|
divider.backgroundColor = UIColor.white.withAlphaComponent(0.12)
|
||||||
|
|
||||||
closeButton.setImage(UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(weight: .semibold)), for: .normal)
|
var backConfiguration = UIButton.Configuration.plain()
|
||||||
closeButton.tintColor = .white
|
backConfiguration.image = UIImage(systemName: "chevron.down")
|
||||||
|
backConfiguration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(
|
||||||
|
pointSize: 21,
|
||||||
|
weight: .semibold
|
||||||
|
)
|
||||||
|
backConfiguration.baseForegroundColor = .white
|
||||||
|
backConfiguration.contentInsets = .zero
|
||||||
|
closeButton.configuration = backConfiguration
|
||||||
closeButton.accessibilityLabel = "关闭图片预览"
|
closeButton.accessibilityLabel = "关闭图片预览"
|
||||||
|
closeButton.accessibilityIdentifier = "travelAlbum.previewBackButton"
|
||||||
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
|
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
|
||||||
|
|
||||||
titleLabel.textColor = .white
|
titleLabel.textColor = .white
|
||||||
titleLabel.font = .systemFont(ofSize: 15, weight: .medium)
|
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
titleLabel.textAlignment = .center
|
titleLabel.textAlignment = .center
|
||||||
titleLabel.lineBreakMode = .byTruncatingMiddle
|
titleLabel.lineBreakMode = .byTruncatingMiddle
|
||||||
sizeLabel.textColor = UIColor.white.withAlphaComponent(0.65)
|
sizeLabel.textColor = UIColor.white.withAlphaComponent(0.52)
|
||||||
sizeLabel.font = .systemFont(ofSize: 11, weight: .regular)
|
sizeLabel.font = .systemFont(ofSize: 13, weight: .regular)
|
||||||
sizeLabel.textAlignment = .center
|
sizeLabel.textAlignment = .center
|
||||||
|
sizeLabel.accessibilityIdentifier = "travelAlbum.previewFileSizeLabel"
|
||||||
|
|
||||||
counterLabel.textColor = .white
|
counterLabel.textColor = UIColor.white.withAlphaComponent(0.82)
|
||||||
counterLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium)
|
counterLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .medium)
|
||||||
counterLabel.textAlignment = .right
|
counterLabel.textAlignment = .center
|
||||||
|
counterLabel.numberOfLines = 1
|
||||||
|
counterLabel.accessibilityIdentifier = "travelAlbum.previewCounterLabel"
|
||||||
|
counterLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
counterLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
counterLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
counterContainer.backgroundColor = UIColor(white: 0.11, alpha: 1)
|
||||||
|
counterContainer.layer.cornerRadius = 17
|
||||||
|
counterContainer.clipsToBounds = true
|
||||||
|
|
||||||
tabStack.axis = .horizontal
|
variantSegmentedControl.selectedSegmentTintColor = UIColor.white.withAlphaComponent(0.2)
|
||||||
tabStack.distribution = .fillEqually
|
variantSegmentedControl.setTitleTextAttributes(
|
||||||
tabStack.alignment = .fill
|
[.foregroundColor: UIColor.white.withAlphaComponent(0.7)],
|
||||||
|
for: .normal
|
||||||
|
)
|
||||||
|
variantSegmentedControl.setTitleTextAttributes(
|
||||||
|
[.foregroundColor: UIColor.white],
|
||||||
|
for: .selected
|
||||||
|
)
|
||||||
|
variantSegmentedControl.accessibilityIdentifier = "travelAlbum.previewVariantSegmentedControl"
|
||||||
|
variantSegmentedControl.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged)
|
||||||
actionStack.axis = .horizontal
|
actionStack.axis = .horizontal
|
||||||
actionStack.distribution = .fillEqually
|
actionStack.distribution = .fillEqually
|
||||||
actionStack.alignment = .fill
|
actionStack.alignment = .fill
|
||||||
|
actionStack.spacing = 10
|
||||||
|
actionStack.isLayoutMarginsRelativeArrangement = true
|
||||||
|
actionStack.directionalLayoutMargins = NSDirectionalEdgeInsets(
|
||||||
|
top: 9,
|
||||||
|
leading: 0,
|
||||||
|
bottom: 9,
|
||||||
|
trailing: 0
|
||||||
|
)
|
||||||
|
actionStack.accessibilityIdentifier = "travelAlbum.previewActionStack"
|
||||||
|
configureHighResolutionButton()
|
||||||
|
configureComparisonButton()
|
||||||
configureActions()
|
configureActions()
|
||||||
|
actionStack.isHidden = !allowsActions
|
||||||
|
|
||||||
let titleStack = UIStackView(arrangedSubviews: [titleLabel, sizeLabel])
|
let titleStack = UIStackView(arrangedSubviews: [titleLabel, sizeLabel])
|
||||||
titleStack.axis = .vertical
|
titleStack.axis = .vertical
|
||||||
@@ -143,31 +223,39 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
view.addSubview(topChrome)
|
view.addSubview(topChrome)
|
||||||
topChrome.addSubview(closeButton)
|
topChrome.addSubview(closeButton)
|
||||||
topChrome.addSubview(titleStack)
|
topChrome.addSubview(titleStack)
|
||||||
topChrome.addSubview(counterLabel)
|
topChrome.addSubview(counterContainer)
|
||||||
|
counterContainer.addSubview(counterLabel)
|
||||||
view.addSubview(bottomChrome)
|
view.addSubview(bottomChrome)
|
||||||
bottomChrome.addSubview(divider)
|
bottomChrome.addSubview(divider)
|
||||||
bottomChrome.addSubview(tabStack)
|
bottomChrome.addSubview(variantControls)
|
||||||
|
variantControls.addSubview(variantSegmentedControl)
|
||||||
|
variantControls.addSubview(highResolutionButton)
|
||||||
bottomChrome.addSubview(actionStack)
|
bottomChrome.addSubview(actionStack)
|
||||||
|
view.addSubview(comparisonButton)
|
||||||
|
|
||||||
collectionView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
|
||||||
topChrome.snp.makeConstraints { make in
|
topChrome.snp.makeConstraints { make in
|
||||||
make.top.leading.trailing.equalToSuperview()
|
make.top.leading.trailing.equalToSuperview()
|
||||||
make.bottom.equalTo(view.safeAreaLayoutGuide.snp.top).offset(64)
|
make.bottom.equalTo(view.safeAreaLayoutGuide.snp.top).offset(72)
|
||||||
}
|
}
|
||||||
closeButton.snp.makeConstraints { make in
|
closeButton.snp.makeConstraints { make in
|
||||||
make.leading.equalToSuperview().offset(8)
|
make.leading.equalToSuperview().offset(10)
|
||||||
make.bottom.equalToSuperview().offset(-10)
|
make.bottom.equalToSuperview().offset(-14)
|
||||||
make.size.equalTo(44)
|
make.size.equalTo(44)
|
||||||
}
|
}
|
||||||
counterLabel.snp.makeConstraints { make in
|
counterContainer.snp.makeConstraints { make in
|
||||||
make.trailing.equalToSuperview().inset(16)
|
make.trailing.equalToSuperview().inset(16)
|
||||||
make.centerY.equalTo(closeButton)
|
make.centerY.equalTo(closeButton)
|
||||||
make.width.greaterThanOrEqualTo(52)
|
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
|
titleStack.snp.makeConstraints { make in
|
||||||
make.centerY.equalTo(closeButton)
|
make.centerY.equalTo(closeButton)
|
||||||
make.leading.greaterThanOrEqualTo(closeButton.snp.trailing).offset(8)
|
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.centerX.equalToSuperview()
|
||||||
make.width.lessThanOrEqualTo(220)
|
make.width.lessThanOrEqualTo(220)
|
||||||
}
|
}
|
||||||
@@ -175,51 +263,144 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
bottomChrome.snp.makeConstraints { make in
|
bottomChrome.snp.makeConstraints { make in
|
||||||
make.leading.trailing.bottom.equalToSuperview()
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
}
|
}
|
||||||
|
collectionView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
divider.snp.makeConstraints { make in
|
divider.snp.makeConstraints { make in
|
||||||
make.top.leading.trailing.equalToSuperview()
|
make.top.leading.trailing.equalToSuperview()
|
||||||
make.height.equalTo(0.5)
|
make.height.equalTo(0.5)
|
||||||
}
|
}
|
||||||
tabStack.snp.makeConstraints { make in
|
variantControls.snp.makeConstraints { make in
|
||||||
make.top.equalTo(divider.snp.bottom)
|
make.top.equalTo(divider.snp.bottom)
|
||||||
make.leading.trailing.equalToSuperview().inset(12)
|
make.leading.trailing.equalToSuperview()
|
||||||
tabHeightConstraint = make.height.equalTo(0).constraint
|
variantControlsHeightConstraint = make.height.equalTo(56).constraint
|
||||||
|
}
|
||||||
|
variantSegmentedControl.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(20)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
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)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.width.equalTo(116)
|
||||||
|
make.height.equalTo(40)
|
||||||
}
|
}
|
||||||
actionStack.snp.makeConstraints { make in
|
actionStack.snp.makeConstraints { make in
|
||||||
make.top.equalTo(tabStack.snp.bottom)
|
make.top.equalTo(variantControls.snp.bottom)
|
||||||
make.leading.trailing.equalToSuperview().inset(8)
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
make.height.equalTo(70)
|
make.height.equalTo(70)
|
||||||
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
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() {
|
||||||
|
var configuration = UIButton.Configuration.filled()
|
||||||
|
configuration.title = "查看高清图"
|
||||||
|
configuration.baseForegroundColor = .white
|
||||||
|
configuration.baseBackgroundColor = UIColor(white: 0.18, alpha: 1)
|
||||||
|
configuration.background.cornerRadius = 20
|
||||||
|
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
|
||||||
|
var attributes = attributes
|
||||||
|
attributes.font = .systemFont(ofSize: 15, weight: .medium)
|
||||||
|
return attributes
|
||||||
|
}
|
||||||
|
highResolutionButton.configuration = configuration
|
||||||
|
highResolutionButton.accessibilityLabel = "查看高清图"
|
||||||
|
highResolutionButton.accessibilityIdentifier = "travelAlbum.previewHighResolutionButton"
|
||||||
|
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() {
|
private func configureActions() {
|
||||||
let aiButton = makeActionButton(title: "AI修图", systemName: "wand.and.stars", color: UIColor(hex: 0x60A5FA))
|
let aiButton = makeActionButton(
|
||||||
|
title: "AI修图",
|
||||||
|
systemName: "wand.and.stars",
|
||||||
|
foregroundColor: .white,
|
||||||
|
backgroundColor: UIColor(hex: 0x1677FF),
|
||||||
|
strokeColor: UIColor(hex: 0x4096FF)
|
||||||
|
)
|
||||||
|
aiButton.accessibilityIdentifier = "travelAlbum.previewAIRetouchButton"
|
||||||
let deleteConfiguration = makeActionButton(
|
let deleteConfiguration = makeActionButton(
|
||||||
title: "删除",
|
title: "删除",
|
||||||
systemName: "trash",
|
systemName: "trash",
|
||||||
color: UIColor(hex: 0xF87171)
|
foregroundColor: UIColor(hex: 0xFF6B6B),
|
||||||
|
backgroundColor: UIColor(hex: 0x211719),
|
||||||
|
strokeColor: UIColor(hex: 0x4A292D)
|
||||||
).configuration
|
).configuration
|
||||||
deleteButton.configuration = deleteConfiguration
|
deleteButton.configuration = deleteConfiguration
|
||||||
deleteButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
|
|
||||||
deleteButton.accessibilityLabel = "删除"
|
deleteButton.accessibilityLabel = "删除"
|
||||||
deleteButton.accessibilityIdentifier = "travelAlbum.previewDeleteButton"
|
deleteButton.accessibilityIdentifier = "travelAlbum.previewDeleteButton"
|
||||||
let refreshButton = makeActionButton(title: "刷新", systemName: "arrow.clockwise", color: .white)
|
refreshButton.configuration = makeActionButton(
|
||||||
|
title: "刷新",
|
||||||
|
systemName: "arrow.clockwise",
|
||||||
|
foregroundColor: UIColor.white.withAlphaComponent(0.92),
|
||||||
|
backgroundColor: UIColor(hex: 0x1C1C1E),
|
||||||
|
strokeColor: UIColor.white.withAlphaComponent(0.1)
|
||||||
|
).configuration
|
||||||
|
refreshButton.accessibilityLabel = "刷新"
|
||||||
|
refreshButton.accessibilityIdentifier = "travelAlbum.previewRefreshButton"
|
||||||
aiButton.addTarget(self, action: #selector(aiTapped), for: .touchUpInside)
|
aiButton.addTarget(self, action: #selector(aiTapped), for: .touchUpInside)
|
||||||
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
||||||
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
|
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
|
||||||
[aiButton, deleteButton, refreshButton].forEach(actionStack.addArrangedSubview)
|
[aiButton, deleteButton, refreshButton].forEach(actionStack.addArrangedSubview)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeActionButton(title: String, systemName: String, color: UIColor) -> UIButton {
|
private func makeActionButton(
|
||||||
var configuration = UIButton.Configuration.plain()
|
title: String,
|
||||||
|
systemName: String,
|
||||||
|
foregroundColor: UIColor,
|
||||||
|
backgroundColor: UIColor,
|
||||||
|
strokeColor: UIColor
|
||||||
|
) -> UIButton {
|
||||||
|
var configuration = UIButton.Configuration.filled()
|
||||||
configuration.title = title
|
configuration.title = title
|
||||||
configuration.image = UIImage(systemName: systemName)
|
configuration.image = UIImage(systemName: systemName)
|
||||||
configuration.imagePlacement = .top
|
configuration.imagePlacement = .leading
|
||||||
configuration.imagePadding = 5
|
configuration.imagePadding = 7
|
||||||
configuration.baseForegroundColor = color
|
configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(
|
||||||
configuration.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 6, bottom: 8, trailing: 6)
|
pointSize: 18,
|
||||||
|
weight: .semibold
|
||||||
|
)
|
||||||
|
configuration.baseForegroundColor = foregroundColor
|
||||||
|
configuration.baseBackgroundColor = backgroundColor
|
||||||
|
configuration.background.cornerRadius = 14
|
||||||
|
configuration.background.strokeColor = strokeColor
|
||||||
|
configuration.background.strokeWidth = 1
|
||||||
|
configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)
|
||||||
|
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
|
||||||
|
var attributes = attributes
|
||||||
|
attributes.font = .systemFont(ofSize: 14, weight: .semibold)
|
||||||
|
return attributes
|
||||||
|
}
|
||||||
let button = UIButton(configuration: configuration)
|
let button = UIButton(configuration: configuration)
|
||||||
button.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
|
|
||||||
button.accessibilityLabel = title
|
button.accessibilityLabel = title
|
||||||
return button
|
return button
|
||||||
}
|
}
|
||||||
@@ -251,48 +432,124 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
guard let node = currentNode else { return }
|
guard let node = currentNode else { return }
|
||||||
let asset = currentAsset
|
let asset = currentAsset
|
||||||
titleLabel.text = asset?.fileName.isEmpty == false ? asset?.fileName : "未命名照片"
|
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.text = "\(node.projectIndex + 1)/\(max(totalCount, projects.count))"
|
||||||
counterLabel.accessibilityLabel = "第 \(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()
|
rebuildTabs()
|
||||||
|
updateComparisonButton()
|
||||||
loadMoreIfNeeded(projectIndex: node.projectIndex)
|
loadMoreIfNeeded(projectIndex: node.projectIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func rebuildTabs() {
|
private func updateComparisonButton() {
|
||||||
tabStack.arrangedSubviews.forEach {
|
let hasComparison = currentProject?.comparisonContent(for: currentAsset?.kind ?? .original) != nil
|
||||||
tabStack.removeArrangedSubview($0)
|
let shouldShow = chromeVisible && hasComparison
|
||||||
$0.removeFromSuperview()
|
comparisonButton.isHidden = !hasComparison
|
||||||
}
|
comparisonButton.isUserInteractionEnabled = shouldShow
|
||||||
guard let project = currentProject, project.hasVariants else {
|
comparisonButton.alpha = shouldShow ? 1 : 0
|
||||||
tabStack.isHidden = true
|
comparisonButton.transform = shouldShow
|
||||||
tabHeightConstraint?.update(offset: 0)
|
? .identity
|
||||||
return
|
: CGAffineTransform(translationX: 0, y: 10)
|
||||||
}
|
|
||||||
tabStack.isHidden = false
|
|
||||||
tabHeightConstraint?.update(offset: 44)
|
|
||||||
let activeKind = currentAsset?.kind ?? .original
|
|
||||||
for asset in project.orderedAssets {
|
|
||||||
let button = UIButton(type: .system)
|
|
||||||
button.tag = asset.kind.rawValue
|
|
||||||
button.setTitle(asset.kind.title, for: .normal)
|
|
||||||
button.titleLabel?.font = .systemFont(ofSize: 13, weight: asset.kind == activeKind ? .semibold : .medium)
|
|
||||||
button.setTitleColor(asset.kind == activeKind ? .white : UIColor.white.withAlphaComponent(0.55), for: .normal)
|
|
||||||
button.backgroundColor = asset.kind == activeKind ? UIColor.white.withAlphaComponent(0.12) : .clear
|
|
||||||
button.layer.cornerRadius = 8
|
|
||||||
button.accessibilityLabel = asset.kind.title
|
|
||||||
button.accessibilityTraits = asset.kind == activeKind ? [.button, .selected] : .button
|
|
||||||
button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside)
|
|
||||||
tabStack.addArrangedSubview(button)
|
|
||||||
}
|
|
||||||
view.layoutIfNeeded()
|
|
||||||
if !chromeVisible {
|
|
||||||
bottomChrome.transform = CGAffineTransform(translationX: 0, y: bottomChrome.bounds.height)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func tabTapped(_ sender: UIButton) {
|
private func rebuildTabs() {
|
||||||
guard let kind = TravelAlbumPreviewAssetKind(rawValue: sender.tag),
|
while variantSegmentedControl.numberOfSegments > 0 {
|
||||||
let node = currentNode,
|
variantSegmentedControl.removeSegment(at: 0, animated: false)
|
||||||
|
}
|
||||||
|
guard let project = currentProject, project.hasVariants else {
|
||||||
|
setVariantControlsVisible(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let activeKind = currentAsset?.kind ?? .original
|
||||||
|
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
|
||||||
|
setVariantControlsVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setVariantControlsVisible(_ visible: Bool) {
|
||||||
|
let visibilityChanged = didConfigureVariantControls && showsVariantControls != visible
|
||||||
|
didConfigureVariantControls = true
|
||||||
|
showsVariantControls = visible
|
||||||
|
variantControlsAnimationGeneration += 1
|
||||||
|
let animationGeneration = variantControlsAnimationGeneration
|
||||||
|
let shouldAnimate = visibilityChanged
|
||||||
|
&& chromeVisible
|
||||||
|
&& view.window != nil
|
||||||
|
&& UIView.areAnimationsEnabled
|
||||||
|
&& !UIAccessibility.isReduceMotionEnabled
|
||||||
|
|
||||||
|
view.layoutIfNeeded()
|
||||||
|
variantControls.isHidden = false
|
||||||
|
variantSegmentedControl.isHidden = false
|
||||||
|
highResolutionButton.isHidden = false
|
||||||
|
variantControlsHeightConstraint?.update(offset: visible ? 56 : 0)
|
||||||
|
|
||||||
|
guard shouldAnimate else {
|
||||||
|
variantControls.alpha = 1
|
||||||
|
variantControls.transform = .identity
|
||||||
|
view.layoutIfNeeded()
|
||||||
|
variantControls.isHidden = !visible
|
||||||
|
variantSegmentedControl.isHidden = !visible
|
||||||
|
highResolutionButton.isHidden = !visible
|
||||||
|
updateHiddenBottomChromePositionIfNeeded()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if visible {
|
||||||
|
variantControls.alpha = 0
|
||||||
|
variantControls.transform = CGAffineTransform(translationX: 0, y: 8)
|
||||||
|
}
|
||||||
|
UIView.animate(
|
||||||
|
withDuration: 0.24,
|
||||||
|
delay: 0,
|
||||||
|
options: [.curveEaseInOut, .beginFromCurrentState, .allowUserInteraction],
|
||||||
|
animations: {
|
||||||
|
self.variantControls.alpha = visible ? 1 : 0
|
||||||
|
self.variantControls.transform = visible
|
||||||
|
? .identity
|
||||||
|
: CGAffineTransform(translationX: 0, y: 8)
|
||||||
|
self.view.layoutIfNeeded()
|
||||||
|
},
|
||||||
|
completion: { [weak self] finished in
|
||||||
|
guard let self,
|
||||||
|
finished,
|
||||||
|
self.variantControlsAnimationGeneration == animationGeneration
|
||||||
|
else { return }
|
||||||
|
self.variantControls.isHidden = !visible
|
||||||
|
self.variantSegmentedControl.isHidden = !visible
|
||||||
|
self.highResolutionButton.isHidden = !visible
|
||||||
|
self.variantControls.alpha = 1
|
||||||
|
self.variantControls.transform = .identity
|
||||||
|
self.updateHiddenBottomChromePositionIfNeeded()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateHiddenBottomChromePositionIfNeeded() {
|
||||||
|
guard !chromeVisible else { return }
|
||||||
|
bottomChrome.transform = CGAffineTransform(translationX: 0, y: bottomChrome.bounds.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func segmentChanged(_ sender: UISegmentedControl) {
|
||||||
|
guard let project = currentProject,
|
||||||
|
project.orderedAssets.indices.contains(sender.selectedSegmentIndex)
|
||||||
|
else { return }
|
||||||
|
let kind = project.orderedAssets[sender.selectedSegmentIndex].kind
|
||||||
|
guard let node = currentNode,
|
||||||
currentProject?.asset(for: kind) != nil
|
currentProject?.asset(for: kind) != nil
|
||||||
else { return }
|
else { return }
|
||||||
switch configuration.swipeMode {
|
switch configuration.swipeMode {
|
||||||
@@ -384,6 +641,11 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
|
|
||||||
private func toggleChrome() {
|
private func toggleChrome() {
|
||||||
chromeVisible.toggle()
|
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 reduceMotion = UIAccessibility.isReduceMotionEnabled
|
||||||
let animations = {
|
let animations = {
|
||||||
self.topChrome.alpha = self.chromeVisible ? 1 : 0
|
self.topChrome.alpha = self.chromeVisible ? 1 : 0
|
||||||
@@ -394,26 +656,21 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
self.bottomChrome.transform = self.chromeVisible
|
self.bottomChrome.transform = self.chromeVisible
|
||||||
? .identity
|
? .identity
|
||||||
: CGAffineTransform(translationX: 0, y: self.bottomChrome.bounds.height)
|
: 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(
|
UIView.animate(
|
||||||
withDuration: reduceMotion ? 0.12 : 0.22,
|
withDuration: reduceMotion ? 0.12 : 0.22,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
options: reduceMotion ? [.curveEaseOut] : [.curveEaseOut, .beginFromCurrentState],
|
options: reduceMotion ? [.curveEaseOut] : [.curveEaseOut, .beginFromCurrentState],
|
||||||
animations: animations
|
animations: animations,
|
||||||
)
|
completion: { [weak self] _ in
|
||||||
}
|
guard let self else { return }
|
||||||
|
self.comparisonButton.isHidden = !self.chromeVisible || !hasComparison
|
||||||
private func performAction(_ operation: @escaping () async -> TravelAlbumPreviewActionResult) {
|
|
||||||
Task { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
let result = await operation()
|
|
||||||
switch result {
|
|
||||||
case .success(let message):
|
|
||||||
if let message, !message.isEmpty { self.showPreviewToast(message) }
|
|
||||||
case .failure(let message), .unavailable(let message):
|
|
||||||
self.showPreviewToast(message)
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showPreviewToast(_ message: String) {
|
private func showPreviewToast(_ message: String) {
|
||||||
@@ -443,9 +700,52 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
dismiss(animated: true)
|
dismiss(animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func highResolutionTapped() {
|
||||||
|
guard chromeVisible else { return }
|
||||||
|
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() {
|
@objc private func aiTapped() {
|
||||||
guard let id = currentProject?.originalMaterialId else { return }
|
guard presentedViewController == nil else { return }
|
||||||
performAction { [actionHandler] in await actionHandler.requestAIRetouch(originalMaterialId: id) }
|
guard let project = currentProject else { return }
|
||||||
|
guard let aiRetouchAPI else {
|
||||||
|
showPreviewToast("AI修图功能暂不可用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let scenicId = scenicIdProvider()
|
||||||
|
guard scenicId > 0 else {
|
||||||
|
showPreviewToast("请先选择景区")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let selectedKind = currentAsset?.kind ?? .original
|
||||||
|
guard let workflow = project.aiRetouchWorkflow(albumId: albumId, selectedKind: selectedKind) else {
|
||||||
|
showPreviewToast("当前图片不支持AI修图")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let controller = TravelAlbumAIRetouchTemplateViewController(
|
||||||
|
viewModel: TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
scenicId: scenicId,
|
||||||
|
workflow: workflow
|
||||||
|
),
|
||||||
|
api: aiRetouchAPI,
|
||||||
|
onSubmitted: { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.dismiss(animated: true) { self.onAIRetouchSubmitted?() }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
present(controller, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func deleteTapped() {
|
@objc private func deleteTapped() {
|
||||||
@@ -539,8 +839,92 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func refreshTapped() {
|
@objc private func refreshTapped() {
|
||||||
guard let id = currentProject?.originalMaterialId else { return }
|
reloadProjects(showGlobalLoading: true, forceRefreshImage: true)
|
||||||
performAction { [actionHandler] in await actionHandler.refreshProject(originalMaterialId: id) }
|
}
|
||||||
|
|
||||||
|
private func reloadProjects(showGlobalLoading: Bool, forceRefreshImage: Bool) {
|
||||||
|
guard !isRefreshingProject else { return }
|
||||||
|
guard let reload else { return }
|
||||||
|
isRefreshingProject = true
|
||||||
|
updateRefreshButton()
|
||||||
|
if showGlobalLoading { GlobalLoadingManager.shared.show() }
|
||||||
|
let currentProjectId = currentProject?.id
|
||||||
|
let currentProjectIndex = currentNode?.projectIndex ?? 0
|
||||||
|
let kind = currentAsset?.kind ?? .original
|
||||||
|
|
||||||
|
Task { [weak self] in
|
||||||
|
defer {
|
||||||
|
if showGlobalLoading { GlobalLoadingManager.shared.hide() }
|
||||||
|
}
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
guard let currentProjectId else {
|
||||||
|
self.isRefreshingProject = false
|
||||||
|
self.updateRefreshButton()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let project = try await reload(currentProjectId)
|
||||||
|
self.applySuccessfulReload(
|
||||||
|
project: project,
|
||||||
|
currentProjectId: currentProjectId,
|
||||||
|
fallbackProjectIndex: currentProjectIndex,
|
||||||
|
kind: kind,
|
||||||
|
forceRefreshImage: forceRefreshImage
|
||||||
|
)
|
||||||
|
} catch is CancellationError {
|
||||||
|
self.isRefreshingProject = false
|
||||||
|
self.updateRefreshButton()
|
||||||
|
} catch {
|
||||||
|
self.isRefreshingProject = false
|
||||||
|
self.updateRefreshButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applySuccessfulReload(
|
||||||
|
project refreshedProject: TravelAlbumPreviewProject,
|
||||||
|
currentProjectId: Int,
|
||||||
|
fallbackProjectIndex: Int,
|
||||||
|
kind: TravelAlbumPreviewAssetKind,
|
||||||
|
forceRefreshImage: Bool
|
||||||
|
) {
|
||||||
|
guard !projects.isEmpty else {
|
||||||
|
isRefreshingProject = false
|
||||||
|
updateRefreshButton()
|
||||||
|
dismiss(animated: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetProjectIndex = projects.firstIndex { $0.id == currentProjectId }
|
||||||
|
?? min(max(0, fallbackProjectIndex), projects.count - 1)
|
||||||
|
projects[targetProjectIndex] = refreshedProject
|
||||||
|
let resolvedKind = refreshedProject.asset(for: kind) == nil ? .original : kind
|
||||||
|
selectedKind = resolvedKind
|
||||||
|
rebuildNodes(keepingProjectIndex: targetProjectIndex, kind: resolvedKind)
|
||||||
|
collectionView.reloadData()
|
||||||
|
collectionView.layoutIfNeeded()
|
||||||
|
setPage(currentNodeIndex, animated: false)
|
||||||
|
updateForCurrentNode()
|
||||||
|
if forceRefreshImage { forceRefreshCurrentImage() }
|
||||||
|
isRefreshingProject = false
|
||||||
|
updateRefreshButton()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func forceRefreshCurrentImage() {
|
||||||
|
let indexPath = IndexPath(item: currentNodeIndex, section: 0)
|
||||||
|
guard let cell = collectionView.cellForItem(at: indexPath) as? TravelAlbumPreviewImageCell else { return }
|
||||||
|
cell.apply(asset: currentAsset, forceRefresh: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateRefreshButton() {
|
||||||
|
refreshButton.isEnabled = !isRefreshingProject
|
||||||
|
refreshButton.alpha = isRefreshingProject ? 0.55 : 1
|
||||||
|
refreshButton.accessibilityValue = isRefreshingProject ? "刷新中" : nil
|
||||||
|
var configuration = refreshButton.configuration
|
||||||
|
configuration?.showsActivityIndicator = isRefreshingProject
|
||||||
|
configuration?.image = isRefreshingProject ? nil : UIImage(systemName: "arrow.clockwise")
|
||||||
|
configuration?.title = isRefreshingProject ? "刷新中" : "刷新"
|
||||||
|
refreshButton.configuration = configuration
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,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 提供远程加载、双击和双指缩放。
|
/// 预览图片 Cell,使用 UIScrollView 提供远程加载、双击和双指缩放。
|
||||||
private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate {
|
private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate {
|
||||||
static let reuseIdentifier = "TravelAlbumPreviewImageCell"
|
static let reuseIdentifier = "TravelAlbumPreviewImageCell"
|
||||||
@@ -639,10 +1073,10 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
|
|||||||
resetZoom()
|
resetZoom()
|
||||||
}
|
}
|
||||||
|
|
||||||
func apply(asset newAsset: TravelAlbumPreviewAsset?) {
|
func apply(asset newAsset: TravelAlbumPreviewAsset?, forceRefresh: Bool = false) {
|
||||||
asset = newAsset
|
asset = newAsset
|
||||||
resetZoom()
|
resetZoom()
|
||||||
loadImage()
|
loadImage(forceRefresh: forceRefresh)
|
||||||
accessibilityLabel = newAsset.map {
|
accessibilityLabel = newAsset.map {
|
||||||
"\($0.kind.title),\($0.fileName.isEmpty ? "未命名照片" : $0.fileName)"
|
"\($0.kind.title),\($0.fileName.isEmpty ? "未命名照片" : $0.fileName)"
|
||||||
}
|
}
|
||||||
@@ -681,7 +1115,7 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
|
|||||||
|
|
||||||
imageView.contentMode = .scaleAspectFit
|
imageView.contentMode = .scaleAspectFit
|
||||||
imageView.backgroundColor = .black
|
imageView.backgroundColor = .black
|
||||||
imageView.kf.indicatorType = .activity
|
imageView.kf.indicatorType = .custom(indicator: TravelAlbumPreviewLoadingIndicator())
|
||||||
imageView.accessibilityIdentifier = "travelAlbum.previewImageView"
|
imageView.accessibilityIdentifier = "travelAlbum.previewImageView"
|
||||||
|
|
||||||
retryButton.setTitle("图片加载失败,点击重试", for: .normal)
|
retryButton.setTitle("图片加载失败,点击重试", for: .normal)
|
||||||
@@ -710,7 +1144,7 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
|
|||||||
scrollView.addGestureRecognizer(doubleTap)
|
scrollView.addGestureRecognizer(doubleTap)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadImage() {
|
private func loadImage(forceRefresh: Bool = false) {
|
||||||
retryButton.isHidden = true
|
retryButton.isHidden = true
|
||||||
let text = asset?.displayURL
|
let text = asset?.displayURL
|
||||||
guard let text, let url = URL(string: text), !text.isEmpty else {
|
guard let text, let url = URL(string: text), !text.isEmpty else {
|
||||||
@@ -718,7 +1152,8 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
|
|||||||
retryButton.isHidden = false
|
retryButton.isHidden = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
imageView.kf.setImage(with: url) { [weak self] result in
|
let options: KingfisherOptionsInfo? = forceRefresh ? [.forceRefresh] : nil
|
||||||
|
imageView.kf.setImage(with: url, options: options) { [weak self] result in
|
||||||
guard let self, self.asset?.displayURL == text else { return }
|
guard let self, self.asset?.displayURL == text else { return }
|
||||||
if case .failure = result, self.imageView.image == nil {
|
if case .failure = result, self.imageView.image == nil {
|
||||||
self.retryButton.isHidden = false
|
self.retryButton.isHidden = false
|
||||||
@@ -748,7 +1183,7 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func retryTapped() {
|
@objc private func retryTapped() {
|
||||||
loadImage()
|
loadImage(forceRefresh: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ final class MessageCenterAPITests: XCTestCase {
|
|||||||
"id": 21,
|
"id": 21,
|
||||||
"receiver_id": 9,
|
"receiver_id": 9,
|
||||||
"receiver_type": "staff",
|
"receiver_type": "staff",
|
||||||
"type": 2,
|
"type": 14,
|
||||||
"type_name": "退款成功通知",
|
"type_name": "AI修图任务通知",
|
||||||
"title": "unused",
|
"title": "unused",
|
||||||
"content": "退款已完成",
|
"content": "退款已完成",
|
||||||
"push_at": "2026-07-09 10:00:00",
|
"push_at": "2026-07-09 10:00:00",
|
||||||
@@ -28,7 +28,7 @@ final class MessageCenterAPITests: XCTestCase {
|
|||||||
"read_at": "",
|
"read_at": "",
|
||||||
"push_channel": 1,
|
"push_channel": 1,
|
||||||
"push_channel_name": "站内信",
|
"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",
|
"created_at": "2026-07-09 10:00:00",
|
||||||
"updated_at": "2026-07-09 10:01:00"
|
"updated_at": "2026-07-09 10:01:00"
|
||||||
}]
|
}]
|
||||||
@@ -50,7 +50,7 @@ final class MessageCenterAPITests: XCTestCase {
|
|||||||
XCTAssertEqual(response.lastId, 18)
|
XCTAssertEqual(response.lastId, 18)
|
||||||
XCTAssertEqual(response.items.first?.id, 21)
|
XCTAssertEqual(response.items.first?.id, 21)
|
||||||
XCTAssertEqual(response.items.first?.isRead, false)
|
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 {
|
func testUnreadCountReadAllReadAndDeleteUseExpectedContracts() async throws {
|
||||||
|
|||||||
@@ -178,6 +178,44 @@ final class MessageCenterViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(failureMessage, "删除失败: server down")
|
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
|
@MainActor
|
||||||
@@ -226,12 +264,17 @@ private struct TestError: LocalizedError {
|
|||||||
var errorDescription: String? { message }
|
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(
|
MessageItem(
|
||||||
id: id,
|
id: id,
|
||||||
receiverId: 1,
|
receiverId: 1,
|
||||||
receiverType: "staff",
|
receiverType: "staff",
|
||||||
type: 1,
|
type: type,
|
||||||
typeName: "系统通知",
|
typeName: "系统通知",
|
||||||
title: "系统通知",
|
title: "系统通知",
|
||||||
content: "消息内容",
|
content: "消息内容",
|
||||||
@@ -240,6 +283,7 @@ private func makeMessage(id: Int, isRead: Bool = false) -> MessageItem {
|
|||||||
readAt: "",
|
readAt: "",
|
||||||
pushChannel: 1,
|
pushChannel: 1,
|
||||||
pushChannelName: "站内信",
|
pushChannelName: "站内信",
|
||||||
|
extraData: extraData,
|
||||||
createdAt: "2026-07-09 10:00:00",
|
createdAt: "2026-07-09 10:00:00",
|
||||||
updatedAt: "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)
|
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 {
|
func testPushAPIUsesAndroidCompatibleEndpointAndQuery() async throws {
|
||||||
let session = MockURLSession(responses: [try TestJSON.envelope(data: EmptyPayload())])
|
let session = MockURLSession(responses: [try TestJSON.envelope(data: EmptyPayload())])
|
||||||
let api = PushAPI(client: APIClient(environment: .testing, session: session))
|
let api = PushAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|||||||
@@ -6,28 +6,36 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
@testable import suixinkan
|
@testable import suixinkan
|
||||||
|
|
||||||
/// AI 修图模板选择状态测试。
|
/// AI 修图模板工作流、选择状态与请求分流测试。
|
||||||
@MainActor
|
@MainActor
|
||||||
final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
||||||
func testLoadDefaultsRequiredSelectionsAndLeavesAtmosphereEmpty() async {
|
func testInitialWorkflowDefaultsRequiredSelectionsAndLeavesAtmosphereEmpty() async {
|
||||||
let api = makeAPI()
|
let api = makeAPI()
|
||||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
albumId: 8,
|
albumId: 8,
|
||||||
scenicId: 18,
|
scenicId: 18,
|
||||||
materialIds: [4, 3, 2, 1]
|
materialIds: [4, 3, 2, 1, 1]
|
||||||
)
|
)
|
||||||
|
|
||||||
await viewModel.loadTemplates(api: api)
|
await viewModel.loadTemplates(api: api)
|
||||||
|
|
||||||
XCTAssertEqual(api.aiRetouchTemplateScenicIds, [18])
|
XCTAssertEqual(api.aiRetouchTemplateScenicIds, [18])
|
||||||
|
XCTAssertEqual(viewModel.workflow, .initial(albumId: 8, materialIds: [1, 2, 3, 4]))
|
||||||
|
XCTAssertEqual(viewModel.selectedPhotoCount, 4)
|
||||||
|
XCTAssertTrue(viewModel.shouldShowInitialTips)
|
||||||
|
XCTAssertEqual(
|
||||||
|
viewModel.initialTipsText,
|
||||||
|
"Tips:氛围感修图为选填;封面风格为必选,将使用第一张照片另生成封面。"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(viewModel.visibleCategories, [.refined, .atmosphere, .cover])
|
||||||
XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11)
|
XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11)
|
||||||
XCTAssertNil(viewModel.selectedAtmosphereTemplateId)
|
XCTAssertNil(viewModel.selectedAtmosphereTemplateId)
|
||||||
XCTAssertEqual(viewModel.selectedCoverTemplateId, 31)
|
XCTAssertEqual(viewModel.selectedCoverTemplateId, 31)
|
||||||
XCTAssertTrue(viewModel.showsCoverTemplates)
|
XCTAssertTrue(viewModel.isOptional(.atmosphere))
|
||||||
XCTAssertTrue(viewModel.canSubmit)
|
XCTAssertTrue(viewModel.canSubmit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAtmosphereSelectionTogglesOffWhenTappedAgain() async {
|
func testInitialAtmosphereSelectionTogglesOffWhenTappedAgain() async {
|
||||||
let api = makeAPI()
|
let api = makeAPI()
|
||||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
albumId: 8,
|
albumId: 8,
|
||||||
@@ -36,6 +44,14 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
await viewModel.loadTemplates(api: api)
|
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)
|
viewModel.toggleTemplate(id: 21, category: .atmosphere)
|
||||||
XCTAssertEqual(viewModel.selectedAtmosphereTemplateId, 21)
|
XCTAssertEqual(viewModel.selectedAtmosphereTemplateId, 21)
|
||||||
|
|
||||||
@@ -43,70 +59,193 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
|||||||
XCTAssertNil(viewModel.selectedAtmosphereTemplateId)
|
XCTAssertNil(viewModel.selectedAtmosphereTemplateId)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testThreeMaterialsHideCoverAndOmitCoverFromSubmission() async {
|
func testInitialSubmissionUsesCoverTemplateOnlyForFourOrMoreMaterials() async {
|
||||||
let api = makeAPI()
|
let api = makeAPI()
|
||||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
let threePhotoViewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
albumId: 8,
|
albumId: 8,
|
||||||
scenicId: 18,
|
scenicId: 18,
|
||||||
materialIds: [3, 1, 2]
|
materialIds: [3, 1, 2]
|
||||||
)
|
)
|
||||||
var submitted = false
|
await threePhotoViewModel.loadTemplates(api: api)
|
||||||
viewModel.onSubmitted = { submitted = true }
|
await threePhotoViewModel.submit(api: api)
|
||||||
await viewModel.loadTemplates(api: api)
|
|
||||||
|
|
||||||
XCTAssertFalse(viewModel.showsCoverTemplates)
|
XCTAssertEqual(
|
||||||
XCTAssertNil(viewModel.selectedCoverTemplateId)
|
api.aiRetouchRequests[0],
|
||||||
|
TravelAlbumAIRetouchRequest(
|
||||||
|
userEquityTravelId: 8,
|
||||||
|
materialIds: [1, 2, 3],
|
||||||
|
refinedTemplateId: 11,
|
||||||
|
atmosphereTemplateId: nil,
|
||||||
|
coverTemplateId: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
await viewModel.submit(api: api)
|
let fourPhotoViewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
|
||||||
XCTAssertTrue(submitted)
|
|
||||||
XCTAssertEqual(api.aiRetouchRequests.count, 1)
|
|
||||||
XCTAssertEqual(api.aiRetouchRequests.first?.materialIds, [1, 2, 3])
|
|
||||||
XCTAssertNil(api.aiRetouchRequests.first?.aiPreviewTabs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testFourMaterialsSubmitAllSelectedTemplateIds() async {
|
|
||||||
let api = makeAPI()
|
|
||||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
|
||||||
albumId: 8,
|
albumId: 8,
|
||||||
scenicId: 18,
|
scenicId: 18,
|
||||||
materialIds: [1, 2, 3, 4]
|
materialIds: [1, 2, 3, 4]
|
||||||
)
|
)
|
||||||
await viewModel.loadTemplates(api: api)
|
await fourPhotoViewModel.loadTemplates(api: api)
|
||||||
viewModel.toggleTemplate(id: 12, category: .refined)
|
fourPhotoViewModel.toggleTemplate(id: 12, category: .refined)
|
||||||
viewModel.toggleTemplate(id: 21, category: .atmosphere)
|
fourPhotoViewModel.toggleTemplate(id: 21, category: .atmosphere)
|
||||||
|
await fourPhotoViewModel.submit(api: api)
|
||||||
await viewModel.submit(api: api)
|
|
||||||
|
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
api.aiRetouchRequests.first,
|
api.aiRetouchRequests[1],
|
||||||
TravelAlbumAIRetouchRequest(
|
TravelAlbumAIRetouchRequest(
|
||||||
userEquityTravelId: 8,
|
userEquityTravelId: 8,
|
||||||
materialIds: [1, 2, 3, 4],
|
materialIds: [1, 2, 3, 4],
|
||||||
refinedTemplateId: 12,
|
refinedTemplateId: 12,
|
||||||
atmosphereTemplateId: 21,
|
atmosphereTemplateId: 21,
|
||||||
aiPreviewTabs: 31
|
coverTemplateId: 31
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMissingRequiredCoverTemplateDisablesSubmission() async {
|
func testReretouchWorkflowAllowsAtmosphereOnlyFromOriginalTab() async {
|
||||||
|
let api = makeAPI()
|
||||||
|
let refined = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
scenicId: 18,
|
||||||
|
workflow: .reretouch(materialId: 7, batchId: 70, type: .refined)
|
||||||
|
)
|
||||||
|
await refined.loadTemplates(api: api)
|
||||||
|
XCTAssertEqual(refined.selectedPhotoCount, 1)
|
||||||
|
XCTAssertFalse(refined.shouldShowInitialTips)
|
||||||
|
XCTAssertEqual(refined.visibleCategories, [.refined])
|
||||||
|
XCTAssertEqual(refined.selectedRefinedTemplateId, 11)
|
||||||
|
XCTAssertNil(refined.selectedAtmosphereTemplateId)
|
||||||
|
await refined.submit(api: api)
|
||||||
|
|
||||||
|
let atmosphere = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
scenicId: 18,
|
||||||
|
workflow: .reretouch(materialId: 8, batchId: 80, type: .atmosphere)
|
||||||
|
)
|
||||||
|
await atmosphere.loadTemplates(api: api)
|
||||||
|
XCTAssertEqual(atmosphere.visibleCategories, [.atmosphere])
|
||||||
|
XCTAssertEqual(atmosphere.selectedAtmosphereTemplateId, 21)
|
||||||
|
atmosphere.toggleTemplate(id: 21, category: .atmosphere)
|
||||||
|
XCTAssertEqual(atmosphere.selectedAtmosphereTemplateId, 21)
|
||||||
|
await atmosphere.submit(api: api)
|
||||||
|
|
||||||
|
let all = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
scenicId: 18,
|
||||||
|
workflow: .reretouch(materialId: 9, batchId: 90, type: .all)
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
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, [
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: 7,
|
||||||
|
aiRetouchBatchId: 70,
|
||||||
|
type: .refined,
|
||||||
|
refinedTemplateId: 11,
|
||||||
|
atmosphereTemplateId: nil
|
||||||
|
),
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: 8,
|
||||||
|
aiRetouchBatchId: 80,
|
||||||
|
type: .atmosphere,
|
||||||
|
refinedTemplateId: nil,
|
||||||
|
atmosphereTemplateId: 21
|
||||||
|
),
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: 9,
|
||||||
|
aiRetouchBatchId: 90,
|
||||||
|
type: .all,
|
||||||
|
refinedTemplateId: nil,
|
||||||
|
atmosphereTemplateId: 21
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInvalidReretouchBatchDisablesSubmission() async {
|
||||||
|
let api = makeAPI()
|
||||||
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
scenicId: 18,
|
||||||
|
workflow: .reretouch(materialId: 7, batchId: 0, type: .refined)
|
||||||
|
)
|
||||||
|
var message: String?
|
||||||
|
viewModel.onShowMessage = { message = $0 }
|
||||||
|
|
||||||
|
await viewModel.loadTemplates(api: api)
|
||||||
|
await viewModel.submit(api: api)
|
||||||
|
|
||||||
|
XCTAssertFalse(viewModel.canSubmit)
|
||||||
|
XCTAssertEqual(viewModel.validationMessage, "当前图片缺少修图批次,请刷新后重试")
|
||||||
|
XCTAssertEqual(message, "当前图片缺少修图批次,请刷新后重试")
|
||||||
|
XCTAssertTrue(api.aiReretouchRequests.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMissingOptionalAtmosphereStillAllowsSelectingRefinedInAllReretouchWorkflow() async {
|
||||||
let api = makeAPI()
|
let api = makeAPI()
|
||||||
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||||
refinedTemplates: [template(11, "清透")],
|
refinedTemplates: [template(11, "清透")],
|
||||||
atmosphereTemplates: [template(21, "暖阳")],
|
atmosphereTemplates: [],
|
||||||
coverTemplates: []
|
coverTemplates: [],
|
||||||
|
remainingQuota: 100
|
||||||
)
|
)
|
||||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
albumId: 8,
|
|
||||||
scenicId: 18,
|
scenicId: 18,
|
||||||
materialIds: [1, 2, 3, 4]
|
workflow: .reretouch(materialId: 7, batchId: 70, type: .all)
|
||||||
)
|
)
|
||||||
|
|
||||||
await viewModel.loadTemplates(api: api)
|
await viewModel.loadTemplates(api: api)
|
||||||
|
|
||||||
XCTAssertFalse(viewModel.canSubmit)
|
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 {
|
func testLoadFailureExposesRetryMessageAndKeepsSubmissionDisabled() async {
|
||||||
@@ -127,21 +266,20 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
func testSubmitFailureKeepsSelectionAndAllowsRetry() async {
|
func testSubmitFailureKeepsSelectionAndAllowsRetry() async {
|
||||||
let api = makeAPI()
|
let api = makeAPI()
|
||||||
api.submitAIRetouchError = APIError.serverCode(500, "提交服务繁忙")
|
api.submitAIReretouchError = APIError.serverCode(500, "提交服务繁忙")
|
||||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
albumId: 8,
|
|
||||||
scenicId: 18,
|
scenicId: 18,
|
||||||
materialIds: [1]
|
workflow: .reretouch(materialId: 7, batchId: 70, type: .refined)
|
||||||
)
|
)
|
||||||
var message: String?
|
var message: String?
|
||||||
var submitted = false
|
var submitted = false
|
||||||
viewModel.onShowMessage = { message = $0 }
|
viewModel.onShowMessage = { message = $0 }
|
||||||
viewModel.onSubmitted = { submitted = true }
|
viewModel.onSubmitted = { _ in submitted = true }
|
||||||
await viewModel.loadTemplates(api: api)
|
await viewModel.loadTemplates(api: api)
|
||||||
|
|
||||||
await viewModel.submit(api: api)
|
await viewModel.submit(api: api)
|
||||||
|
|
||||||
XCTAssertEqual(api.aiRetouchRequests.count, 1)
|
XCTAssertEqual(api.aiReretouchRequests.count, 1)
|
||||||
XCTAssertEqual(message, "提交服务繁忙")
|
XCTAssertEqual(message, "提交服务繁忙")
|
||||||
XCTAssertFalse(submitted)
|
XCTAssertFalse(submitted)
|
||||||
XCTAssertFalse(viewModel.isSubmitting)
|
XCTAssertFalse(viewModel.isSubmitting)
|
||||||
@@ -168,12 +306,33 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
|||||||
XCTAssertFalse(viewModel.isSubmitting)
|
XCTAssertFalse(viewModel.isSubmitting)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeAPI() -> TravelAlbumMockAPI {
|
func testRemainingQuotaDisablesSubmissionWhenSelectedOutputsExceedBalance() async {
|
||||||
|
let api = makeAPI(remainingQuota: 2)
|
||||||
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
albumId: 8,
|
||||||
|
scenicId: 18,
|
||||||
|
materialIds: [1, 2]
|
||||||
|
)
|
||||||
|
await viewModel.loadTemplates(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.remainingQuota, 2)
|
||||||
|
XCTAssertEqual(viewModel.requiredQuota, 2)
|
||||||
|
XCTAssertTrue(viewModel.canSubmit)
|
||||||
|
|
||||||
|
viewModel.toggleTemplate(id: 21, category: .atmosphere)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.requiredQuota, 4)
|
||||||
|
XCTAssertFalse(viewModel.canSubmit)
|
||||||
|
XCTAssertEqual(viewModel.validationMessage, "剩余修图次数不足,需要4次,当前剩余2次")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeAPI(remainingQuota: Int = 100) -> TravelAlbumMockAPI {
|
||||||
let api = TravelAlbumMockAPI()
|
let api = TravelAlbumMockAPI()
|
||||||
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||||
refinedTemplates: [template(11, "清透"), template(12, "自然")],
|
refinedTemplates: [template(11, "清透"), template(12, "自然")],
|
||||||
atmosphereTemplates: [template(21, "暖阳")],
|
atmosphereTemplates: [template(21, "暖阳")],
|
||||||
coverTemplates: [template(31, "杂志")]
|
coverTemplates: [template(31, "杂志")],
|
||||||
|
remainingQuota: remainingQuota
|
||||||
)
|
)
|
||||||
return api
|
return api
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,42 @@ final class TravelAlbumAPITests: XCTestCase {
|
|||||||
XCTAssertEqual(session.requests[2].url?.path, "/api/yf-handset-app/photog/travel-album/mp-code")
|
XCTAssertEqual(session.requests[2].url?.path, "/api/yf-handset-app/photog/travel-album/mp-code")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testMaterialInfoBuildsQueryAndDecodesMaterial() async throws {
|
||||||
|
let data = envelopeJSON(
|
||||||
|
#"{"id":6,"user_equity_travel_id":3,"status":1,"order_number":"","user_id":9,"file_name":"A.JPG","file_type":2,"file_url":"https://cdn/a.jpg","file_size":1024,"cover_url":"","is_purchased":false,"ai_retouch_status":3,"ai_retouch_status_name":"AI已修","ai_retouch_batch_id":60,"ai_refined_url":"https://cdn/refined.jpg","ai_atmosphere_url":"https://cdn/atmosphere.jpg","created_at":"","updated_at":""}"#
|
||||||
|
)
|
||||||
|
let session = MockURLSession(responses: [data])
|
||||||
|
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
let material = try await api.materialInfo(userEquityTravelId: 3, materialId: 6)
|
||||||
|
|
||||||
|
XCTAssertEqual(material.id, 6)
|
||||||
|
XCTAssertEqual(material.aiRetouchBatchId, 60)
|
||||||
|
XCTAssertEqual(material.aiRefinedURL, "https://cdn/refined.jpg")
|
||||||
|
let request = try XCTUnwrap(session.requests.first)
|
||||||
|
XCTAssertEqual(request.httpMethod, "GET")
|
||||||
|
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/material-info")
|
||||||
|
let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems
|
||||||
|
XCTAssertEqual(query?.first { $0.name == "user_equity_travel_id" }?.value, "3")
|
||||||
|
XCTAssertEqual(query?.first { $0.name == "material_id" }?.value, "6")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBatchDeleteMaterialsBuildsPathAndBody() async throws {
|
||||||
|
let session = MockURLSession(responses: [envelopeJSON("{}")])
|
||||||
|
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
try await api.batchDeleteMaterials(ids: [2031, 2032, 2033])
|
||||||
|
|
||||||
|
let request = try XCTUnwrap(session.requests.first)
|
||||||
|
XCTAssertEqual(request.httpMethod, "POST")
|
||||||
|
XCTAssertEqual(
|
||||||
|
request.url?.path,
|
||||||
|
"/api/yf-handset-app/photog/travel-album/batch-delete-material"
|
||||||
|
)
|
||||||
|
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any]
|
||||||
|
XCTAssertEqual(body?["ids"] as? [Int], [2031, 2032, 2033])
|
||||||
|
}
|
||||||
|
|
||||||
func testUploadMaterialBuildsPathAndBody() async throws {
|
func testUploadMaterialBuildsPathAndBody() async throws {
|
||||||
let material = envelopeJSON(#"{"id":6,"user_equity_travel_id":3,"status":1,"order_number":"","user_id":9,"file_name":"A.JPG","file_type":1,"file_url":"https://cdn/a.jpg","file_size":1024,"cover_url":"","is_purchased":false,"created_at":"2026-07-08 10:00:00","updated_at":"2026-07-08 10:00:00"}"#)
|
let material = envelopeJSON(#"{"id":6,"user_equity_travel_id":3,"status":1,"order_number":"","user_id":9,"file_name":"A.JPG","file_type":1,"file_url":"https://cdn/a.jpg","file_size":1024,"cover_url":"","is_purchased":false,"created_at":"2026-07-08 10:00:00","updated_at":"2026-07-08 10:00:00"}"#)
|
||||||
let session = MockURLSession(responses: [material])
|
let session = MockURLSession(responses: [material])
|
||||||
@@ -126,7 +162,7 @@ final class TravelAlbumAPITests: XCTestCase {
|
|||||||
|
|
||||||
func testAIRetouchTemplatesBuildsQueryAndDecodesGroups() async throws {
|
func testAIRetouchTemplatesBuildsQueryAndDecodesGroups() async throws {
|
||||||
let data = envelopeJSON(
|
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"}]}"#
|
#"{"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 session = MockURLSession(responses: [data])
|
||||||
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||||
@@ -134,8 +170,11 @@ final class TravelAlbumAPITests: XCTestCase {
|
|||||||
let response = try await api.aiRetouchTemplates(scenicId: 18)
|
let response = try await api.aiRetouchTemplates(scenicId: 18)
|
||||||
|
|
||||||
XCTAssertEqual(response.refinedTemplates.first?.name, "清透")
|
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.atmosphereTemplates.first?.id, 2)
|
||||||
XCTAssertEqual(response.coverTemplates.first?.previewURL, "https://cdn/cover.jpg")
|
XCTAssertEqual(response.coverTemplates.first?.previewURL, "https://cdn/cover.jpg")
|
||||||
|
XCTAssertEqual(response.remainingQuota, 12)
|
||||||
let request = try XCTUnwrap(session.requests.first)
|
let request = try XCTUnwrap(session.requests.first)
|
||||||
XCTAssertEqual(request.httpMethod, "GET")
|
XCTAssertEqual(request.httpMethod, "GET")
|
||||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch-templates")
|
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch-templates")
|
||||||
@@ -144,7 +183,7 @@ final class TravelAlbumAPITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testSubmitAIRetouchEncodesRequiredAndSelectedOptionalTemplates() async throws {
|
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))
|
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
try await api.submitAIRetouch(
|
try await api.submitAIRetouch(
|
||||||
@@ -153,7 +192,7 @@ final class TravelAlbumAPITests: XCTestCase {
|
|||||||
materialIds: [11, 12, 13, 14],
|
materialIds: [11, 12, 13, 14],
|
||||||
refinedTemplateId: 21,
|
refinedTemplateId: 21,
|
||||||
atmosphereTemplateId: 22,
|
atmosphereTemplateId: 22,
|
||||||
aiPreviewTabs: 31
|
coverTemplateId: 31
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -165,11 +204,18 @@ final class TravelAlbumAPITests: XCTestCase {
|
|||||||
XCTAssertEqual(body?["material_ids"] as? [Int], [11, 12, 13, 14])
|
XCTAssertEqual(body?["material_ids"] as? [Int], [11, 12, 13, 14])
|
||||||
XCTAssertEqual(body?["refined_template_id"] as? Int, 21)
|
XCTAssertEqual(body?["refined_template_id"] as? Int, 21)
|
||||||
XCTAssertEqual(body?["atmosphere_template_id"] as? Int, 22)
|
XCTAssertEqual(body?["atmosphere_template_id"] as? Int, 22)
|
||||||
XCTAssertEqual(body?["ai_preview_tabs"] as? Int, 31)
|
XCTAssertEqual(body?["cover_template_id"] as? Int, 31)
|
||||||
|
XCTAssertEqual(Set(body?.keys.map { $0 } ?? []), [
|
||||||
|
"user_equity_travel_id",
|
||||||
|
"material_ids",
|
||||||
|
"refined_template_id",
|
||||||
|
"atmosphere_template_id",
|
||||||
|
"cover_template_id",
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSubmitAIRetouchOmitsAllOptionalTemplatesWhenAbsent() async throws {
|
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))
|
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
try await api.submitAIRetouch(
|
try await api.submitAIRetouch(
|
||||||
@@ -178,13 +224,133 @@ final class TravelAlbumAPITests: XCTestCase {
|
|||||||
materialIds: [11],
|
materialIds: [11],
|
||||||
refinedTemplateId: 21,
|
refinedTemplateId: 21,
|
||||||
atmosphereTemplateId: nil,
|
atmosphereTemplateId: nil,
|
||||||
aiPreviewTabs: nil
|
coverTemplateId: nil
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(session.requests.first?.httpBody)) as? [String: Any]
|
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(session.requests.first?.httpBody)) as? [String: Any]
|
||||||
XCTAssertNil(body?["atmosphere_template_id"])
|
XCTAssertNil(body?["atmosphere_template_id"])
|
||||||
XCTAssertNil(body?["ai_preview_tabs"])
|
XCTAssertNil(body?["cover_template_id"])
|
||||||
|
XCTAssertEqual(Set(body?.keys.map { $0 } ?? []), [
|
||||||
|
"user_equity_travel_id",
|
||||||
|
"material_ids",
|
||||||
|
"refined_template_id",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSubmitAIReretouchEncodesOnlyFieldsRequiredByEachType() async throws {
|
||||||
|
let session = MockURLSession(responses: [
|
||||||
|
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))
|
||||||
|
|
||||||
|
try await api.submitAIReretouch(
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: 11,
|
||||||
|
aiRetouchBatchId: 51,
|
||||||
|
type: .refined,
|
||||||
|
refinedTemplateId: 21,
|
||||||
|
atmosphereTemplateId: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try await api.submitAIReretouch(
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: 12,
|
||||||
|
aiRetouchBatchId: 52,
|
||||||
|
type: .atmosphere,
|
||||||
|
refinedTemplateId: nil,
|
||||||
|
atmosphereTemplateId: 22
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try await api.submitAIReretouch(
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: 13,
|
||||||
|
aiRetouchBatchId: 53,
|
||||||
|
type: .all,
|
||||||
|
refinedTemplateId: 21,
|
||||||
|
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: 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"])
|
||||||
|
XCTAssertEqual(Set(bodies[2]?.keys.map { $0 } ?? []), [
|
||||||
|
"id",
|
||||||
|
"ai_retouch_batch_id",
|
||||||
|
"type",
|
||||||
|
"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 {
|
private func envelopeJSON(_ dataJSON: String) -> Data {
|
||||||
@@ -192,4 +358,10 @@ final class TravelAlbumAPITests: XCTestCase {
|
|||||||
{"code":100000,"msg":"success","data":\(dataJSON)}
|
{"code":100000,"msg":"success","data":\(dataJSON)}
|
||||||
""".data(using: .utf8)!
|
""".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"}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,40 @@ import XCTest
|
|||||||
|
|
||||||
/// 旅拍相册模型与展示工具测试。
|
/// 旅拍相册模型与展示工具测试。
|
||||||
final class TravelAlbumModelsTests: XCTestCase {
|
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() {
|
func testPreviewProjectMapsMaterialToOriginalAsset() {
|
||||||
let material = TravelAlbumMaterial(
|
let material = TravelAlbumMaterial(
|
||||||
id: 8,
|
id: 8,
|
||||||
@@ -33,6 +67,7 @@ final class TravelAlbumModelsTests: XCTestCase {
|
|||||||
fileUrl: "https://cdn.example.com/original.jpg",
|
fileUrl: "https://cdn.example.com/original.jpg",
|
||||||
fileSize: 4096,
|
fileSize: 4096,
|
||||||
coverUrl: "https://cdn.example.com/cover.jpg",
|
coverUrl: "https://cdn.example.com/cover.jpg",
|
||||||
|
aiRetouchBatchId: 55,
|
||||||
aiRefinedURL: " https://cdn.example.com/refined.jpg ",
|
aiRefinedURL: " https://cdn.example.com/refined.jpg ",
|
||||||
aiAtmosphereURL: "https://cdn.example.com/atmosphere.jpg"
|
aiAtmosphereURL: "https://cdn.example.com/atmosphere.jpg"
|
||||||
)
|
)
|
||||||
@@ -50,6 +85,7 @@ final class TravelAlbumModelsTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
XCTAssertNil(project.asset(for: .cover))
|
XCTAssertNil(project.asset(for: .cover))
|
||||||
XCTAssertTrue(project.hasVariants)
|
XCTAssertTrue(project.hasVariants)
|
||||||
|
XCTAssertEqual(project.aiRetouchBatchId, 55)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPreviewProjectIgnoresBlankAIResultURLs() {
|
func testPreviewProjectIgnoresBlankAIResultURLs() {
|
||||||
@@ -66,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() {
|
func testPreviewAssetFallsBackToAvailableURLForBothQualityLevels() {
|
||||||
let missingOriginal = TravelAlbumPreviewAsset(
|
let missingOriginal = TravelAlbumPreviewAsset(
|
||||||
id: "cover-only",
|
id: "cover-only",
|
||||||
@@ -161,24 +268,40 @@ final class TravelAlbumModelsTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPlaceholderPreviewActionsReturnUnavailableWithoutMutation() async {
|
func testPreviewProjectUsesAllReretouchWorkflowForEveryVisibleVariantTab() {
|
||||||
let handler = PlaceholderTravelAlbumPreviewActionHandler()
|
let originalOnly = makePreviewProject(id: 9, kinds: [.original])
|
||||||
let aiResult = await handler.requestAIRetouch(originalMaterialId: 9)
|
let variants = TravelAlbumPreviewProject(
|
||||||
let deleteResult = await handler.deleteProject(originalMaterialId: 9)
|
originalMaterialId: 10,
|
||||||
let refreshResult = await handler.refreshProject(originalMaterialId: 9)
|
aiRetouchBatchId: 88,
|
||||||
|
assets: makePreviewProject(id: 10, kinds: [.original, .retouched, .atmosphere]).assets
|
||||||
|
)
|
||||||
|
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
aiResult,
|
originalOnly.aiRetouchWorkflow(albumId: 7, selectedKind: .original),
|
||||||
.unavailable("AI修图功能待接口接入")
|
.initial(albumId: 7, materialIds: [9])
|
||||||
)
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
variants.aiRetouchWorkflow(albumId: 7, selectedKind: .original),
|
||||||
|
.reretouch(materialId: 10, batchId: 88, type: .all)
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
variants.aiRetouchWorkflow(albumId: 7, selectedKind: .retouched),
|
||||||
|
.reretouch(materialId: 10, batchId: 88, type: .all)
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
variants.aiRetouchWorkflow(albumId: 7, selectedKind: .atmosphere),
|
||||||
|
.reretouch(materialId: 10, batchId: 88, type: .all)
|
||||||
|
)
|
||||||
|
XCTAssertNil(variants.aiRetouchWorkflow(albumId: 7, selectedKind: .cover))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPlaceholderPreviewDeleteReturnsUnavailableWithoutMutation() async {
|
||||||
|
let handler = PlaceholderTravelAlbumPreviewActionHandler()
|
||||||
|
let deleteResult = await handler.deleteProject(originalMaterialId: 9)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
deleteResult,
|
deleteResult,
|
||||||
.unavailable("项目删除接口待接入")
|
.unavailable("项目删除接口待接入")
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
|
||||||
refreshResult,
|
|
||||||
.unavailable("关联图片刷新接口待接入")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testTravelAlbumDecodesSnakeCaseFields() throws {
|
func testTravelAlbumDecodesSnakeCaseFields() throws {
|
||||||
@@ -225,6 +348,7 @@ final class TravelAlbumModelsTests: XCTestCase {
|
|||||||
"is_purchased": false,
|
"is_purchased": false,
|
||||||
"ai_retouch_status": 3,
|
"ai_retouch_status": 3,
|
||||||
"ai_retouch_status_name": "AI已修",
|
"ai_retouch_status_name": "AI已修",
|
||||||
|
"ai_retouch_batch_id": 66,
|
||||||
"ai_refined_url": "https://cdn/refined.jpg",
|
"ai_refined_url": "https://cdn/refined.jpg",
|
||||||
"ai_atmosphere_url": "https://cdn/atmosphere.jpg",
|
"ai_atmosphere_url": "https://cdn/atmosphere.jpg",
|
||||||
"created_at": "",
|
"created_at": "",
|
||||||
@@ -238,6 +362,7 @@ final class TravelAlbumModelsTests: XCTestCase {
|
|||||||
XCTAssertFalse(material.isPurchased)
|
XCTAssertFalse(material.isPurchased)
|
||||||
XCTAssertEqual(material.aiRetouchStatus, 3)
|
XCTAssertEqual(material.aiRetouchStatus, 3)
|
||||||
XCTAssertEqual(material.aiRetouchStatusName, "AI已修")
|
XCTAssertEqual(material.aiRetouchStatusName, "AI已修")
|
||||||
|
XCTAssertEqual(material.aiRetouchBatchId, 66)
|
||||||
XCTAssertEqual(material.aiRefinedURL, "https://cdn/refined.jpg")
|
XCTAssertEqual(material.aiRefinedURL, "https://cdn/refined.jpg")
|
||||||
XCTAssertEqual(material.aiAtmosphereURL, "https://cdn/atmosphere.jpg")
|
XCTAssertEqual(material.aiAtmosphereURL, "https://cdn/atmosphere.jpg")
|
||||||
}
|
}
|
||||||
@@ -258,6 +383,7 @@ final class TravelAlbumModelsTests: XCTestCase {
|
|||||||
"is_purchased": false,
|
"is_purchased": false,
|
||||||
"ai_retouch_status": "invalid",
|
"ai_retouch_status": "invalid",
|
||||||
"ai_retouch_status_name": null,
|
"ai_retouch_status_name": null,
|
||||||
|
"ai_retouch_batch_id": "invalid",
|
||||||
"ai_refined_url": 123,
|
"ai_refined_url": 123,
|
||||||
"created_at": "",
|
"created_at": "",
|
||||||
"updated_at": ""
|
"updated_at": ""
|
||||||
@@ -268,38 +394,53 @@ final class TravelAlbumModelsTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(material.aiRetouchStatus, 0)
|
XCTAssertEqual(material.aiRetouchStatus, 0)
|
||||||
XCTAssertEqual(material.aiRetouchStatusName, "")
|
XCTAssertEqual(material.aiRetouchStatusName, "")
|
||||||
|
XCTAssertEqual(material.aiRetouchBatchId, 0)
|
||||||
XCTAssertEqual(material.aiRefinedURL, "")
|
XCTAssertEqual(material.aiRefinedURL, "")
|
||||||
XCTAssertEqual(material.aiAtmosphereURL, "")
|
XCTAssertEqual(material.aiAtmosphereURL, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testTravelAlbumMaterialBadgePriorityAndFallbacks() {
|
func testTravelAlbumMaterialPurchaseAndAIRetouchBadgesAreIndependent() {
|
||||||
XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 0).badgePresentation)
|
XCTAssertNil(TravelAlbumMaterial().purchaseBadgePresentation)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 0, aiRetouchStatusName: "已上传")
|
TravelAlbumMaterial(isPurchased: true).purchaseBadgePresentation,
|
||||||
.badgePresentation,
|
TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
||||||
|
)
|
||||||
|
XCTAssertNil(TravelAlbumMaterial(isPurchased: true).aiRetouchBadgePresentation)
|
||||||
|
let purchasedAndProcessing = TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 2)
|
||||||
|
XCTAssertEqual(
|
||||||
|
purchasedAndProcessing.purchaseBadgePresentation,
|
||||||
TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
TravelAlbumMaterial(aiRetouchStatus: 1, aiRetouchStatusName: " 排队中 ").badgePresentation,
|
purchasedAndProcessing.aiRetouchBadgePresentation,
|
||||||
TravelAlbumMaterialBadgePresentation(kind: .pending, text: "排队中")
|
|
||||||
)
|
|
||||||
XCTAssertEqual(
|
|
||||||
TravelAlbumMaterial(aiRetouchStatus: 2, aiRetouchStatusName: " ").badgePresentation,
|
|
||||||
TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中")
|
TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中")
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
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已修")
|
TravelAlbumMaterialBadgePresentation(kind: .retouched, text: "AI已修")
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面").badgePresentation,
|
TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面")
|
||||||
|
.aiRetouchBadgePresentation,
|
||||||
TravelAlbumMaterialBadgePresentation(kind: .cover, text: "AI封面")
|
TravelAlbumMaterialBadgePresentation(kind: .cover, text: "AI封面")
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
TravelAlbumMaterial(aiRetouchStatus: 4).badgePresentation,
|
TravelAlbumMaterial(aiRetouchStatus: 4).aiRetouchBadgePresentation,
|
||||||
TravelAlbumMaterialBadgePresentation(kind: .failed, text: "失败")
|
TravelAlbumMaterialBadgePresentation(kind: .failed, text: "失败")
|
||||||
)
|
)
|
||||||
XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 99, aiRetouchStatusName: "未知").badgePresentation)
|
XCTAssertNil(
|
||||||
|
TravelAlbumMaterial(aiRetouchStatus: 99, aiRetouchStatusName: "未知")
|
||||||
|
.aiRetouchBadgePresentation
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDisplayFormatters() {
|
func testDisplayFormatters() {
|
||||||
|
|||||||
@@ -6,9 +6,112 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
@testable import suixinkan
|
@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
|
@MainActor
|
||||||
/// 旅拍相册 ViewModel 测试。
|
/// 旅拍相册 ViewModel 测试。
|
||||||
final class TravelAlbumEntryViewModelTests: XCTestCase {
|
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 {
|
func testLoadAlbumsUpdatesListAndTotal() async {
|
||||||
let api = TravelAlbumMockAPI()
|
let api = TravelAlbumMockAPI()
|
||||||
api.listResponse = TravelAlbumListResponse(total: 1, list: [TravelAlbum(id: 1, name: "A")])
|
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")
|
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 {
|
func testPreShootRequiresSinglePrice() async {
|
||||||
let api = TravelAlbumMockAPI()
|
let api = TravelAlbumMockAPI()
|
||||||
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
|
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
|
||||||
@@ -163,14 +279,22 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(TravelAlbumDisplayFormatter.creationTimeText(""), "--")
|
XCTAssertEqual(TravelAlbumDisplayFormatter.creationTimeText(""), "--")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOnlyUnpurchasedMaterialCanBeSelected() {
|
func testAllMaterialsCanBeSelectedAndPurchasedTabSupportsSelectionMode() async {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.materialListResponses = [TravelAlbumListResponse(total: 1, list: [])]
|
||||||
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||||
|
await viewModel.selectTab(.purchased, api: api)
|
||||||
viewModel.toggleSelectionMode()
|
viewModel.toggleSelectionMode()
|
||||||
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 2))
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, isPurchased: true))
|
||||||
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 2, isPurchased: false))
|
||||||
|
|
||||||
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 1))
|
XCTAssertTrue(viewModel.isSelectionMode)
|
||||||
XCTAssertEqual(viewModel.selectedMaterialIds, [1])
|
XCTAssertEqual(viewModel.selectedMaterialIds, [1, 2])
|
||||||
|
XCTAssertEqual(viewModel.selectedPurchasedMaterialCount, 1)
|
||||||
|
XCTAssertEqual(
|
||||||
|
viewModel.deleteSelectedConfirmationMessage,
|
||||||
|
"已选择 2 张素材,其中 1 张已购买。不会删除已购买的项目,只会删除 1 张未购买的项目。"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCompletingAIRetouchClearsSelectionModeAndSelectedMaterials() {
|
func testCompletingAIRetouchClearsSelectionModeAndSelectedMaterials() {
|
||||||
@@ -184,6 +308,33 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
|||||||
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
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 {
|
func testPreviewDeletionRemovesMaterialAndSynchronizesCounts() async {
|
||||||
let api = TravelAlbumMockAPI()
|
let api = TravelAlbumMockAPI()
|
||||||
api.materialListResponses = [
|
api.materialListResponses = [
|
||||||
@@ -206,6 +357,31 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.purchasedPhotoCount, 0)
|
XCTAssertEqual(viewModel.purchasedPhotoCount, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testRefreshMaterialUpdatesOnlyMatchingCachedMaterial() async throws {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.materialListResponses = [
|
||||||
|
TravelAlbumListResponse(
|
||||||
|
total: 2,
|
||||||
|
list: [TravelAlbumMaterial(id: 7), TravelAlbumMaterial(id: 8)]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
api.materialInfoResponse = TravelAlbumMaterial(
|
||||||
|
id: 7,
|
||||||
|
aiRetouchBatchId: 700,
|
||||||
|
aiRefinedURL: "https://cdn/refined.jpg"
|
||||||
|
)
|
||||||
|
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||||
|
await viewModel.loadMaterials(reset: true, api: api)
|
||||||
|
|
||||||
|
let material = try await viewModel.refreshMaterial(id: 7, api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(material.aiRetouchBatchId, 700)
|
||||||
|
XCTAssertEqual(viewModel.materials.first { $0.id == 7 }?.aiRefinedURL, "https://cdn/refined.jpg")
|
||||||
|
XCTAssertEqual(viewModel.materials.first { $0.id == 8 }?.aiRetouchBatchId, 0)
|
||||||
|
XCTAssertEqual(api.materialInfoRequests, [.init(userEquityTravelId: 2, materialId: 7)])
|
||||||
|
XCTAssertEqual(api.materialRequests.count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
func testDeleteAlbumCallsCallback() async {
|
func testDeleteAlbumCallsCallback() async {
|
||||||
let api = TravelAlbumMockAPI()
|
let api = TravelAlbumMockAPI()
|
||||||
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
|
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
|
||||||
@@ -217,6 +393,62 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(api.deletedAlbumIds, [5])
|
XCTAssertEqual(api.deletedAlbumIds, [5])
|
||||||
XCTAssertEqual(deletedId, 5)
|
XCTAssertEqual(deletedId, 5)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDeleteSelectedMaterialsUsesSingleBatchRequest() 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: 2033, status: 1))
|
||||||
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 2031, status: 1))
|
||||||
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 2032, status: 1))
|
||||||
|
|
||||||
|
await viewModel.deleteSelectedMaterials(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.deletedMaterialIDBatches, [[2031, 2032, 2033]])
|
||||||
|
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 测试。
|
/// 有线传输 ViewModel 测试。
|
||||||
@@ -1031,6 +1263,11 @@ private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
|
|||||||
/// 旅拍相册 API 测试替身。
|
/// 旅拍相册 API 测试替身。
|
||||||
@MainActor
|
@MainActor
|
||||||
final class TravelAlbumMockAPI: TravelAlbumServing {
|
final class TravelAlbumMockAPI: TravelAlbumServing {
|
||||||
|
struct AIJobListRequest: Equatable {
|
||||||
|
let statusGroup: TravelAlbumAIJobFilter
|
||||||
|
let limit: Int
|
||||||
|
let cursor: String?
|
||||||
|
}
|
||||||
struct MaterialRequest: Equatable {
|
struct MaterialRequest: Equatable {
|
||||||
let userEquityTravelId: Int
|
let userEquityTravelId: Int
|
||||||
let page: Int
|
let page: Int
|
||||||
@@ -1039,12 +1276,20 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
|||||||
let isPurchased: Int?
|
let isPurchased: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct MaterialInfoRequest: Equatable {
|
||||||
|
let userEquityTravelId: Int
|
||||||
|
let materialId: Int
|
||||||
|
}
|
||||||
|
|
||||||
var availableOrdersResponse: [TravelAlbumAvailableOrder] = []
|
var availableOrdersResponse: [TravelAlbumAvailableOrder] = []
|
||||||
var listResponse = TravelAlbumListResponse<TravelAlbum>()
|
var listResponse = TravelAlbumListResponse<TravelAlbum>()
|
||||||
|
var listError: Error?
|
||||||
var createResponse = TravelAlbumCreateResponse(id: 0)
|
var createResponse = TravelAlbumCreateResponse(id: 0)
|
||||||
var infoResponse = TravelAlbum()
|
var infoResponse = TravelAlbum()
|
||||||
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
|
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
|
||||||
var materialListFailingCallIndexes: Set<Int> = []
|
var materialListFailingCallIndexes: Set<Int> = []
|
||||||
|
var materialInfoResponse = TravelAlbumMaterial()
|
||||||
|
var materialInfoError: Error?
|
||||||
var uploadMaterialResponse = TravelAlbumMaterial()
|
var uploadMaterialResponse = TravelAlbumMaterial()
|
||||||
var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [])
|
var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [])
|
||||||
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
|
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
|
||||||
@@ -1053,18 +1298,34 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
|||||||
var aiRetouchTemplatesError: Error?
|
var aiRetouchTemplatesError: Error?
|
||||||
var submitAIRetouchError: Error?
|
var submitAIRetouchError: Error?
|
||||||
var submitAIRetouchDelayNanoseconds: UInt64 = 0
|
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 deleteMaterialError: Error?
|
||||||
var deleteMaterialDelayNanoseconds: UInt64 = 0
|
var deleteMaterialDelayNanoseconds: UInt64 = 0
|
||||||
|
|
||||||
private(set) var availableOrdersCallCount = 0
|
private(set) var availableOrdersCallCount = 0
|
||||||
private(set) var createRequests: [TravelAlbumCreateRequest] = []
|
private(set) var createRequests: [TravelAlbumCreateRequest] = []
|
||||||
private(set) var materialRequests: [MaterialRequest] = []
|
private(set) var materialRequests: [MaterialRequest] = []
|
||||||
|
private(set) var materialInfoRequests: [MaterialInfoRequest] = []
|
||||||
private(set) var uploadMaterialRequests: [TravelAlbumUploadMaterialRequest] = []
|
private(set) var uploadMaterialRequests: [TravelAlbumUploadMaterialRequest] = []
|
||||||
private(set) var materialClientPhotoIdsCallCount = 0
|
private(set) var materialClientPhotoIdsCallCount = 0
|
||||||
private(set) var deletedAlbumIds: [Int] = []
|
private(set) var deletedAlbumIds: [Int] = []
|
||||||
private(set) var deletedMaterialIds: [Int] = []
|
private(set) var deletedMaterialIDBatches: [[Int]] = []
|
||||||
private(set) var aiRetouchTemplateScenicIds: [Int] = []
|
private(set) var aiRetouchTemplateScenicIds: [Int] = []
|
||||||
private(set) var aiRetouchRequests: [TravelAlbumAIRetouchRequest] = []
|
private(set) var aiRetouchRequests: [TravelAlbumAIRetouchRequest] = []
|
||||||
|
private(set) var aiReretouchRequests: [TravelAlbumAIReretouchRequest] = []
|
||||||
|
private(set) var aiJobListRequests: [AIJobListRequest] = []
|
||||||
|
|
||||||
func availableOrders() async throws -> [TravelAlbumAvailableOrder] {
|
func availableOrders() async throws -> [TravelAlbumAvailableOrder] {
|
||||||
availableOrdersCallCount += 1
|
availableOrdersCallCount += 1
|
||||||
@@ -1078,7 +1339,8 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func list(page: Int, pageSize: Int) async throws -> TravelAlbumListResponse<TravelAlbum> {
|
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 {
|
func info(id: Int) async throws -> TravelAlbum {
|
||||||
@@ -1109,6 +1371,14 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
|||||||
return materialListResponses.removeFirst()
|
return materialListResponses.removeFirst()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func materialInfo(userEquityTravelId: Int, materialId: Int) async throws -> TravelAlbumMaterial {
|
||||||
|
materialInfoRequests.append(
|
||||||
|
MaterialInfoRequest(userEquityTravelId: userEquityTravelId, materialId: materialId)
|
||||||
|
)
|
||||||
|
if let materialInfoError { throw materialInfoError }
|
||||||
|
return materialInfoResponse
|
||||||
|
}
|
||||||
|
|
||||||
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
|
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
|
||||||
uploadMaterialRequests.append(request)
|
uploadMaterialRequests.append(request)
|
||||||
return uploadMaterialResponse
|
return uploadMaterialResponse
|
||||||
@@ -1123,8 +1393,8 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
|||||||
deletedAlbumIds.append(id)
|
deletedAlbumIds.append(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteMaterial(id: Int) async throws {
|
func batchDeleteMaterials(ids: [Int]) async throws {
|
||||||
deletedMaterialIds.append(id)
|
deletedMaterialIDBatches.append(ids)
|
||||||
if deleteMaterialDelayNanoseconds > 0 {
|
if deleteMaterialDelayNanoseconds > 0 {
|
||||||
try await Task.sleep(nanoseconds: deleteMaterialDelayNanoseconds)
|
try await Task.sleep(nanoseconds: deleteMaterialDelayNanoseconds)
|
||||||
}
|
}
|
||||||
@@ -1141,11 +1411,39 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
|||||||
return aiRetouchTemplatesResponse
|
return aiRetouchTemplatesResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws {
|
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission {
|
||||||
aiRetouchRequests.append(request)
|
aiRetouchRequests.append(request)
|
||||||
if submitAIRetouchDelayNanoseconds > 0 {
|
if submitAIRetouchDelayNanoseconds > 0 {
|
||||||
try await Task.sleep(nanoseconds: submitAIRetouchDelayNanoseconds)
|
try await Task.sleep(nanoseconds: submitAIRetouchDelayNanoseconds)
|
||||||
}
|
}
|
||||||
if let submitAIRetouchError { throw submitAIRetouchError }
|
if let submitAIRetouchError { throw submitAIRetouchError }
|
||||||
|
return aiJobSubmission
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||