Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dca4bd5a20 | ||
|
|
eb57666033 | ||
|
|
2c4123214d | ||
|
|
8cd6c0aac8 | ||
|
|
1757ba5e36 | ||
|
|
c34c4e1923 | ||
|
|
b127495e52 | ||
|
|
76aa7bb4aa | ||
|
|
8e444f7434 | ||
|
|
83afcb6deb | ||
|
|
30bfe0313f | ||
|
|
0f3b26991e | ||
|
|
e190b5958b | ||
|
|
6e2899dbfd | ||
|
|
48b6b9be22 | ||
|
|
202b0f5e41 | ||
|
|
7b9e630838 | ||
|
|
109ec3dad0 | ||
|
|
f2a005e008 | ||
|
|
60502784db | ||
|
|
05cfdc3662 | ||
|
|
010add828b | ||
|
|
aca19a53fb | ||
|
|
e64dcec094 | ||
|
|
43c75c8e36 | ||
|
|
5704531f3a | ||
|
|
87819780c9 | ||
|
|
3b88b9cde9 | ||
|
|
439edf827c | ||
|
|
24fa66281c | ||
|
|
99fbad3e97 | ||
|
|
dcdab3cff7 |
@@ -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` 支持以下统一语义:
|
||||||
|
|
||||||
|
> 对本次传入模板对应的每种输出独立处理:已有结果则在新任务成功后覆盖,没有结果则新增;未传模板的类型不处理。不要因为某一种关联图历史上从未生成,就拒绝整个重新修图请求。
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="430" viewBox="0 0 1200 430">
|
|
||||||
<defs>
|
|
||||||
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
|
|
||||||
<path d="M0,0 L0,6 L9,3 z" fill="#4D7CFE"/>
|
|
||||||
</marker>
|
|
||||||
<style>
|
|
||||||
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
|
|
||||||
.label { font: 600 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
|
|
||||||
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
|
|
||||||
.box { fill: #FFFFFF; stroke: #B9C8DA; stroke-width: 2; }
|
|
||||||
.active { fill: #EEF4FF; stroke: #4D7CFE; stroke-width: 2.5; }
|
|
||||||
.action { fill: #F6F8FB; stroke: #D5DEE9; stroke-width: 2; }
|
|
||||||
.line { fill: none; stroke: #4D7CFE; stroke-width: 2.5; marker-end: url(#arrow); }
|
|
||||||
</style>
|
|
||||||
</defs>
|
|
||||||
<rect width="1200" height="430" rx="24" fill="#F7F9FC"/>
|
|
||||||
<text x="42" y="52" class="title">照片预览与左右切换</text>
|
|
||||||
<rect x="42" y="92" width="205" height="104" rx="16" class="box"/>
|
|
||||||
<text x="144" y="126" text-anchor="middle" class="label">相册管理缩略图</text>
|
|
||||||
<text x="144" y="158" text-anchor="middle" class="small">点击任意照片</text>
|
|
||||||
<path d="M247 144 H326" class="line"/>
|
|
||||||
<rect x="326" y="78" width="300" height="132" rx="18" class="active"/>
|
|
||||||
<text x="476" y="115" text-anchor="middle" class="label">单一返回按钮 + 文件信息</text>
|
|
||||||
<text x="476" y="146" text-anchor="middle" class="small">文件名 / 文件大小 / 当前照片</text>
|
|
||||||
<text x="476" y="176" text-anchor="middle" class="small">底部:AI修图、删除、刷新</text>
|
|
||||||
<path d="M626 144 H706" class="line"/>
|
|
||||||
<rect x="706" y="78" width="205" height="132" rx="18" class="box"/>
|
|
||||||
<text x="808" y="115" text-anchor="middle" class="label">左右滑动</text>
|
|
||||||
<text x="808" y="146" text-anchor="middle" class="small">切换上一张 / 下一张</text>
|
|
||||||
<text x="808" y="176" text-anchor="middle" class="small">首尾不循环</text>
|
|
||||||
<path d="M911 144 H990" class="line"/>
|
|
||||||
<rect x="990" y="92" width="168" height="104" rx="16" class="box"/>
|
|
||||||
<text x="1074" y="126" text-anchor="middle" class="label">同步刷新信息</text>
|
|
||||||
<text x="1074" y="158" text-anchor="middle" class="small">名称、大小、结果状态</text>
|
|
||||||
<rect x="42" y="258" width="344" height="112" rx="16" class="action"/>
|
|
||||||
<text x="214" y="292" text-anchor="middle" class="label">删除当前照片</text>
|
|
||||||
<text x="214" y="323" text-anchor="middle" class="small">删除后定位到相邻照片</text>
|
|
||||||
<text x="214" y="349" text-anchor="middle" class="small">最后一张删除后返回相册管理</text>
|
|
||||||
<rect x="428" y="258" width="344" height="112" rx="16" class="action"/>
|
|
||||||
<text x="600" y="292" text-anchor="middle" class="label">刷新当前照片</text>
|
|
||||||
<text x="600" y="323" text-anchor="middle" class="small">重新加载原图或最新修图结果</text>
|
|
||||||
<text x="600" y="349" text-anchor="middle" class="small">加载失败保留当前可见内容</text>
|
|
||||||
<rect x="814" y="258" width="344" height="112" rx="16" class="action"/>
|
|
||||||
<text x="986" y="292" text-anchor="middle" class="label">预览底部操作</text>
|
|
||||||
<text x="986" y="323" text-anchor="middle" class="small">保留 AI修图、删除、刷新</text>
|
|
||||||
<text x="986" y="349" text-anchor="middle" class="small">不展示打印按钮</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.4 KiB |
@@ -1,44 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="430" viewBox="0 0 1200 430">
|
|
||||||
<defs>
|
|
||||||
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
|
|
||||||
<path d="M0,0 L0,6 L9,3 z" fill="#4D7CFE"/>
|
|
||||||
</marker>
|
|
||||||
<style>
|
|
||||||
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
|
|
||||||
.label { font: 600 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
|
|
||||||
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
|
|
||||||
.box { fill: #FFFFFF; stroke: #B9C8DA; stroke-width: 2; }
|
|
||||||
.active { fill: #EEF4FF; stroke: #4D7CFE; stroke-width: 2.5; }
|
|
||||||
.danger { fill: #FFF1F1; stroke: #E35D6A; stroke-width: 2; }
|
|
||||||
.line { fill: none; stroke: #4D7CFE; stroke-width: 2.5; marker-end: url(#arrow); }
|
|
||||||
</style>
|
|
||||||
</defs>
|
|
||||||
<rect width="1200" height="430" rx="24" fill="#F7F9FC"/>
|
|
||||||
<text x="42" y="52" class="title">相册管理多选操作栏</text>
|
|
||||||
<rect x="42" y="92" width="215" height="110" rx="16" class="box"/>
|
|
||||||
<text x="149" y="127" text-anchor="middle" class="label">普通浏览状态</text>
|
|
||||||
<text x="149" y="160" text-anchor="middle" class="small">底部显示“上传照片”</text>
|
|
||||||
<path d="M257 147 H337" class="line"/>
|
|
||||||
<rect x="337" y="92" width="215" height="110" rx="16" class="active"/>
|
|
||||||
<text x="444" y="127" text-anchor="middle" class="label">点击右上角选择</text>
|
|
||||||
<text x="444" y="160" text-anchor="middle" class="small">缩略图出现单选圆圈</text>
|
|
||||||
<path d="M552 147 H632" class="line"/>
|
|
||||||
<rect x="632" y="92" width="225" height="110" rx="16" class="active"/>
|
|
||||||
<text x="744" y="127" text-anchor="middle" class="label">进入多选状态</text>
|
|
||||||
<text x="744" y="160" text-anchor="middle" class="small">上传按钮隐藏</text>
|
|
||||||
<text x="744" y="184" text-anchor="middle" class="small">显示 AI修图、删除</text>
|
|
||||||
<path d="M857 147 H937" class="line"/>
|
|
||||||
<rect x="937" y="92" width="221" height="110" rx="16" class="box"/>
|
|
||||||
<text x="1048" y="127" text-anchor="middle" class="label">选择至少 1 张</text>
|
|
||||||
<text x="1048" y="160" text-anchor="middle" class="small">两个操作按钮恢复可用</text>
|
|
||||||
<rect x="140" y="268" width="310" height="110" rx="16" class="active"/>
|
|
||||||
<text x="295" y="304" text-anchor="middle" class="label">点击 AI修图</text>
|
|
||||||
<text x="295" y="336" text-anchor="middle" class="small">进入批量 AI 修图弹窗</text>
|
|
||||||
<text x="295" y="360" text-anchor="middle" class="small">包含处理中照片时按钮置灰</text>
|
|
||||||
<rect x="750" y="268" width="310" height="110" rx="16" class="danger"/>
|
|
||||||
<text x="905" y="304" text-anchor="middle" class="label">点击删除</text>
|
|
||||||
<text x="905" y="336" text-anchor="middle" class="small">二次确认后删除选中素材</text>
|
|
||||||
<text x="905" y="360" text-anchor="middle" class="small">取消确认则保持原选择</text>
|
|
||||||
<path d="M744 202 V235 H295 V268" class="line"/>
|
|
||||||
<path d="M744 202 V235 H905 V268" class="line"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.0 KiB |
@@ -1,31 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="300" viewBox="0 0 1080 300">
|
|
||||||
<defs>
|
|
||||||
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
|
|
||||||
<path d="M0,0 L0,6 L9,3 z" fill="#7A8699"/>
|
|
||||||
</marker>
|
|
||||||
<style>
|
|
||||||
.title { font: 600 18px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #1F2937; }
|
|
||||||
.detail { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #667085; }
|
|
||||||
.box { fill: #FFFFFF; stroke: #D9E2EF; stroke-width: 2; rx: 16; }
|
|
||||||
.active { fill: #EEF5FF; stroke: #1677FF; stroke-width: 2; rx: 16; }
|
|
||||||
</style>
|
|
||||||
</defs>
|
|
||||||
<rect width="1080" height="300" fill="#F7F9FC"/>
|
|
||||||
<rect class="box" x="40" y="104" width="180" height="92"/>
|
|
||||||
<text class="title" x="130" y="142" text-anchor="middle">相册管理</text>
|
|
||||||
<text class="detail" x="130" y="170" text-anchor="middle">点击上传照片</text>
|
|
||||||
<line x1="220" y1="150" x2="292" y2="150" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
|
|
||||||
<rect class="active" x="305" y="70" width="230" height="160"/>
|
|
||||||
<text class="title" x="420" y="108" text-anchor="middle">选择传输模式</text>
|
|
||||||
<text class="detail" x="420" y="142" text-anchor="middle">边拍边传</text>
|
|
||||||
<text class="detail" x="420" y="174" text-anchor="middle">拍后传输</text>
|
|
||||||
<text class="detail" x="420" y="206" text-anchor="middle">取消:留在当前页</text>
|
|
||||||
<line x1="535" y1="118" x2="622" y2="78" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
|
|
||||||
<line x1="535" y1="182" x2="622" y2="222" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
|
|
||||||
<rect class="box" x="635" y="28" width="400" height="100"/>
|
|
||||||
<text class="title" x="835" y="68" text-anchor="middle">有线传输 · 边拍边传</text>
|
|
||||||
<text class="detail" x="835" y="96" text-anchor="middle">新照片传入后自动上传当前相册</text>
|
|
||||||
<rect class="box" x="635" y="172" width="400" height="100"/>
|
|
||||||
<text class="title" x="835" y="212" text-anchor="middle">有线传输 · 拍后传输</text>
|
|
||||||
<text class="detail" x="835" y="240" text-anchor="middle">拍摄完成后选择照片批量上传</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,56 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="470" viewBox="0 0 1200 470">
|
|
||||||
<defs>
|
|
||||||
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
|
|
||||||
<path d="M0,0 L0,6 L9,3 z" fill="#4D7CFE"/>
|
|
||||||
</marker>
|
|
||||||
<style>
|
|
||||||
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
|
|
||||||
.label { font: 600 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
|
|
||||||
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
|
|
||||||
.box { fill: #FFFFFF; stroke: #B9C8DA; stroke-width: 2; }
|
|
||||||
.active { fill: #EEF4FF; stroke: #4D7CFE; stroke-width: 2.5; }
|
|
||||||
.optional { fill: #FFF8E8; stroke: #F2A93B; stroke-width: 2; }
|
|
||||||
.line { fill: none; stroke: #4D7CFE; stroke-width: 2.5; marker-end: url(#arrow); }
|
|
||||||
</style>
|
|
||||||
</defs>
|
|
||||||
<rect width="1200" height="470" rx="24" fill="#F7F9FC"/>
|
|
||||||
<text x="42" y="52" class="title">AI 修图弹窗:原图精修、氛围感与结果组合</text>
|
|
||||||
<rect x="42" y="92" width="190" height="116" rx="16" class="box"/>
|
|
||||||
<text x="137" y="127" text-anchor="middle" class="label">打开 AI修图</text>
|
|
||||||
<text x="137" y="158" text-anchor="middle" class="small">显示已选照片数量</text>
|
|
||||||
<text x="137" y="183" text-anchor="middle" class="small">内容区域可纵向滚动</text>
|
|
||||||
<path d="M232 150 H272" class="line"/>
|
|
||||||
<rect x="272" y="78" width="230" height="144" rx="18" class="active"/>
|
|
||||||
<text x="387" y="116" text-anchor="middle" class="label">原图精修</text>
|
|
||||||
<text x="387" y="148" text-anchor="middle" class="small">预设文字 + 对应效果图</text>
|
|
||||||
<text x="387" y="174" text-anchor="middle" class="small">横向滚动,始终单选</text>
|
|
||||||
<text x="387" y="200" text-anchor="middle" class="small">生成基础“精修后”结果</text>
|
|
||||||
<path d="M502 150 H542" class="line"/>
|
|
||||||
<rect x="542" y="78" width="230" height="144" rx="18" class="optional"/>
|
|
||||||
<text x="657" y="116" text-anchor="middle" class="label">氛围感修图(选填)</text>
|
|
||||||
<text x="657" y="148" text-anchor="middle" class="small">横向滚动,多样式单选</text>
|
|
||||||
<text x="657" y="174" text-anchor="middle" class="small">默认不选,重复点击可取消</text>
|
|
||||||
<text x="657" y="200" text-anchor="middle" class="small">额外生成独立氛围感结果</text>
|
|
||||||
<path d="M772 150 H812" class="line"/>
|
|
||||||
<rect x="812" y="92" width="170" height="116" rx="16" class="box"/>
|
|
||||||
<text x="897" y="127" text-anchor="middle" class="label">只读额度</text>
|
|
||||||
<text x="897" y="158" text-anchor="middle" class="small">显示剩余 9 张</text>
|
|
||||||
<text x="897" y="183" text-anchor="middle" class="small">不提供勾选操作</text>
|
|
||||||
<path d="M982 150 H1014" class="line"/>
|
|
||||||
<rect x="1014" y="92" width="144" height="116" rx="16" class="active"/>
|
|
||||||
<text x="1086" y="127" text-anchor="middle" class="label">点击确定</text>
|
|
||||||
<text x="1086" y="158" text-anchor="middle" class="small">提交样式组合</text>
|
|
||||||
<text x="1086" y="183" text-anchor="middle" class="small">开始处理</text>
|
|
||||||
<rect x="42" y="286" width="344" height="120" rx="16" class="box"/>
|
|
||||||
<text x="214" y="322" text-anchor="middle" class="label">点击取消 / 下拉关闭</text>
|
|
||||||
<text x="214" y="354" text-anchor="middle" class="small">不启动任务</text>
|
|
||||||
<text x="214" y="380" text-anchor="middle" class="small">不改变原图、旧结果或额度状态</text>
|
|
||||||
<rect x="428" y="286" width="344" height="120" rx="16" class="box"/>
|
|
||||||
<text x="600" y="322" text-anchor="middle" class="label">效果图预览</text>
|
|
||||||
<text x="600" y="354" text-anchor="middle" class="small">各样式卡片显示“预览”按钮</text>
|
|
||||||
<text x="600" y="380" text-anchor="middle" class="small">全屏查看,不改变当前选中项</text>
|
|
||||||
<rect x="814" y="286" width="344" height="120" rx="16" class="box"/>
|
|
||||||
<text x="986" y="322" text-anchor="middle" class="label">结果独立保存</text>
|
|
||||||
<text x="986" y="354" text-anchor="middle" class="small">原图 / 精修后 / 氛围感分别查看</text>
|
|
||||||
<text x="986" y="380" text-anchor="middle" class="small">再次确认后以新组合覆盖旧结果</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.1 KiB |
@@ -1,31 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="300" viewBox="0 0 1080 300">
|
|
||||||
<defs>
|
|
||||||
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
|
|
||||||
<path d="M0,0 L0,6 L9,3 z" fill="#7A8699"/>
|
|
||||||
</marker>
|
|
||||||
<style>
|
|
||||||
.title { font: 600 18px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #1F2937; }
|
|
||||||
.detail { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #667085; }
|
|
||||||
.box { fill: #FFFFFF; stroke: #D9E2EF; stroke-width: 2; rx: 16; }
|
|
||||||
.active { fill: #EEF5FF; stroke: #1677FF; stroke-width: 2; rx: 16; }
|
|
||||||
</style>
|
|
||||||
</defs>
|
|
||||||
<rect width="1080" height="300" fill="#F7F9FC"/>
|
|
||||||
<rect class="box" x="40" y="104" width="210" height="92"/>
|
|
||||||
<text class="title" x="145" y="142" text-anchor="middle">有线传输照片列表</text>
|
|
||||||
<text class="detail" x="145" y="170" text-anchor="middle">点击任意缩略图</text>
|
|
||||||
<line x1="250" y1="150" x2="320" y2="150" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
|
|
||||||
<rect class="active" x="333" y="70" width="260" height="160"/>
|
|
||||||
<text class="title" x="463" y="108" text-anchor="middle">多图全屏预览</text>
|
|
||||||
<text class="detail" x="463" y="142" text-anchor="middle">从被点击照片开始</text>
|
|
||||||
<text class="detail" x="463" y="174" text-anchor="middle">页码显示 当前张 / 总张数</text>
|
|
||||||
<text class="detail" x="463" y="206" text-anchor="middle">不可预览照片自动跳过</text>
|
|
||||||
<line x1="593" y1="112" x2="668" y2="74" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
|
|
||||||
<line x1="593" y1="188" x2="668" y2="226" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
|
|
||||||
<rect class="box" x="681" y="28" width="354" height="100"/>
|
|
||||||
<text class="title" x="858" y="68" text-anchor="middle">向右滑动</text>
|
|
||||||
<text class="detail" x="858" y="96" text-anchor="middle">查看上一张,第一张处停止</text>
|
|
||||||
<rect class="box" x="681" y="172" width="354" height="100"/>
|
|
||||||
<text class="title" x="858" y="212" text-anchor="middle">向左滑动</text>
|
|
||||||
<text class="detail" x="858" y="240" text-anchor="middle">查看下一张,最后一张处停止</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,49 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="500" viewBox="0 0 1200 500">
|
|
||||||
<defs>
|
|
||||||
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
|
|
||||||
<path d="M0,0 L0,6 L9,3 z" fill="#4D7CFE"/>
|
|
||||||
</marker>
|
|
||||||
<style>
|
|
||||||
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
|
|
||||||
.label { font: 600 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
|
|
||||||
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
|
|
||||||
.box { fill: #FFFFFF; stroke: #B9C8DA; stroke-width: 2; }
|
|
||||||
.processing { fill: #EEF4FF; stroke: #4D7CFE; stroke-width: 2.5; }
|
|
||||||
.success { fill: #EEFAF3; stroke: #37A86B; stroke-width: 2.5; }
|
|
||||||
.failed { fill: #FFF1F1; stroke: #E35D6A; stroke-width: 2.5; }
|
|
||||||
.line { fill: none; stroke: #4D7CFE; stroke-width: 2.5; marker-end: url(#arrow); }
|
|
||||||
.retry { fill: none; stroke: #E35D6A; stroke-width: 2.5; marker-end: url(#arrow); }
|
|
||||||
</style>
|
|
||||||
</defs>
|
|
||||||
<rect width="1200" height="500" rx="24" fill="#F7F9FC"/>
|
|
||||||
<text x="42" y="52" class="title">AI 修图任务生命周期与覆盖规则</text>
|
|
||||||
<rect x="42" y="104" width="205" height="116" rx="16" class="box"/>
|
|
||||||
<text x="144" y="140" text-anchor="middle" class="label">可发起</text>
|
|
||||||
<text x="144" y="172" text-anchor="middle" class="small">未修图或已有旧结果</text>
|
|
||||||
<text x="144" y="198" text-anchor="middle" class="small">AI修图按钮可点击</text>
|
|
||||||
<path d="M247 162 H337" class="line"/>
|
|
||||||
<rect x="337" y="104" width="230" height="116" rx="16" class="processing"/>
|
|
||||||
<text x="452" y="140" text-anchor="middle" class="label">修图中</text>
|
|
||||||
<text x="452" y="172" text-anchor="middle" class="small">按钮置灰,禁止重复提交</text>
|
|
||||||
<text x="452" y="198" text-anchor="middle" class="small">离开页面后任务继续完成</text>
|
|
||||||
<path d="M567 142 H692" class="line"/>
|
|
||||||
<path d="M567 182 H692" class="retry"/>
|
|
||||||
<rect x="692" y="76" width="230" height="116" rx="16" class="success"/>
|
|
||||||
<text x="807" y="112" text-anchor="middle" class="label">成功</text>
|
|
||||||
<text x="807" y="144" text-anchor="middle" class="small">分别保存精修与氛围感结果</text>
|
|
||||||
<text x="807" y="170" text-anchor="middle" class="small">默认展示“精修后”</text>
|
|
||||||
<rect x="692" y="228" width="230" height="116" rx="16" class="failed"/>
|
|
||||||
<text x="807" y="264" text-anchor="middle" class="label">失败</text>
|
|
||||||
<text x="807" y="296" text-anchor="middle" class="small">显示“修图失败”</text>
|
|
||||||
<text x="807" y="322" text-anchor="middle" class="small">恢复 AI修图按钮,可重试</text>
|
|
||||||
<path d="M922 134 H1012" class="line"/>
|
|
||||||
<rect x="1012" y="76" width="146" height="116" rx="16" class="box"/>
|
|
||||||
<text x="1085" y="112" text-anchor="middle" class="label">再次修图</text>
|
|
||||||
<text x="1085" y="144" text-anchor="middle" class="small">新组合完成后</text>
|
|
||||||
<text x="1085" y="170" text-anchor="middle" class="small">整组覆盖旧结果</text>
|
|
||||||
<path d="M1085 192 V405 H144 V220" class="line"/>
|
|
||||||
<path d="M807 344 V405 H452 V220" class="retry"/>
|
|
||||||
<rect x="337" y="380" width="230" height="82" rx="16" class="box"/>
|
|
||||||
<text x="452" y="414" text-anchor="middle" class="label">还原为原图</text>
|
|
||||||
<text x="452" y="440" text-anchor="middle" class="small">清除精修、氛围感、封面及失败状态</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.4 KiB |
@@ -1,52 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="500" viewBox="0 0 1200 500">
|
|
||||||
<defs>
|
|
||||||
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
|
|
||||||
<path d="M0,0 L0,6 L9,3 z" fill="#4D7CFE"/>
|
|
||||||
</marker>
|
|
||||||
<style>
|
|
||||||
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
|
|
||||||
.label { font: 600 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
|
|
||||||
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
|
|
||||||
.box { fill: #FFFFFF; stroke: #B9C8DA; stroke-width: 2; }
|
|
||||||
.active { fill: #EEF4FF; stroke: #4D7CFE; stroke-width: 2.5; }
|
|
||||||
.free { fill: #FFF8E8; stroke: #F2A93B; stroke-width: 2.5; }
|
|
||||||
.cover { fill: #FFF4E8; stroke: #F59E0B; stroke-width: 2.5; }
|
|
||||||
.line { fill: none; stroke: #4D7CFE; stroke-width: 2.5; marker-end: url(#arrow); }
|
|
||||||
</style>
|
|
||||||
</defs>
|
|
||||||
<rect width="1200" height="500" rx="24" fill="#F7F9FC"/>
|
|
||||||
<text x="42" y="52" class="title">多图三类风格选择与封面生成</text>
|
|
||||||
<rect x="42" y="92" width="220" height="116" rx="16" class="box"/>
|
|
||||||
<text x="152" y="128" text-anchor="middle" class="label">统计选中照片数</text>
|
|
||||||
<text x="152" y="160" text-anchor="middle" class="small">进入批量 AI 修图前判断</text>
|
|
||||||
<text x="152" y="186" text-anchor="middle" class="small">阈值以当前需求为准</text>
|
|
||||||
<path d="M262 150 H342" class="line"/>
|
|
||||||
<rect x="342" y="76" width="230" height="148" rx="18" class="active"/>
|
|
||||||
<text x="457" y="112" text-anchor="middle" class="label">数量是否 ≥ 4 张?</text>
|
|
||||||
<text x="457" y="147" text-anchor="middle" class="small">否:显示精修 + 氛围感</text>
|
|
||||||
<text x="457" y="175" text-anchor="middle" class="small">是:再显示封面模板</text>
|
|
||||||
<text x="457" y="201" text-anchor="middle" class="small">弹窗自动使用更高高度</text>
|
|
||||||
<path d="M572 150 H652" class="line"/>
|
|
||||||
<rect x="652" y="76" width="250" height="148" rx="18" class="free"/>
|
|
||||||
<text x="777" y="112" text-anchor="middle" class="label">显示三个风格模块</text>
|
|
||||||
<text x="777" y="147" text-anchor="middle" class="small">原图精修:单选基础预设</text>
|
|
||||||
<text x="777" y="175" text-anchor="middle" class="small">氛围感:横向单选,可取消</text>
|
|
||||||
<text x="777" y="201" text-anchor="middle" class="small">封面:横向单选,可全屏预览</text>
|
|
||||||
<path d="M902 150 H982" class="line"/>
|
|
||||||
<rect x="982" y="92" width="176" height="116" rx="16" class="cover"/>
|
|
||||||
<text x="1070" y="128" text-anchor="middle" class="label">确认并生成</text>
|
|
||||||
<text x="1070" y="160" text-anchor="middle" class="small">每张保存独立结果</text>
|
|
||||||
<text x="1070" y="186" text-anchor="middle" class="small">第一张另作封面</text>
|
|
||||||
<rect x="42" y="300" width="344" height="126" rx="16" class="box"/>
|
|
||||||
<text x="214" y="336" text-anchor="middle" class="label">当前免费阶段</text>
|
|
||||||
<text x="214" y="368" text-anchor="middle" class="small">封面 quotaCost = 0,不减少额度</text>
|
|
||||||
<text x="214" y="394" text-anchor="middle" class="small">弹窗显示“赠送 · 不占精修张数”</text>
|
|
||||||
<rect x="428" y="300" width="344" height="126" rx="16" class="box"/>
|
|
||||||
<text x="600" y="336" text-anchor="middle" class="label">未来收费阶段</text>
|
|
||||||
<text x="600" y="368" text-anchor="middle" class="small">服务端将 quotaCost 配置为正数</text>
|
|
||||||
<text x="600" y="394" text-anchor="middle" class="small">确认前展示实际消耗,成功后再扣减</text>
|
|
||||||
<rect x="814" y="300" width="344" height="126" rx="16" class="box"/>
|
|
||||||
<text x="986" y="336" text-anchor="middle" class="label">重复选择封面模板</text>
|
|
||||||
<text x="986" y="368" text-anchor="middle" class="small">新封面效果覆盖旧封面效果</text>
|
|
||||||
<text x="986" y="394" text-anchor="middle" class="small">失败时不把结果标记为已生成</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB |
@@ -1,51 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="520" viewBox="0 0 1200 520">
|
|
||||||
<defs>
|
|
||||||
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
|
|
||||||
<path d="M0,0 L0,6 L9,3 z" fill="#7A8CA5"/>
|
|
||||||
</marker>
|
|
||||||
<style>
|
|
||||||
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
|
|
||||||
.label { font: 700 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
|
|
||||||
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
|
|
||||||
.card { fill: #FFFFFF; stroke: #D5DEE9; stroke-width: 2; }
|
|
||||||
.line { fill: none; stroke: #7A8CA5; stroke-width: 2.3; marker-end: url(#arrow); }
|
|
||||||
.badge { font: 700 15px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #FFFFFF; }
|
|
||||||
</style>
|
|
||||||
</defs>
|
|
||||||
<rect width="1200" height="520" rx="24" fill="#F7F9FC"/>
|
|
||||||
<text x="42" y="52" class="title">相册缩略图结果类型与状态优先级</text>
|
|
||||||
<rect x="42" y="98" width="190" height="148" rx="18" class="card"/>
|
|
||||||
<rect x="58" y="114" width="70" height="28" rx="8" fill="#34C759"/>
|
|
||||||
<text x="93" y="134" text-anchor="middle" class="badge">已上传</text>
|
|
||||||
<text x="137" y="178" text-anchor="middle" class="label">普通原图</text>
|
|
||||||
<text x="137" y="210" text-anchor="middle" class="small">显示服务端缩略图</text>
|
|
||||||
<path d="M232 172 H282" class="line"/>
|
|
||||||
<rect x="282" y="98" width="190" height="148" rx="18" class="card"/>
|
|
||||||
<rect x="298" y="114" width="70" height="28" rx="8" fill="#4D7CFE"/>
|
|
||||||
<text x="333" y="134" text-anchor="middle" class="badge">修图中</text>
|
|
||||||
<text x="377" y="178" text-anchor="middle" class="label">处理中</text>
|
|
||||||
<text x="377" y="210" text-anchor="middle" class="small">AI入口同步置灰</text>
|
|
||||||
<path d="M472 172 H522" class="line"/>
|
|
||||||
<rect x="522" y="98" width="190" height="148" rx="18" class="card"/>
|
|
||||||
<rect x="538" y="114" width="70" height="28" rx="8" fill="#6750A4"/>
|
|
||||||
<text x="573" y="134" text-anchor="middle" class="badge">AI已修</text>
|
|
||||||
<text x="617" y="178" text-anchor="middle" class="label">普通修图结果</text>
|
|
||||||
<text x="617" y="210" text-anchor="middle" class="small">缩略图展示最新结果</text>
|
|
||||||
<path d="M712 172 H762" class="line"/>
|
|
||||||
<rect x="762" y="98" width="190" height="148" rx="18" class="card"/>
|
|
||||||
<rect x="778" y="114" width="78" height="28" rx="8" fill="#F59E0B"/>
|
|
||||||
<text x="817" y="134" text-anchor="middle" class="badge">AI封面</text>
|
|
||||||
<text x="857" y="178" text-anchor="middle" class="label">封面结果</text>
|
|
||||||
<text x="857" y="210" text-anchor="middle" class="small">显示封面模板排版图</text>
|
|
||||||
<path d="M952 172 H1002" class="line"/>
|
|
||||||
<rect x="1002" y="98" width="156" height="148" rx="18" class="card"/>
|
|
||||||
<rect x="1018" y="114" width="88" height="28" rx="8" fill="#E53935"/>
|
|
||||||
<text x="1062" y="134" text-anchor="middle" class="badge">修图失败</text>
|
|
||||||
<text x="1080" y="178" text-anchor="middle" class="label">失败结果</text>
|
|
||||||
<text x="1080" y="210" text-anchor="middle" class="small">可再次修图</text>
|
|
||||||
<rect x="42" y="310" width="1116" height="150" rx="18" fill="#FFFFFF" stroke="#D5DEE9" stroke-width="2"/>
|
|
||||||
<text x="70" y="346" class="label">状态显示优先级</text>
|
|
||||||
<text x="70" y="382" class="small">修图中 > 修图失败 > AI封面 > AI已修 > 已上传。高优先级状态先展示,避免处理中或失败被旧成功结果遮盖。</text>
|
|
||||||
<text x="70" y="416" class="small">成功:缩略图切换为最新修图图或封面图;再次成功:覆盖旧结果;还原原图:回到“已上传”;删除素材:同步清除本地结果。</text>
|
|
||||||
<text x="70" y="446" class="small">当前 AI 修图、封面结果及角标为本地演示数据,App 进程重启后会清空;服务端接入后应返回结果类型、状态、结果图地址和模板 ID。</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 7.8 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,85 @@
|
|||||||
|
# AI 修图完整链路设计稿 V1
|
||||||
|
|
||||||
|
## 交付内容
|
||||||
|
|
||||||
|
| 文件 | 页面 / 状态 |
|
||||||
|
|---|---|
|
||||||
|
| `00-flow-overview.png` | 六张关键屏稿总览 |
|
||||||
|
| `01-album-multi-selection.png` | 相册管理 · 已选择 4 张照片 |
|
||||||
|
| `02-template-selection.png` | AI 修图模板选择 Sheet |
|
||||||
|
| `03-template-preview.png` | 模板全屏预览 |
|
||||||
|
| `04-result-success.png` | 精修结果 · 成功 |
|
||||||
|
| `05-result-processing.png` | 精修结果 · 生成新版本中 |
|
||||||
|
| `06-result-failed.png` | 精修结果 · 新版本生成失败 |
|
||||||
|
|
||||||
|
独立屏稿均为 853 × 1844 像素(约 9:19.5),面向 iOS 16+、iPhone 竖屏、浅色模式。
|
||||||
|
|
||||||
|
## 设计系统
|
||||||
|
|
||||||
|
- 主色:`#1677FF`;辅助渐变:`#5CA8FF`、`#A7D8FF`。
|
||||||
|
- 页面底色:`#F7FAFF`;主文字:`#172033`;次文字:`#667085`。
|
||||||
|
- 失败色:`#EF4444`;赠送/免费标签使用橙色,避免与主要操作竞争。
|
||||||
|
- 字体:SF Pro / 苹方风格;圆角 12–16 pt;以 8 pt 为基础间距。
|
||||||
|
- 正文置于高不透明度表面;极光光晕只用于背景与重点操作,不承载关键信息。
|
||||||
|
- 结果页只通过“原图 / 精修后 / 氛围感”标签切换,不使用对比滑杆。
|
||||||
|
- 重新修图期间及失败后保留上一版成功图片;状态反馈只占局部,不遮挡照片。
|
||||||
|
|
||||||
|
## 可复用 ImageGen 提示词
|
||||||
|
|
||||||
|
以下提示词使用内置 ImageGen。首屏以 `../travel-album-management-redesign-v2.png` 为风格参考;后续页面以上一张已确认屏稿作为参考,确保视觉连续。
|
||||||
|
|
||||||
|
### 通用前缀
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: ui-mockup
|
||||||
|
Asset type: shippable high-fidelity iOS app screen, full portrait canvas, no device frame
|
||||||
|
Style/medium: realistic production UIKit UI, light Aurora blue, crisp SF Pro / PingFang-like typography, SF Symbols-style icons, 12–16 pt corners, 8 pt spacing grid, subtle borders and restrained shadows.
|
||||||
|
Color palette: primary #1677FF, gradient accents #5CA8FF and #A7D8FF, background #F7FAFF, text #172033 and #667085, danger #EF4444.
|
||||||
|
Constraints: iPhone portrait 9:19.5; iOS status bar and home indicator; practical UIKit-buildable hierarchy; accessible contrast; minimum 44 pt touch targets; no dark mode; no watermark; no decorative device frame; no annotations.
|
||||||
|
Text constraints: render specified Chinese text exactly once; no gibberish, duplicate labels or unrelated copy.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 01 · 相册多选
|
||||||
|
|
||||||
|
```text
|
||||||
|
Create the album management screen in multi-selection mode. Top navigation: “相册管理” and “完成”. Album summary: “旅拍相册”, “138****8000”. Filter row: “全部 128”, “已购 36”, “时间 ↓”. Use a precise three-column grid of nine travel photos. Four photos have blue checkmarks, blue outlines and subtle selected overlays; other photos show empty selectors. Fixed bottom actions: red outline “删除 4 张” and blue gradient “AI修图 4 张”.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 02 · 模板选择
|
||||||
|
|
||||||
|
```text
|
||||||
|
Create a large native page sheet titled “AI修图” with subtitle “已选择 4 张照片”. Show the note “每张照片生成精修结果,氛围感可选;本次可免费生成 1 张封面”. Include three horizontal sections: “原图精修” + “必选”, “氛围感修图” + “选填”, “封面风格” + “赠送 · 不占额度”. Each section has three photographic cards, a clear single selected state and “预览” buttons. Cost card: “AI精修 · 按张收费”, “预计消耗 8 张”, “剩余 12 张”. Fixed actions: “取消” and “确定”.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 03 · 模板预览
|
||||||
|
|
||||||
|
```text
|
||||||
|
Create an immersive full-screen preview for the selected “自然通透” template. Use a dark navy photographic canvas, a circular back button, title “自然通透” and subtitle “双指缩放查看细节”. The large photo shows a woman in a straw hat overlooking Santorini. Add “模板效果预览”. Bottom information panel: “自然通透”, “提亮肤色与画面通透感,保留自然质感”, “当前已选择”. Primary action: “使用此模板”. Do not add a comparison slider.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 04 · 正常结果
|
||||||
|
|
||||||
|
```text
|
||||||
|
Create “照片预览” with counter “1 / 9”. Add exactly three tabs: “原图”, “精修后”, “氛围感”; select “精修后”. Show the same Santorini photo with badge “AI已修”, filename “IMG_8291.JPG” and “自然通透 · 2026/08/13”. Information card: “精修结果”, “自然通透模板 · 已生成最新版本”. Fixed actions: “AI修图”, “删除”, “刷新”. Use tabs only; no slider or press-and-hold comparison.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 05 · 处理中状态编辑
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: precise-object-edit. Keep the successful result screen, photo, tabs and layout unchanged. Replace “AI已修” with spinner + “修图中”. Replace the result card with “正在生成新版本” and “当前仍展示上一版结果,完成后自动更新”, plus an indeterminate shimmer line with no percentage. Keep the previous photo unobscured. Disable only the “AI修图” button; leave “删除” and “刷新” available.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 06 · 失败状态编辑
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: precise-object-edit. Keep the successful result screen, photo, tabs and layout unchanged. Replace “AI已修” with a local red badge “生成失败”. Replace the result card with pale-red recovery content: “新版本生成失败”, “上一版结果已保留,可重新修图”, and an outline action “重新修图”. Keep the previous photo unobscured and restore the bottom “AI修图” button to its primary blue state. Do not show a modal, stack trace or error code.
|
||||||
|
```
|
||||||
|
|
||||||
|
## QA 记录
|
||||||
|
|
||||||
|
- 六张独立屏稿尺寸一致,使用同一旅拍照片体系与组件语言。
|
||||||
|
- 精修必选、氛围感选填、四张照片触发封面、封面不占额度均已表达。
|
||||||
|
- 成功、处理中、失败三种状态均保留同一照片与结果标签,便于直接比较。
|
||||||
|
- 处理中不展示虚构百分比;重复提交按钮禁用,刷新仍可用。
|
||||||
|
- 失败状态提供“重新修图”恢复路径,且上一版结果保持可见。
|
||||||
|
- 本目录只包含设计稿与说明,未修改 UIKit、业务逻辑或 Asset Catalog。
|
||||||
|
After Width: | Height: | Size: 8.0 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,76 @@
|
|||||||
|
# AI 修图完整链路设计稿 V2
|
||||||
|
|
||||||
|
## 本轮调整
|
||||||
|
|
||||||
|
- 第 1、2 张沿用 V1 的浅色“轻盈极光蓝”风格。
|
||||||
|
- 第 3–6 张改为统一的黑色专业影像工作区,突出照片本身。
|
||||||
|
- 暗色页面不做简单反色:背景、浮层、边框、文字和按钮均使用独立暗色语义层级。
|
||||||
|
- V1 完整保留在 `../ai-retouch-v1/`,便于对比和回退。
|
||||||
|
|
||||||
|
## 交付内容
|
||||||
|
|
||||||
|
| 文件 | 页面 / 状态 | 风格 |
|
||||||
|
|---|---|---|
|
||||||
|
| `00-flow-overview-v2.png` | 六张关键屏稿总览 | 混合流程 |
|
||||||
|
| `01-album-multi-selection.png` | 相册管理 · 已选择 4 张 | 浅色 |
|
||||||
|
| `02-template-selection.png` | AI 修图模板选择 Sheet | 浅色 |
|
||||||
|
| `03-template-preview-dark.png` | 模板全屏预览 | 暗色 |
|
||||||
|
| `04-result-success-dark.png` | 精修结果 · 成功 | 暗色 |
|
||||||
|
| `05-result-processing-dark.png` | 精修结果 · 生成中 | 暗色 |
|
||||||
|
| `06-result-failed-dark.png` | 精修结果 · 生成失败 | 暗色 |
|
||||||
|
|
||||||
|
## 暗色设计 Token
|
||||||
|
|
||||||
|
- 页面背景:`#05070D`,允许向深海军蓝 `#07111F` 做非常轻微的层次过渡。
|
||||||
|
- 抬升表面:`#111827`;边框:`#263244`。
|
||||||
|
- 主文字:`#F8FAFC`;次文字:`#9CA3AF`;未选中标签:`#C7CED9`。
|
||||||
|
- 品牌主色:`#1677FF`;蓝色高光:`#5CA8FF`。
|
||||||
|
- 失败色:`#EF4444`,仅用于失败徽标、恢复入口和局部边框。
|
||||||
|
- 图片保持明亮自然,不叠加全屏蒙层;信息区和操作区不出现白色表面。
|
||||||
|
|
||||||
|
## 可复用 ImageGen 暗色编辑提示词
|
||||||
|
|
||||||
|
以下提示词使用内置 ImageGen,以对应 V1 页面作为 edit target;第 3 张暗色稿确定后,也作为第 4–6 张的风格参考。
|
||||||
|
|
||||||
|
### 通用暗色编辑约束
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: precise-object-edit
|
||||||
|
Restyle the target into a cohesive dark professional photography workspace.
|
||||||
|
Preserve the exact iPhone portrait dimensions, photo content and crop, person identity and pose, all Chinese copy, hierarchy, controls and safe-area spacing.
|
||||||
|
Use background #05070D, elevated surface #111827, border #263244, primary #1677FF, text #F8FAFC and secondary #9CA3AF.
|
||||||
|
Keep the bright photo unchanged as the focal point. Use white text and SF Symbols-style white icons. Use brand blue only for selected state and primary actions.
|
||||||
|
No white page areas, no comparison slider, no device frame, no annotations, no watermark and no additional copy.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模板预览
|
||||||
|
|
||||||
|
```text
|
||||||
|
Keep “自然通透”, “双指缩放查看细节”, the Santorini photo and “模板效果预览”. Change the lower information panel to charcoal with white heading, muted description and blue “当前已选择”. Change the complete bottom safe area to black and retain the blue “使用此模板” action.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修图结果
|
||||||
|
|
||||||
|
```text
|
||||||
|
Keep “照片预览”, “1 / 9” and the three tabs “原图 / 精修后 / 氛围感”. Use a charcoal segmented control with “精修后” selected in blue. Keep “AI已修”, the filename metadata, the photo and the result information. Change the result card and secondary actions “删除 / 刷新” to dark surfaces; keep “AI修图” blue.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 处理中状态
|
||||||
|
|
||||||
|
```text
|
||||||
|
Based on the approved dark result screen, change only the badge to spinner + “修图中”. Keep the previous photo visible. Change the card to “正在生成新版本” and “当前仍展示上一版结果,完成后自动更新”, with an indeterminate shimmer line and no percentage. Disable only “AI修图”.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 失败状态
|
||||||
|
|
||||||
|
```text
|
||||||
|
Based on the approved dark result screen, change only the badge to “生成失败”. Keep the previous photo visible. Use a charcoal recovery card with restrained red border, “新版本生成失败”, “上一版结果已保留,可重新修图” and “重新修图”. Keep the main “AI修图” button blue. Do not use a white or pale-red card.
|
||||||
|
```
|
||||||
|
|
||||||
|
## QA 记录
|
||||||
|
|
||||||
|
- 第 1、2 张与 V1 像素级复用,没有重新生成。
|
||||||
|
- 第 3–6 张均为 853 × 1844,暗色背景、表面、文字和操作层级一致。
|
||||||
|
- 成功、处理中、失败三态保留同一结果标签、照片和底部操作布局。
|
||||||
|
- 处理中无虚构百分比;失败状态有明确恢复动作;旧结果始终可见。
|
||||||
|
- 本目录只新增设计稿与说明,未修改 UIKit、业务逻辑或 Asset Catalog。
|
||||||
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 926 KiB |
|
After Width: | Height: | Size: 893 KiB |
|
After Width: | Height: | Size: 893 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,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 重修模板多倍图资源,优化弱网和大图加载体验。
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
# 相册管理与 AI 修图功能说明
|
|
||||||
|
|
||||||
> 文档日期:2026-07-29
|
|
||||||
> 适用范围:新增相册 → 相册任务 → 相册管理 → 照片上传与传输 → 照片预览与 AI 修图
|
|
||||||
> 数据说明:相册和照片列表、有线相机连接及照片传输继续使用项目已有真实数据能力;传输模式选择保存在本地。原图精修结果、氛围感结果、封面模板、剩余 9 张额度及状态角标目前为本地可演示数据,尚未接入服务端持久化,App 进程重启后会清空。
|
|
||||||
|
|
||||||
## 1. 照片预览、左右滑动与底部操作
|
|
||||||
|
|
||||||
### 功能说明
|
|
||||||
|
|
||||||
用户在“相册管理”照片网格中点击任意缩略图,进入单张照片预览页。页面只保留一个返回按钮,导航区域展示当前照片文件名和文件大小;照片区域支持左右滑动查看同一相册内的上一张或下一张照片。
|
|
||||||
|
|
||||||
预览页统一使用黑色沉浸式自定义头部,不再混用系统导航栏:左侧为圆形返回按钮,中间展示文件名和文件大小,右侧展示“当前张数/总张数”。切换照片时三处信息同步更新。照片预览与原图精修、氛围感、封面模板预览都直接使用同一个全屏模态容器,不进入项目导航栈,因此不会叠加系统白色导航栏或出现两个返回按钮;关闭后回到进入前的相册管理页或修图弹窗。
|
|
||||||
|
|
||||||
底部固定展示以下三个操作,不展示打印按钮:
|
|
||||||
|
|
||||||
| 操作 | 点击结果 | 操作期间或异常处理 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| AI修图 | 打开当前照片的 AI 修图弹窗 | 当前照片处于“修图中”时按钮置灰,避免重复提交 |
|
|
||||||
| 删除 | 二次确认后删除当前照片 | 删除成功后自动定位到相邻照片;删除最后一张后返回相册管理 |
|
|
||||||
| 刷新 | 重新加载当前照片及最新修图结果 | 刷新失败时不清空当前已显示内容,可再次点击 |
|
|
||||||
|
|
||||||
左右滑动时同步刷新文件名、文件大小、原图/修图后状态和底部按钮状态。第一张继续向右滑、最后一张继续向左滑时保持当前照片,不循环跳转。
|
|
||||||
|
|
||||||
页面底部图标使用项目内 `TravelAlbumPreviewIcons` SVG 资源,确保 AI 修图、删除、刷新三个入口视觉一致。
|
|
||||||
|
|
||||||
### 状态流转图
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### 本功能验收
|
|
||||||
|
|
||||||
- [ ] 从任意缩略图进入时只显示一个返回入口。
|
|
||||||
- [ ] 文件名和文件大小与当前照片一致。
|
|
||||||
- [ ] 导航头部保持黑色沉浸样式,右侧张数随照片切换同步变化。
|
|
||||||
- [ ] 左右滑动后照片和文件信息同步变化。
|
|
||||||
- [ ] 首尾照片不会越界或循环。
|
|
||||||
- [ ] 底部仅显示 AI修图、删除、刷新,不显示打印。
|
|
||||||
- [ ] 删除当前照片后能正确定位或退出预览。
|
|
||||||
|
|
||||||
## 2. 上传照片与传输模式选择
|
|
||||||
|
|
||||||
### 功能说明
|
|
||||||
|
|
||||||
相册管理处于普通状态时,用户点击底部“上传照片”,先从底部弹出“选择传输模式”,展示“边拍边传”和“拍后传输”两个选项。点击任一选项后弹窗关闭,并进入当前相册的有线传输页面;点击“取消”或向下关闭弹窗时停留在相册管理,不改变现有照片和选择状态。
|
|
||||||
|
|
||||||
| 传输模式 | 弹窗说明 | 下一页初始状态 | 后续行为 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 边拍边传 | 相机拍摄后,照片自动传输并上传到当前相册 | 传输模式显示“边拍边传” | 新传入 App 的照片自动上传 |
|
|
||||||
| 拍后传输 | 拍摄完成后,再选择相机照片批量传输 | 传输模式显示“拍后传输” | 新照片先保留,用户后续选择并上传 |
|
|
||||||
|
|
||||||
本次弹窗选择优先于本机上一次保存的传输模式,并同步成为新的最近使用模式,确保下一页展示和实际传输行为一致。相册 ID、相册名称、联系电话及照片数据继续沿用相册管理页面的真实数据,不创建新的相册或假照片。
|
|
||||||
|
|
||||||
进入有线传输页面后,非批量选择状态下点击任意照片缩略图,预览页会使用当前 Tab 筛选后的照片顺序,并从被点击照片开始展示。用户可左右滑动查看上一张或下一张,顶部页码随照片同步变化;没有可用预览地址的照片不进入预览序列,也不占用页码。第一张继续向右滑、最后一张继续向左滑时均停留在边界,不循环跳转。处于批量上传选择状态时,点击照片仍执行选择或取消选择,不打开预览。
|
|
||||||
|
|
||||||
### 状态流转图
|
|
||||||
|
|
||||||

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

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

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

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

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

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

|
|
||||||
|
|
||||||
### 本功能验收
|
|
||||||
|
|
||||||
- [ ] 未修图照片显示“已上传”。
|
|
||||||
- [ ] 修图中照片显示“修图中”且不能重复提交。
|
|
||||||
- [ ] 普通成功结果显示“AI已修”和最新效果图。
|
|
||||||
- [ ] 封面成功结果显示“AI封面”和封面排版图。
|
|
||||||
- [ ] 最近一次任务失败时优先显示“修图失败”。
|
|
||||||
- [ ] 还原、覆盖和删除后缩略图状态同步更新。
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "travel_album_preview_ai_edit.svg",
|
"filename" : "ai_retouch_template_placeholder.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "travel_album_cover_photo_icon.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "filename" : "travel_album_cover_photo_icon@2x.png", "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "filename" : "travel_album_cover_photo_icon@3x.png", "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 642 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "travel_album_header_background@1x.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "filename" : "travel_album_header_background@2x.png", "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "filename" : "travel_album_header_background@3x.png", "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 225 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "travel_album_sort_icon.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "filename" : "travel_album_sort_icon@2x.png", "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "filename" : "travel_album_sort_icon@3x.png", "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 258 B |
|
After Width: | Height: | Size: 588 B |
|
After Width: | Height: | Size: 970 B |
@@ -1,3 +0,0 @@
|
|||||||
<svg width="24" height="24" viewBox="0 -128 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill="#FFFFFF" d="M764 824q-40 40 -97 40t-97 -40l-544 -542v-320q0 -25 17.5 -43t42.5 -18h340l528 538q41 40 41 97t-41 97zM338 -29h-179q-26 0 -44.5 18.5t-18.5 45.5v196q12 -3 31 -9t65 -29.5t77 -53.5q29 -27 63.5 -86t38.5 -82h-33zM885 463l-435 -438q-73 119 -124 171q-15 15 -39.5 32t-48 31t-45.5 26t-35 19l-13 6l448 446q24 24 60.5 24t60.5 -25l171 -171q25 -26 25 -61.5t-25 -59.5zM611 315l-43 43l32 31l42 -42zM692 397l-42 42l126 127l43 -43z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 541 B |
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "travel_album_preview_delete.svg",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<svg width="24" height="24" viewBox="0 -128 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill="#FFFFFF" d="M368 440v-279q0 -13 8.5 -22t20.5 -9t20 8q9 10 9 23v279q0 13 -8.5 22.5t-20.5 9.5t-20.5 -9.5t-8.5 -22.5zM569 440v-279q0 -13 8.5 -22t20.5 -9t20.5 9t8.5 22v279q0 13 -8.5 22.5t-20.5 9.5t-20.5 -9.5t-8.5 -22.5zM283 -109h442q57 0 97 42t40 102v606h95q12 0 20.5 9.5t8.5 22.5t-8.5 22t-20.5 9h-259l-1 25q0 53 -35 101t-81 48h-154q-45 0 -80 -48t-36 -101v-25h-251q-12 0 -20.5 -9t-8.5 -22t8.5 -22.5t20.5 -9.5h87v-606q0 -60 40 -102t96 -42zM369 730q1 27 18.5 55t37.5 30h2h154q21 -2 39 -30t19 -55l1 -26h-272zM205 641h599v-606q0 -34 -23 -57.5t-56 -23.5h-442q-33 0 -55.5 23.5t-22.5 57.5v606v0z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 700 B |
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "travel_album_preview_refresh.svg",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<svg width="24" height="24" viewBox="0 -128 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill="#FFFFFF" d="M505.5 -127q-102.5 0 -196.5 39q-90 39 -159.5 108.5t-108.5 159.5q-39 94 -39 196.5t39 195.5q39 90 108.5 160t159.5 108q94 40 196.5 40t195.5 -40q90 -38 160 -108t108 -160q40 -93 40 -195.5t-40 -196.5q-38 -90 -108 -159.5t-160 -108.5q-93 -39 -195.5 -39zM505 819q-90 0 -172 -35q-80 -33 -141 -94.5t-95 -140.5q-35 -83 -35 -173t35 -172q34 -80 95 -141t141 -95q82 -35 172 -35t173 35q79 34 140.5 95t94.5 141q35 82 35 172t-35 173q-33 79 -94.5 140.5t-140.5 94.5q-83 35 -173 35zM595 472h-240l62 62q9 9 9 21.5t-9 21.5t-21.5 9t-21.5 -9l-113 -114q-9 -9 -9 -21.5t9 -21.5l113 -113q9 -9 21.5 -9t21.5 9t9 21.5t-9 21.5l-62 61h240q41 0 69.5 -28t28.5 -68t-28.5 -68.5t-69.5 -28.5h-221q-12 0 -21 -9t-9 -21.5t9 -21.5t21 -9h221q66 0 112 46.5t46 111.5t-46 111t-112 46z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 863 B |
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "purchased_alpine_lake.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 970 KiB |
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "purchased_forest_waterfall.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 1.2 MiB |
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "purchased_grassland_path.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 1.1 MiB |
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "purchased_lakeside_flowers.png",
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "universal",
|
|
||||||
"scale" : "3x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before 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,11 +41,30 @@ protocol TravelAlbumServing {
|
|||||||
/// 删除旅拍相册。
|
/// 删除旅拍相册。
|
||||||
func deleteAlbum(id: Int) async throws
|
func deleteAlbum(id: Int) async throws
|
||||||
|
|
||||||
/// 删除旅拍相册素材。
|
/// 批量删除旅拍相册素材。
|
||||||
func deleteMaterial(id: Int) async throws
|
func batchDeleteMaterials(ids: [Int]) async throws
|
||||||
|
|
||||||
/// 拉取相册小程序码。
|
/// 拉取相册小程序码。
|
||||||
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse
|
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse
|
||||||
|
|
||||||
|
/// 拉取当前景区可用的 AI 修图模板。
|
||||||
|
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse
|
||||||
|
|
||||||
|
/// 提交相册素材 AI 修图任务。
|
||||||
|
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission
|
||||||
|
|
||||||
|
/// 提交单张素材重新修图任务。
|
||||||
|
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws -> TravelAlbumAIJobSubmission
|
||||||
|
|
||||||
|
/// 拉取当前账号的 AI 修图任务列表。
|
||||||
|
func aiRetouchJobList(
|
||||||
|
statusGroup: TravelAlbumAIJobFilter,
|
||||||
|
limit: Int,
|
||||||
|
cursor: String?
|
||||||
|
) async throws -> TravelAlbumAIJobListResponse
|
||||||
|
|
||||||
|
/// 拉取指定 AI 修图任务详情。
|
||||||
|
func aiRetouchJobInfo(batchId: Int) async throws -> TravelAlbumAIJobDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -113,6 +135,20 @@ final class TravelAlbumAPI: TravelAlbumServing {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 拉取单个相册素材及其最新关联图片信息。
|
||||||
|
func materialInfo(userEquityTravelId: Int, materialId: Int) async throws -> TravelAlbumMaterial {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "\(basePath)/material-info",
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "user_equity_travel_id", value: String(userEquityTravelId)),
|
||||||
|
URLQueryItem(name: "material_id", value: String(materialId)),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// 上传并登记旅拍相册素材。
|
/// 上传并登记旅拍相册素材。
|
||||||
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
|
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
|
||||||
try await client.send(APIRequest(method: .post, path: "\(basePath)/upload-material", body: request))
|
try await client.send(APIRequest(method: .post, path: "\(basePath)/upload-material", body: request))
|
||||||
@@ -138,10 +174,14 @@ final class TravelAlbumAPI: TravelAlbumServing {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除旅拍相册素材。
|
/// 批量删除旅拍相册素材。
|
||||||
func deleteMaterial(id: Int) async throws {
|
func batchDeleteMaterials(ids: [Int]) async throws {
|
||||||
let _: EmptyPayload = try await client.send(
|
let _: EmptyPayload = try await client.send(
|
||||||
APIRequest(method: .post, path: "\(basePath)/delete-material", body: TravelAlbumIDRequest(id: id))
|
APIRequest(
|
||||||
|
method: .post,
|
||||||
|
path: "\(basePath)/batch-delete-material",
|
||||||
|
body: TravelAlbumBatchDeleteMaterialRequest(ids: ids)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,9 +195,64 @@ final class TravelAlbumAPI: TravelAlbumServing {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 拉取当前景区可用的 AI 修图模板。
|
||||||
|
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "\(basePath)/ai-retouch-templates",
|
||||||
|
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交相册素材 AI 修图任务并返回任务摘要。
|
||||||
|
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission {
|
||||||
|
try await client.send(APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交重新修图任务并返回任务摘要。
|
||||||
|
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws -> TravelAlbumAIJobSubmission {
|
||||||
|
try await client.send(APIRequest(method: .post, path: "\(basePath)/ai-reretouch", body: request))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取当前账号的 AI 修图任务列表。
|
||||||
|
func aiRetouchJobList(
|
||||||
|
statusGroup: TravelAlbumAIJobFilter,
|
||||||
|
limit: Int = 20,
|
||||||
|
cursor: String? = nil
|
||||||
|
) async throws -> TravelAlbumAIJobListResponse {
|
||||||
|
var queryItems = [
|
||||||
|
URLQueryItem(name: "status_group", value: statusGroup.rawValue),
|
||||||
|
URLQueryItem(name: "limit", value: String(min(50, max(1, limit)))),
|
||||||
|
]
|
||||||
|
if let cursor = cursor?.trimmingCharacters(in: .whitespacesAndNewlines), !cursor.isEmpty {
|
||||||
|
queryItems.append(URLQueryItem(name: "cursor", value: cursor))
|
||||||
|
}
|
||||||
|
return try await client.send(
|
||||||
|
APIRequest(method: .get, path: "\(basePath)/ai-retouch-job-list", queryItems: queryItems)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取指定 AI 修图任务详情。
|
||||||
|
func aiRetouchJobInfo(batchId: Int) async throws -> TravelAlbumAIJobDetail {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "\(basePath)/ai-retouch-job-info",
|
||||||
|
queryItems: [URLQueryItem(name: "ai_retouch_batch_id", value: String(batchId))]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 旅拍相册 ID 请求体。
|
/// 旅拍相册 ID 请求体。
|
||||||
private struct TravelAlbumIDRequest: Encodable, Sendable {
|
private struct TravelAlbumIDRequest: Encodable, Sendable {
|
||||||
let id: Int
|
let id: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 旅拍相册素材批量删除请求体。
|
||||||
|
private struct TravelAlbumBatchDeleteMaterialRequest: Encodable, Sendable {
|
||||||
|
let ids: [Int]
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// AI 修图任务筛选分组,对应任务列表接口的 `status_group`。
|
||||||
|
enum TravelAlbumAIJobFilter: String, CaseIterable, Sendable, Hashable {
|
||||||
|
case all
|
||||||
|
case inProgress = "in_progress"
|
||||||
|
case completed
|
||||||
|
case failed
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .all: "全部"
|
||||||
|
case .inProgress: "进行中"
|
||||||
|
case .completed: "已完成"
|
||||||
|
case .failed: "失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务及子任务状态;未知值安全降级,避免新增后端状态导致整页解码失败。
|
||||||
|
enum TravelAlbumAIJobStatus: Sendable, Hashable {
|
||||||
|
case queued
|
||||||
|
case processing
|
||||||
|
case succeeded
|
||||||
|
case partiallySucceeded
|
||||||
|
case failed
|
||||||
|
case canceled
|
||||||
|
case unknown(String)
|
||||||
|
|
||||||
|
init(rawValue: String) {
|
||||||
|
switch rawValue {
|
||||||
|
case "queued": self = .queued
|
||||||
|
case "processing": self = .processing
|
||||||
|
case "succeeded": self = .succeeded
|
||||||
|
case "partially_succeeded": self = .partiallySucceeded
|
||||||
|
case "failed": self = .failed
|
||||||
|
case "canceled": self = .canceled
|
||||||
|
default: self = .unknown(rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawValue: String {
|
||||||
|
switch self {
|
||||||
|
case .queued: "queued"
|
||||||
|
case .processing: "processing"
|
||||||
|
case .succeeded: "succeeded"
|
||||||
|
case .partiallySucceeded: "partially_succeeded"
|
||||||
|
case .failed: "failed"
|
||||||
|
case .canceled: "canceled"
|
||||||
|
case .unknown(let value): value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isInProgress: Bool { self == .queued || self == .processing }
|
||||||
|
var isTerminal: Bool { !isInProgress && !isUnknown }
|
||||||
|
private var isUnknown: Bool { if case .unknown = self { true } else { false } }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .queued: "排队中"
|
||||||
|
case .processing: "修图中"
|
||||||
|
case .succeeded: "已完成"
|
||||||
|
case .partiallySucceeded: "部分完成"
|
||||||
|
case .failed: "失败"
|
||||||
|
case .canceled: "已取消"
|
||||||
|
case .unknown: "状态更新中"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TravelAlbumAIJobStatus: Decodable {
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
self.init(rawValue: (try? decoder.singleValueContainer().decode(String.self)) ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图输出类型。
|
||||||
|
enum TravelAlbumAIJobOutputType: Sendable, Hashable {
|
||||||
|
case refined
|
||||||
|
case atmosphere
|
||||||
|
case cover
|
||||||
|
case unknown(String)
|
||||||
|
|
||||||
|
init(rawValue: String) {
|
||||||
|
switch rawValue {
|
||||||
|
case "refined": self = .refined
|
||||||
|
case "atmosphere": self = .atmosphere
|
||||||
|
case "cover": self = .cover
|
||||||
|
default: self = .unknown(rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .refined: "原图精修"
|
||||||
|
case .atmosphere: "氛围感"
|
||||||
|
case .cover: "封面"
|
||||||
|
case .unknown: "其他结果"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var previewKind: TravelAlbumPreviewAssetKind? {
|
||||||
|
switch self {
|
||||||
|
case .refined: .retouched
|
||||||
|
case .atmosphere: .atmosphere
|
||||||
|
case .cover: .cover
|
||||||
|
case .unknown: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TravelAlbumAIJobOutputType: Decodable {
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
self.init(rawValue: (try? decoder.singleValueContainer().decode(String.self)) ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图子任务数量进度。
|
||||||
|
struct TravelAlbumAIJobProgress: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let total: Int
|
||||||
|
let queued: Int
|
||||||
|
let processing: Int
|
||||||
|
let succeeded: Int
|
||||||
|
let failed: Int
|
||||||
|
let canceled: Int
|
||||||
|
|
||||||
|
var completed: Int { min(total, succeeded + failed + canceled) }
|
||||||
|
var fraction: Double { total > 0 ? min(1, Double(completed) / Double(total)) : 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图提交成功后返回的任务摘要。
|
||||||
|
struct TravelAlbumAIJobSubmission: Decodable, Sendable, Equatable {
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
|
let userEquityTravelId: Int
|
||||||
|
let status: TravelAlbumAIJobStatus
|
||||||
|
let progress: TravelAlbumAIJobProgress
|
||||||
|
let createdAt: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||||
|
case userEquityTravelId = "user_equity_travel_id"
|
||||||
|
case status, progress
|
||||||
|
case createdAt = "created_at"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务所属相册摘要。
|
||||||
|
struct TravelAlbumAIJobAlbum: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let userPhone: String
|
||||||
|
let coverURL: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name
|
||||||
|
case userPhone = "user_phone"
|
||||||
|
case coverURL = "cover_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务计划输出数量。
|
||||||
|
struct TravelAlbumAIJobOutput: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let type: TravelAlbumAIJobOutputType
|
||||||
|
let count: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务列表缩略图。
|
||||||
|
struct TravelAlbumAIJobPreviewImage: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let materialId: Int
|
||||||
|
let thumbnailURL: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case materialId = "material_id"
|
||||||
|
case thumbnailURL = "thumbnail_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务列表项。
|
||||||
|
struct TravelAlbumAIJobSummary: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||||
|
var id: Int { aiRetouchBatchId }
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
|
let userEquityTravelId: Int
|
||||||
|
let scope: String
|
||||||
|
let status: TravelAlbumAIJobStatus
|
||||||
|
let album: TravelAlbumAIJobAlbum
|
||||||
|
let sourceCount: Int
|
||||||
|
let outputs: [TravelAlbumAIJobOutput]
|
||||||
|
let previewImages: [TravelAlbumAIJobPreviewImage]
|
||||||
|
let progress: TravelAlbumAIJobProgress
|
||||||
|
let estimatedFinishAt: String?
|
||||||
|
let failureSummary: String?
|
||||||
|
let createdAt: String
|
||||||
|
let startedAt: String?
|
||||||
|
let finishedAt: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||||
|
case userEquityTravelId = "user_equity_travel_id"
|
||||||
|
case scope, status, album, outputs, progress
|
||||||
|
case sourceCount = "source_count"
|
||||||
|
case previewImages = "preview_images"
|
||||||
|
case estimatedFinishAt = "estimated_finish_at"
|
||||||
|
case failureSummary = "failure_summary"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case startedAt = "started_at"
|
||||||
|
case finishedAt = "finished_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayFailureSummary: String? {
|
||||||
|
guard status == .failed || status == .partiallySucceeded || progress.failed > 0 else { return nil }
|
||||||
|
let value = failureSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return value.isEmpty ? "部分照片处理失败,点击查看原因" : value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务游标分页响应。
|
||||||
|
struct TravelAlbumAIJobListResponse: Decodable, Sendable, Equatable {
|
||||||
|
let items: [TravelAlbumAIJobSummary]
|
||||||
|
let nextCursor: String?
|
||||||
|
let hasMore: Bool
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case items
|
||||||
|
case nextCursor = "next_cursor"
|
||||||
|
case hasMore = "has_more"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图额度结算信息。
|
||||||
|
struct TravelAlbumAIJobQuotaSettlement: Decodable, Sendable, Equatable {
|
||||||
|
let status: String
|
||||||
|
let reservedUnits: Int
|
||||||
|
let consumedUnits: Int
|
||||||
|
let releasedUnits: Int
|
||||||
|
let coverUnits: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case status
|
||||||
|
case reservedUnits = "reserved_units"
|
||||||
|
case consumedUnits = "consumed_units"
|
||||||
|
case releasedUnits = "released_units"
|
||||||
|
case coverUnits = "cover_units"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图目标的来源素材。
|
||||||
|
struct TravelAlbumAIJobSourceMaterial: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let fileName: String
|
||||||
|
let thumbnailURL: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case fileName = "file_name"
|
||||||
|
case thumbnailURL = "thumbnail_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图目标使用的模板摘要。
|
||||||
|
struct TravelAlbumAIJobTemplate: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图成功结果资源。
|
||||||
|
struct TravelAlbumAIJobResultAsset: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let materialId: Int
|
||||||
|
let url: String
|
||||||
|
let thumbnailURL: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case materialId = "material_id"
|
||||||
|
case url
|
||||||
|
case thumbnailURL = "thumbnail_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 可直接展示给用户的 AI 修图失败信息。
|
||||||
|
struct TravelAlbumAIJobError: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let code: String
|
||||||
|
let message: String
|
||||||
|
let retryable: Bool
|
||||||
|
|
||||||
|
var displayMessage: String {
|
||||||
|
let value = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return value.isEmpty ? "处理失败,请前往相册重新修图" : value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单个输出目标的处理明细。
|
||||||
|
struct TravelAlbumAIJobTarget: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||||
|
var id: Int { targetId }
|
||||||
|
let targetId: Int
|
||||||
|
let sourceMaterial: TravelAlbumAIJobSourceMaterial?
|
||||||
|
let inputMaterialIds: [Int]
|
||||||
|
let outputType: TravelAlbumAIJobOutputType
|
||||||
|
let template: TravelAlbumAIJobTemplate?
|
||||||
|
let status: TravelAlbumAIJobStatus
|
||||||
|
let resultAsset: TravelAlbumAIJobResultAsset?
|
||||||
|
let error: TravelAlbumAIJobError?
|
||||||
|
let createdAt: String
|
||||||
|
let startedAt: String?
|
||||||
|
let finishedAt: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case targetId = "target_id"
|
||||||
|
case sourceMaterial = "source_material"
|
||||||
|
case inputMaterialIds = "input_material_ids"
|
||||||
|
case outputType = "output_type"
|
||||||
|
case template, status, error
|
||||||
|
case resultAsset = "result_asset"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case startedAt = "started_at"
|
||||||
|
case finishedAt = "finished_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayFailureMessage: String? {
|
||||||
|
guard status == .failed else { return nil }
|
||||||
|
return error?.displayMessage ?? "处理失败,请前往相册重新修图"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务完整详情。
|
||||||
|
struct TravelAlbumAIJobDetail: Decodable, Sendable, Equatable {
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
|
let userEquityTravelId: Int
|
||||||
|
let scope: String
|
||||||
|
let status: TravelAlbumAIJobStatus
|
||||||
|
let album: TravelAlbumAIJobAlbum
|
||||||
|
let sourceCount: Int
|
||||||
|
let outputs: [TravelAlbumAIJobOutput]
|
||||||
|
let progress: TravelAlbumAIJobProgress
|
||||||
|
let quotaSettlement: TravelAlbumAIJobQuotaSettlement
|
||||||
|
let targets: [TravelAlbumAIJobTarget]
|
||||||
|
let estimatedFinishAt: String?
|
||||||
|
let createdAt: String
|
||||||
|
let startedAt: String?
|
||||||
|
let finishedAt: String?
|
||||||
|
let durationSeconds: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||||
|
case userEquityTravelId = "user_equity_travel_id"
|
||||||
|
case scope, status, album, outputs, progress, targets
|
||||||
|
case sourceCount = "source_count"
|
||||||
|
case quotaSettlement = "quota_settlement"
|
||||||
|
case estimatedFinishAt = "estimated_finish_at"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case startedAt = "started_at"
|
||||||
|
case finishedAt = "finished_at"
|
||||||
|
case durationSeconds = "duration_seconds"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务日期展示工具。
|
||||||
|
enum TravelAlbumAIJobDateFormatter {
|
||||||
|
private static let internetFormatter = ISO8601DateFormatter()
|
||||||
|
private static let preciseFormatter: ISO8601DateFormatter = {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func date(_ value: String?) -> Date? {
|
||||||
|
guard let value, !value.isEmpty else { return nil }
|
||||||
|
return preciseFormatter.date(from: value) ?? internetFormatter.date(from: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func display(_ value: String?) -> String {
|
||||||
|
guard let date = date(value) else { return "--" }
|
||||||
|
return date.formatted(.dateTime.month().day().hour().minute())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func time(_ value: String?) -> String {
|
||||||
|
guard let date = date(value) else { return "--" }
|
||||||
|
return date.formatted(.dateTime.hour().minute())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,8 +116,6 @@ struct TravelAlbumAvailableOrder: Decodable, Sendable, Equatable, Hashable {
|
|||||||
|
|
||||||
/// 旅拍相册素材实体,对齐 Android `TravelAlbumMaterialEntity`。
|
/// 旅拍相册素材实体,对齐 Android `TravelAlbumMaterialEntity`。
|
||||||
struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||||
private static let bundledImageURLPrefix = "asset://"
|
|
||||||
|
|
||||||
let id: Int
|
let id: Int
|
||||||
let userEquityTravelId: Int
|
let userEquityTravelId: Int
|
||||||
let status: Int
|
let status: Int
|
||||||
@@ -129,6 +127,11 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
let fileSize: Int
|
let fileSize: Int
|
||||||
let coverUrl: String
|
let coverUrl: String
|
||||||
let isPurchased: Bool
|
let isPurchased: Bool
|
||||||
|
let aiRetouchStatus: Int
|
||||||
|
let aiRetouchStatusName: String
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
|
let aiRefinedURL: String
|
||||||
|
let aiAtmosphereURL: String
|
||||||
let createdAt: String
|
let createdAt: String
|
||||||
let updatedAt: String
|
let updatedAt: String
|
||||||
|
|
||||||
@@ -144,10 +147,42 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
case fileSize = "file_size"
|
case fileSize = "file_size"
|
||||||
case coverUrl = "cover_url"
|
case coverUrl = "cover_url"
|
||||||
case isPurchased = "is_purchased"
|
case isPurchased = "is_purchased"
|
||||||
|
case aiRetouchStatus = "ai_retouch_status"
|
||||||
|
case aiRetouchStatusName = "ai_retouch_status_name"
|
||||||
|
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||||
|
case aiRefinedURL = "ai_refined_url"
|
||||||
|
case aiAtmosphereURL = "ai_atmosphere_url"
|
||||||
case createdAt = "created_at"
|
case createdAt = "created_at"
|
||||||
case updatedAt = "updated_at"
|
case updatedAt = "updated_at"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从素材接口解码;AI 修图扩展字段缺失、为空或类型异常时使用安全默认值。
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try container.decode(Int.self, forKey: .id)
|
||||||
|
userEquityTravelId = try container.decode(Int.self, forKey: .userEquityTravelId)
|
||||||
|
status = try container.decode(Int.self, forKey: .status)
|
||||||
|
orderNumber = try container.decode(String.self, forKey: .orderNumber)
|
||||||
|
userId = try container.decode(Int.self, forKey: .userId)
|
||||||
|
fileName = try container.decode(String.self, forKey: .fileName)
|
||||||
|
fileType = try container.decode(Int.self, forKey: .fileType)
|
||||||
|
fileUrl = try container.decode(String.self, forKey: .fileUrl)
|
||||||
|
fileSize = try container.decode(Int.self, forKey: .fileSize)
|
||||||
|
coverUrl = try container.decode(String.self, forKey: .coverUrl)
|
||||||
|
isPurchased = try container.decode(Bool.self, forKey: .isPurchased)
|
||||||
|
aiRetouchStatus = (try? container.decodeIfPresent(Int.self, forKey: .aiRetouchStatus)) ?? 0
|
||||||
|
aiRetouchStatusName = (
|
||||||
|
try? container.decodeIfPresent(String.self, forKey: .aiRetouchStatusName)
|
||||||
|
) ?? ""
|
||||||
|
aiRetouchBatchId = (try? container.decodeIfPresent(Int.self, forKey: .aiRetouchBatchId)) ?? 0
|
||||||
|
aiRefinedURL = (try? container.decodeIfPresent(String.self, forKey: .aiRefinedURL)) ?? ""
|
||||||
|
aiAtmosphereURL = (
|
||||||
|
try? container.decodeIfPresent(String.self, forKey: .aiAtmosphereURL)
|
||||||
|
) ?? ""
|
||||||
|
createdAt = try container.decode(String.self, forKey: .createdAt)
|
||||||
|
updatedAt = try container.decode(String.self, forKey: .updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: Int = 0,
|
id: Int = 0,
|
||||||
userEquityTravelId: Int = 0,
|
userEquityTravelId: Int = 0,
|
||||||
@@ -160,6 +195,11 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
fileSize: Int = 0,
|
fileSize: Int = 0,
|
||||||
coverUrl: String = "",
|
coverUrl: String = "",
|
||||||
isPurchased: Bool = false,
|
isPurchased: Bool = false,
|
||||||
|
aiRetouchStatus: Int = 0,
|
||||||
|
aiRetouchStatusName: String = "",
|
||||||
|
aiRetouchBatchId: Int = 0,
|
||||||
|
aiRefinedURL: String = "",
|
||||||
|
aiAtmosphereURL: String = "",
|
||||||
createdAt: String = "",
|
createdAt: String = "",
|
||||||
updatedAt: String = ""
|
updatedAt: String = ""
|
||||||
) {
|
) {
|
||||||
@@ -174,20 +214,67 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
self.fileSize = fileSize
|
self.fileSize = fileSize
|
||||||
self.coverUrl = coverUrl
|
self.coverUrl = coverUrl
|
||||||
self.isPurchased = isPurchased
|
self.isPurchased = isPurchased
|
||||||
|
self.aiRetouchStatus = aiRetouchStatus
|
||||||
|
self.aiRetouchStatusName = aiRetouchStatusName
|
||||||
|
self.aiRetouchBatchId = aiRetouchBatchId
|
||||||
|
self.aiRefinedURL = aiRefinedURL
|
||||||
|
self.aiAtmosphereURL = aiAtmosphereURL
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 将工程内图片资源转换为素材可识别的本地地址,仅供无接口数据的演示素材使用。
|
/// 相册素材网格角标类别,用于稳定映射独立业务状态和语义颜色。
|
||||||
static func bundledImageURL(assetName: String) -> String {
|
enum TravelAlbumMaterialBadgeKind: Sendable, Equatable {
|
||||||
bundledImageURLPrefix + assetName
|
case purchased
|
||||||
|
case pending
|
||||||
|
case processing
|
||||||
|
case retouched
|
||||||
|
case cover
|
||||||
|
case failed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 相册素材网格角标展示内容。
|
||||||
|
struct TravelAlbumMaterialBadgePresentation: Sendable, Equatable {
|
||||||
|
let kind: TravelAlbumMaterialBadgeKind
|
||||||
|
let text: String
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TravelAlbumMaterial {
|
||||||
|
/// 生成购买状态角标;购买状态与修图状态互不覆盖。
|
||||||
|
var purchaseBadgePresentation: TravelAlbumMaterialBadgePresentation? {
|
||||||
|
isPurchased
|
||||||
|
? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
||||||
|
: nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 返回本地演示素材对应的图片资源名;真实网络素材返回 `nil`。
|
/// 生成 AI 修图状态角标;未进入修图流程时返回 nil。
|
||||||
var bundledImageAssetName: String? {
|
var aiRetouchBadgePresentation: TravelAlbumMaterialBadgePresentation? {
|
||||||
guard fileUrl.hasPrefix(Self.bundledImageURLPrefix) else { return nil }
|
let statusName = aiRetouchStatusName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let assetName = String(fileUrl.dropFirst(Self.bundledImageURLPrefix.count))
|
switch aiRetouchStatus {
|
||||||
return assetName.isEmpty ? nil : assetName
|
case 1:
|
||||||
|
return TravelAlbumMaterialBadgePresentation(
|
||||||
|
kind: .pending,
|
||||||
|
text: statusName.isEmpty ? "待处理" : statusName
|
||||||
|
)
|
||||||
|
case 2:
|
||||||
|
return TravelAlbumMaterialBadgePresentation(
|
||||||
|
kind: .processing,
|
||||||
|
text: statusName.isEmpty ? "修图中" : statusName
|
||||||
|
)
|
||||||
|
case 3:
|
||||||
|
return TravelAlbumMaterialBadgePresentation(
|
||||||
|
kind: statusName == "AI封面" ? .cover : .retouched,
|
||||||
|
text: statusName.isEmpty ? "AI已修" : statusName
|
||||||
|
)
|
||||||
|
case 4:
|
||||||
|
return TravelAlbumMaterialBadgePresentation(
|
||||||
|
kind: .failed,
|
||||||
|
text: statusName.isEmpty ? "失败" : statusName
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +348,188 @@ struct TravelAlbumMpCodeResponse: Decodable, Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AI 修图模板类别,决定页面分组、选择规则和提交字段。
|
||||||
|
enum TravelAlbumAIRetouchTemplateCategory: Int, Sendable, Hashable {
|
||||||
|
case refined
|
||||||
|
case atmosphere
|
||||||
|
case cover
|
||||||
|
|
||||||
|
/// 模板分组展示标题。
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .refined: "原图精修"
|
||||||
|
case .atmosphere: "氛围感修图"
|
||||||
|
case .cover: "封面风格"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图模板页工作流,明确区分首次批量修图与单张结果覆盖重修。
|
||||||
|
enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable {
|
||||||
|
/// 对一个相册内的原始素材发起首次 AI 修图。
|
||||||
|
case initial(albumId: Int, materialIds: [Int])
|
||||||
|
/// 对单张素材的既有 AI 结果发起覆盖重修。
|
||||||
|
case reretouch(materialId: Int, batchId: Int, type: TravelAlbumAIReretouchType)
|
||||||
|
|
||||||
|
/// 当前页面需要展示的模板分组。
|
||||||
|
var visibleCategories: [TravelAlbumAIRetouchTemplateCategory] {
|
||||||
|
switch self {
|
||||||
|
case .initial(_, let materialIds):
|
||||||
|
var categories: [TravelAlbumAIRetouchTemplateCategory] = [.refined, .atmosphere]
|
||||||
|
if materialIds.count >= 4 { categories.append(.cover) }
|
||||||
|
return categories
|
||||||
|
case .reretouch(_, _, .refined):
|
||||||
|
return [.refined]
|
||||||
|
case .reretouch(_, _, .atmosphere):
|
||||||
|
return [.atmosphere]
|
||||||
|
case .reretouch(_, _, .all):
|
||||||
|
return [.refined, .atmosphere]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前分组是否允许不选择;首次修图仅氛围感选填,已有 AI 结果的预览重修两类模板均选填。
|
||||||
|
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
|
||||||
|
switch self {
|
||||||
|
case .initial:
|
||||||
|
return category == .atmosphere
|
||||||
|
case .reretouch(_, _, .all):
|
||||||
|
return category == .refined || category == .atmosphere
|
||||||
|
case .reretouch:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 工作流目标是否满足接口的最小参数要求。
|
||||||
|
var isValid: Bool {
|
||||||
|
switch self {
|
||||||
|
case .initial(let albumId, let materialIds):
|
||||||
|
return albumId > 0 && !materialIds.isEmpty
|
||||||
|
case .reretouch(let materialId, let batchId, _):
|
||||||
|
return materialId > 0 && batchId > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图模板,包含业务 ID、展示名称、卡片预览图及前后对比图片地址。
|
||||||
|
struct TravelAlbumAIRetouchTemplate: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let previewURL: String
|
||||||
|
let beforeURL: String
|
||||||
|
let afterURL: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case name
|
||||||
|
case previewURL = "preview_url"
|
||||||
|
case beforeURL = "before_url"
|
||||||
|
case afterURL = "after_url"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建 AI 修图模板。
|
||||||
|
init(
|
||||||
|
id: Int,
|
||||||
|
name: String,
|
||||||
|
previewURL: String,
|
||||||
|
beforeURL: String = "",
|
||||||
|
afterURL: String = ""
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.previewURL = previewURL
|
||||||
|
self.beforeURL = beforeURL
|
||||||
|
self.afterURL = afterURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解码模板;前后对比字段缺失或为 null 时按空字符串兼容旧接口响应。
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try container.decode(Int.self, forKey: .id)
|
||||||
|
name = try container.decode(String.self, forKey: .name)
|
||||||
|
previewURL = try container.decode(String.self, forKey: .previewURL)
|
||||||
|
beforeURL = (try? container.decodeIfPresent(String.self, forKey: .beforeURL)) ?? ""
|
||||||
|
afterURL = (try? container.decodeIfPresent(String.self, forKey: .afterURL)) ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图模板接口响应,按原图、氛围感和封面风格分组。
|
||||||
|
struct TravelAlbumAIRetouchTemplatesResponse: Decodable, Sendable, Equatable {
|
||||||
|
let refinedTemplates: [TravelAlbumAIRetouchTemplate]
|
||||||
|
let atmosphereTemplates: [TravelAlbumAIRetouchTemplate]
|
||||||
|
let coverTemplates: [TravelAlbumAIRetouchTemplate]
|
||||||
|
let remainingQuota: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case refinedTemplates = "refined_templates"
|
||||||
|
case atmosphereTemplates = "atmosphere_templates"
|
||||||
|
case coverTemplates = "cover_templates"
|
||||||
|
case remainingQuota = "remaining_quota"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建分组模板响应,默认各组为空。
|
||||||
|
init(
|
||||||
|
refinedTemplates: [TravelAlbumAIRetouchTemplate] = [],
|
||||||
|
atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = [],
|
||||||
|
coverTemplates: [TravelAlbumAIRetouchTemplate] = [],
|
||||||
|
remainingQuota: Int = 0
|
||||||
|
) {
|
||||||
|
self.refinedTemplates = refinedTemplates
|
||||||
|
self.atmosphereTemplates = atmosphereTemplates
|
||||||
|
self.coverTemplates = coverTemplates
|
||||||
|
self.remainingQuota = max(0, remainingQuota)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解码模板和剩余额度;旧响应缺少额度时按零处理,避免误提交付费任务。
|
||||||
|
init(from decoder: any Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
refinedTemplates = try container.decode([TravelAlbumAIRetouchTemplate].self, forKey: .refinedTemplates)
|
||||||
|
atmosphereTemplates = try container.decode([TravelAlbumAIRetouchTemplate].self, forKey: .atmosphereTemplates)
|
||||||
|
coverTemplates = try container.decode([TravelAlbumAIRetouchTemplate].self, forKey: .coverTemplates)
|
||||||
|
remainingQuota = max(0, try container.decodeIfPresent(Int.self, forKey: .remainingQuota) ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交 AI 修图任务的请求参数。
|
||||||
|
struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable {
|
||||||
|
let userEquityTravelId: Int
|
||||||
|
let materialIds: [Int]
|
||||||
|
let refinedTemplateId: Int
|
||||||
|
let atmosphereTemplateId: Int?
|
||||||
|
let coverTemplateId: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case userEquityTravelId = "user_equity_travel_id"
|
||||||
|
case materialIds = "material_ids"
|
||||||
|
case refinedTemplateId = "refined_template_id"
|
||||||
|
case atmosphereTemplateId = "atmosphere_template_id"
|
||||||
|
case coverTemplateId = "cover_template_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重新修图类型,决定后端覆盖的 AI 结果及必需模板字段。
|
||||||
|
enum TravelAlbumAIReretouchType: Int, Encodable, Sendable, Equatable {
|
||||||
|
case refined = 1
|
||||||
|
case atmosphere = 2
|
||||||
|
case all = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交单张素材重新修图的最小请求参数。
|
||||||
|
struct TravelAlbumAIReretouchRequest: Encodable, Sendable, Equatable {
|
||||||
|
let id: Int
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
|
let type: TravelAlbumAIReretouchType
|
||||||
|
let refinedTemplateId: Int?
|
||||||
|
let atmosphereTemplateId: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||||
|
case type
|
||||||
|
case refinedTemplateId = "refined_template_id"
|
||||||
|
case atmosphereTemplateId = "atmosphere_template_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 旅拍相册展示格式化工具。
|
/// 旅拍相册展示格式化工具。
|
||||||
enum TravelAlbumDisplayFormatter {
|
enum TravelAlbumDisplayFormatter {
|
||||||
/// 脱敏手机号。
|
/// 脱敏手机号。
|
||||||
@@ -291,4 +560,22 @@ enum TravelAlbumDisplayFormatter {
|
|||||||
}
|
}
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将服务端创建时间格式化为摘要卡使用的 `yyyy/MM/dd HH:mm`。
|
||||||
|
static func creationTimeText(_ text: String) -> String {
|
||||||
|
guard !text.isEmpty else { return "--" }
|
||||||
|
let normalized = text.replacingOccurrences(of: "T", with: " ")
|
||||||
|
guard normalized.count >= 16 else { return normalized }
|
||||||
|
return String(normalized.prefix(16)).replacingOccurrences(of: "-", with: "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算相册摘要卡封面,按相册、素材缩略图、素材原图顺序回退。
|
||||||
|
static func albumCoverURL(album: TravelAlbum?, materials: [TravelAlbumMaterial]) -> String {
|
||||||
|
if let coverURL = album?.coverUrl.trimmingCharacters(in: .whitespacesAndNewlines), !coverURL.isEmpty {
|
||||||
|
return coverURL
|
||||||
|
}
|
||||||
|
guard let first = materials.first else { return "" }
|
||||||
|
let materialCover = first.coverUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return materialCover.isEmpty ? first.fileUrl.trimmingCharacters(in: .whitespacesAndNewlines) : materialCover
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,316 +0,0 @@
|
|||||||
//
|
|
||||||
// TravelAlbumPhotoPreviewModels.swift
|
|
||||||
// suixinkan
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// 单张照片的 AI 修图状态,控制处理中禁用、结果对比以及再次修图覆盖。
|
|
||||||
struct TravelAlbumAIEditResultState: Equatable, Sendable {
|
|
||||||
/// 相册缩略图需要展示的 AI 修图状态。
|
|
||||||
enum ThumbnailStatus: Equatable, Sendable {
|
|
||||||
case none
|
|
||||||
case processing
|
|
||||||
case edited
|
|
||||||
case cover
|
|
||||||
case failed
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 当前预览展示的版本。
|
|
||||||
enum DisplayMode: Equatable, Sendable {
|
|
||||||
case original
|
|
||||||
case edited
|
|
||||||
case atmosphere
|
|
||||||
case cover
|
|
||||||
}
|
|
||||||
|
|
||||||
private(set) var isProcessing = false
|
|
||||||
private(set) var didFailLastAttempt = false
|
|
||||||
private(set) var appliedPresetID: String?
|
|
||||||
private(set) var appliedAtmosphereOptionID: String?
|
|
||||||
private(set) var appliedCoverTemplateID: String?
|
|
||||||
private(set) var displayMode: DisplayMode = .original
|
|
||||||
|
|
||||||
/// 已经存在可查看的修图结果。
|
|
||||||
var hasEditedResult: Bool {
|
|
||||||
appliedPresetID != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 处理期间禁止再次发起 AI 修图。
|
|
||||||
var canStartEditing: Bool {
|
|
||||||
!isProcessing
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 当前结果是否为封面模板生成的封面图。
|
|
||||||
var isCover: Bool {
|
|
||||||
appliedCoverTemplateID != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 当前是否已经生成可独立查看的氛围感修图结果。
|
|
||||||
var hasAtmosphereResult: Bool {
|
|
||||||
appliedAtmosphereOptionID != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 根据修图任务状态生成缩略图角标,处理中和失败优先于历史成功结果。
|
|
||||||
var thumbnailStatus: ThumbnailStatus {
|
|
||||||
if isProcessing {
|
|
||||||
return .processing
|
|
||||||
}
|
|
||||||
if didFailLastAttempt {
|
|
||||||
return .failed
|
|
||||||
}
|
|
||||||
if isCover {
|
|
||||||
return .cover
|
|
||||||
}
|
|
||||||
return hasEditedResult ? .edited : .none
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 开始修图;已有结果保留到新结果完成,避免处理中画面回退。
|
|
||||||
mutating func startProcessing() {
|
|
||||||
guard !isProcessing else { return }
|
|
||||||
isProcessing = true
|
|
||||||
didFailLastAttempt = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 完成修图并覆盖旧预设结果,默认展示新的修图后版本。
|
|
||||||
mutating func complete(
|
|
||||||
presetID: String,
|
|
||||||
atmosphereOptionID: String? = nil,
|
|
||||||
coverTemplateID: String? = nil
|
|
||||||
) {
|
|
||||||
isProcessing = false
|
|
||||||
didFailLastAttempt = false
|
|
||||||
appliedPresetID = presetID
|
|
||||||
appliedAtmosphereOptionID = atmosphereOptionID
|
|
||||||
appliedCoverTemplateID = coverTemplateID
|
|
||||||
displayMode = coverTemplateID == nil ? .edited : .cover
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 处理失败时恢复入口;已有成功结果继续保留。
|
|
||||||
mutating func failProcessing() {
|
|
||||||
isProcessing = false
|
|
||||||
didFailLastAttempt = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 选择还原原图后清除历史修图结果和失败状态。
|
|
||||||
mutating func restoreOriginal() {
|
|
||||||
isProcessing = false
|
|
||||||
didFailLastAttempt = false
|
|
||||||
appliedPresetID = nil
|
|
||||||
appliedAtmosphereOptionID = nil
|
|
||||||
appliedCoverTemplateID = nil
|
|
||||||
displayMode = .original
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 在已有结果时切换原图或修图后预览。
|
|
||||||
mutating func selectDisplayMode(_ mode: DisplayMode) {
|
|
||||||
guard hasEditedResult else {
|
|
||||||
displayMode = .original
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if mode == .atmosphere, !hasAtmosphereResult {
|
|
||||||
displayMode = .edited
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if mode == .cover, !isCover {
|
|
||||||
displayMode = .edited
|
|
||||||
return
|
|
||||||
}
|
|
||||||
displayMode = mode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 相册管理底部操作栏状态,用于在上传与多选操作之间切换。
|
|
||||||
struct TravelAlbumSelectionToolbarState: Equatable, Sendable {
|
|
||||||
let isSelectionMode: Bool
|
|
||||||
let selectedCount: Int
|
|
||||||
|
|
||||||
/// 普通状态显示上传照片。
|
|
||||||
var showsUpload: Bool {
|
|
||||||
!isSelectionMode
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 多选状态显示 AI 修图和删除。
|
|
||||||
var showsSelectionActions: Bool {
|
|
||||||
isSelectionMode
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 至少选中一张照片后才允许执行批量操作。
|
|
||||||
var selectionActionsEnabled: Bool {
|
|
||||||
isSelectionMode && selectedCount > 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 相册照片预览状态,维护当前照片位置并处理左右切换。
|
|
||||||
struct TravelAlbumPhotoPreviewState: Equatable, Sendable {
|
|
||||||
private(set) var materials: [TravelAlbumMaterial]
|
|
||||||
private(set) var currentIndex: Int
|
|
||||||
|
|
||||||
init(materials: [TravelAlbumMaterial], initialIndex: Int) {
|
|
||||||
self.materials = materials
|
|
||||||
currentIndex = min(max(initialIndex, 0), max(materials.count - 1, 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 当前正在预览的照片。
|
|
||||||
var currentMaterial: TravelAlbumMaterial? {
|
|
||||||
materials.indices.contains(currentIndex) ? materials[currentIndex] : nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 切换至下一张;已经是最后一张时保持不变。
|
|
||||||
@discardableResult
|
|
||||||
mutating func moveNext() -> Bool {
|
|
||||||
guard currentIndex + 1 < materials.count else { return false }
|
|
||||||
currentIndex += 1
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 切换至上一张;已经是第一张时保持不变。
|
|
||||||
@discardableResult
|
|
||||||
mutating func movePrevious() -> Bool {
|
|
||||||
guard currentIndex > 0 else { return false }
|
|
||||||
currentIndex -= 1
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除当前照片,并自动定位到相邻照片。
|
|
||||||
@discardableResult
|
|
||||||
mutating func removeCurrent() -> Bool {
|
|
||||||
guard materials.indices.contains(currentIndex) else { return false }
|
|
||||||
materials.remove(at: currentIndex)
|
|
||||||
if currentIndex >= materials.count {
|
|
||||||
currentIndex = max(materials.count - 1, 0)
|
|
||||||
}
|
|
||||||
return !materials.isEmpty
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 相册照片 AI 修图预设,当前为本地演示数据,后续可替换为服务端下发的预设列表。
|
|
||||||
struct TravelAlbumEditPreset: Hashable, Sendable, Identifiable {
|
|
||||||
/// 图像效果类型,用于在预览页生成对应效果图。
|
|
||||||
enum Effect: String, Hashable, Sendable {
|
|
||||||
case original
|
|
||||||
case portrait
|
|
||||||
case vintage
|
|
||||||
case brocade
|
|
||||||
case distantMountain
|
|
||||||
case mist
|
|
||||||
case summer
|
|
||||||
case rich
|
|
||||||
}
|
|
||||||
|
|
||||||
let id: String
|
|
||||||
let title: String
|
|
||||||
let effect: Effect
|
|
||||||
|
|
||||||
/// AI 修图底部弹窗展示的默认预设,暂无接口时用于完整演示选择流程。
|
|
||||||
static let defaultOptions: [TravelAlbumEditPreset] = [
|
|
||||||
TravelAlbumEditPreset(id: "original", title: "还原为原图", effect: .original),
|
|
||||||
TravelAlbumEditPreset(id: "portrait", title: "写真-简约肖像", effect: .portrait),
|
|
||||||
TravelAlbumEditPreset(id: "vintage", title: "写真-清冷古风", effect: .vintage),
|
|
||||||
TravelAlbumEditPreset(id: "brocade", title: "旅拍-锦绣", effect: .brocade),
|
|
||||||
TravelAlbumEditPreset(id: "distant_mountain", title: "旅拍-远山", effect: .distantMountain),
|
|
||||||
TravelAlbumEditPreset(id: "mist", title: "旅拍-薄雾", effect: .mist),
|
|
||||||
TravelAlbumEditPreset(id: "summer", title: "油画-夏日", effect: .summer),
|
|
||||||
TravelAlbumEditPreset(id: "rich", title: "油画-浓郁", effect: .rich),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 可选的氛围感修图样式;选中后在原图精修之外额外生成一张独立结果。
|
|
||||||
struct TravelAlbumAtmosphereEditOption: Hashable, Sendable, Identifiable {
|
|
||||||
let id: String
|
|
||||||
let title: String
|
|
||||||
let subtitle: String
|
|
||||||
let effect: TravelAlbumEditPreset.Effect
|
|
||||||
|
|
||||||
/// 暂无接口时用于演示横向滚动单选的氛围感样式。
|
|
||||||
static let defaultOptions: [TravelAlbumAtmosphereEditOption] = [
|
|
||||||
TravelAlbumAtmosphereEditOption(
|
|
||||||
id: "warm_sun",
|
|
||||||
title: "日落暖阳",
|
|
||||||
subtitle: "温暖柔和",
|
|
||||||
effect: .summer
|
|
||||||
),
|
|
||||||
TravelAlbumAtmosphereEditOption(
|
|
||||||
id: "retro_film",
|
|
||||||
title: "复古胶片",
|
|
||||||
subtitle: "怀旧颗粒感",
|
|
||||||
effect: .vintage
|
|
||||||
),
|
|
||||||
TravelAlbumAtmosphereEditOption(
|
|
||||||
id: "clear_blue",
|
|
||||||
title: "清透蓝调",
|
|
||||||
subtitle: "通透轻盈",
|
|
||||||
effect: .mist
|
|
||||||
),
|
|
||||||
TravelAlbumAtmosphereEditOption(
|
|
||||||
id: "forest_mist",
|
|
||||||
title: "森系薄雾",
|
|
||||||
subtitle: "低饱和自然感",
|
|
||||||
effect: .distantMountain
|
|
||||||
),
|
|
||||||
TravelAlbumAtmosphereEditOption(
|
|
||||||
id: "vivid_story",
|
|
||||||
title: "浓郁故事",
|
|
||||||
subtitle: "高饱和电影感",
|
|
||||||
effect: .rich
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
/// 兼容现有结果测试与默认演示数据的首个氛围感样式。
|
|
||||||
static let defaultOption = defaultOptions[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 多图 AI 修图使用的封面风格模板,当前为本地演示数据,后续可替换为服务端配置。
|
|
||||||
struct TravelAlbumCoverTemplate: Hashable, Sendable, Identifiable {
|
|
||||||
/// 封面预览的排版样式,用于生成可辨识的本地效果图。
|
|
||||||
enum PreviewStyle: String, Hashable, Sendable {
|
|
||||||
case travelMemoir
|
|
||||||
case minimal
|
|
||||||
case scenicStory
|
|
||||||
case film
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 触发展示封面模板选择的最少照片数量。
|
|
||||||
static let minimumPhotoCount = 4
|
|
||||||
|
|
||||||
let id: String
|
|
||||||
let title: String
|
|
||||||
let previewStyle: PreviewStyle
|
|
||||||
/// 当前消耗的精修额度;免费阶段为 0,后续收费可由服务端改为正数。
|
|
||||||
let quotaCost: Int
|
|
||||||
|
|
||||||
/// 当前模板是否为赠送且不占用精修张数。
|
|
||||||
var isFreeGift: Bool {
|
|
||||||
quotaCost == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 判断本次多选是否需要展示封面模板模块。
|
|
||||||
static func shouldShow(for selectedPhotoCount: Int) -> Bool {
|
|
||||||
selectedPhotoCount >= minimumPhotoCount
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 暂无接口时用于完整演示单选流程的封面模板。
|
|
||||||
static let defaultOptions: [TravelAlbumCoverTemplate] = [
|
|
||||||
TravelAlbumCoverTemplate(id: "travel_memoir", title: "旅行纪念册", previewStyle: .travelMemoir, quotaCost: 0),
|
|
||||||
TravelAlbumCoverTemplate(id: "minimal", title: "简约留白", previewStyle: .minimal, quotaCost: 0),
|
|
||||||
TravelAlbumCoverTemplate(id: "scenic_story", title: "景区故事", previewStyle: .scenicStory, quotaCost: 0),
|
|
||||||
TravelAlbumCoverTemplate(id: "film", title: "电影胶片", previewStyle: .film, quotaCost: 0),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// AI 修图弹窗最终提交的样式组合,统一承载原图精修、氛围感和封面模板。
|
|
||||||
struct TravelAlbumAIEditSelection: Equatable, Sendable {
|
|
||||||
let refinedPreset: TravelAlbumEditPreset
|
|
||||||
let atmosphereOption: TravelAlbumAtmosphereEditOption?
|
|
||||||
let coverTemplate: TravelAlbumCoverTemplate?
|
|
||||||
|
|
||||||
/// 每张普通照片需要生成的修图结果数量,不包含原图。
|
|
||||||
var resultCountPerPhoto: Int {
|
|
||||||
atmosphereOption == nil ? 1 : 2
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 弹窗当前展示并提交的样式类别数量。
|
|
||||||
var selectedStyleCount: Int {
|
|
||||||
1 + (atmosphereOption == nil ? 0 : 1) + (coverTemplate == nil ? 0 : 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumPreviewModels.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 前后图片对比页所需的通用内容,与具体入口和 UI 框架解耦。
|
||||||
|
struct BeforeAfterComparisonContent: Sendable, Equatable {
|
||||||
|
let beforeURL: String
|
||||||
|
let afterURL: String
|
||||||
|
let beforeLabel: String
|
||||||
|
let afterLabel: String
|
||||||
|
|
||||||
|
/// 创建前后对比内容,默认使用 AI 修图模块的中文角标。
|
||||||
|
init(
|
||||||
|
beforeURL: String,
|
||||||
|
afterURL: String,
|
||||||
|
beforeLabel: String = "原图",
|
||||||
|
afterLabel: String = "效果图"
|
||||||
|
) {
|
||||||
|
self.beforeURL = beforeURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.afterURL = afterURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.beforeLabel = beforeLabel
|
||||||
|
self.afterLabel = afterLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 两张图片地址均存在时才允许进入对比页。
|
||||||
|
var isValid: Bool {
|
||||||
|
!beforeURL.isEmpty && !afterURL.isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 相册预览页横向滑动策略,由调用方通过配置注入。
|
||||||
|
enum TravelAlbumPreviewSwipeMode: Sendable, Equatable {
|
||||||
|
/// 横滑只切换原图项目,关联图通过 Tab 切换。
|
||||||
|
case projectsOnly
|
||||||
|
/// 横滑依次切换当前项目的关联图,并在边界进入相邻项目原图。
|
||||||
|
case includeVariants
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 相册预览页配置。
|
||||||
|
struct TravelAlbumPreviewConfiguration: Sendable {
|
||||||
|
let swipeMode: TravelAlbumPreviewSwipeMode
|
||||||
|
|
||||||
|
/// 创建预览配置,默认只按原图项目分页。
|
||||||
|
init(swipeMode: TravelAlbumPreviewSwipeMode = .projectsOnly) {
|
||||||
|
self.swipeMode = swipeMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览图片类型,顺序同时决定 Tab 与关联图浏览顺序。
|
||||||
|
enum TravelAlbumPreviewAssetKind: Int, CaseIterable, Sendable, Hashable {
|
||||||
|
case original
|
||||||
|
case retouched
|
||||||
|
case atmosphere
|
||||||
|
case cover
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .original: "原图"
|
||||||
|
case .retouched: "精修后"
|
||||||
|
case .atmosphere: "氛围感"
|
||||||
|
case .cover: "封面"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览页使用的单张图片信息,与后端关联图字段解耦。
|
||||||
|
struct TravelAlbumPreviewAsset: Identifiable, Sendable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let kind: TravelAlbumPreviewAssetKind
|
||||||
|
let fileURL: String
|
||||||
|
let coverURL: String
|
||||||
|
let fileName: String
|
||||||
|
let fileSize: Int
|
||||||
|
|
||||||
|
/// 实际用于展示的地址,优先使用原图地址。
|
||||||
|
var displayURL: String {
|
||||||
|
let trimmedFileURL = fileURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmedFileURL.isEmpty ? coverURL.trimmingCharacters(in: .whitespacesAndNewlines) : trimmedFileURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 横滑预览地址,优先使用体积更小的封面图以降低解码开销。
|
||||||
|
var previewURL: String {
|
||||||
|
let trimmedCoverURL = coverURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmedCoverURL.isEmpty ? fileURL.trimmingCharacters(in: .whitespacesAndNewlines) : trimmedCoverURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一张原图及其所有关联图片组成的预览项目。
|
||||||
|
struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
||||||
|
let originalMaterialId: Int
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
|
let assets: [TravelAlbumPreviewAsset]
|
||||||
|
|
||||||
|
var id: Int { originalMaterialId }
|
||||||
|
|
||||||
|
/// 按产品约定顺序返回实际存在的图片。
|
||||||
|
var orderedAssets: [TravelAlbumPreviewAsset] {
|
||||||
|
TravelAlbumPreviewAssetKind.allCases.compactMap(asset(for:))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否存在原图之外的关联图片。
|
||||||
|
var hasVariants: Bool {
|
||||||
|
orderedAssets.contains { $0.kind != .original }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回指定类型的图片。
|
||||||
|
func asset(for kind: TravelAlbumPreviewAssetKind) -> TravelAlbumPreviewAsset? {
|
||||||
|
assets.first { $0.kind == kind }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 为精修或氛围感结果构建与原图的对比内容;其他类型或无效地址返回 nil。
|
||||||
|
func comparisonContent(for kind: TravelAlbumPreviewAssetKind) -> BeforeAfterComparisonContent? {
|
||||||
|
guard kind == .retouched || kind == .atmosphere,
|
||||||
|
let original = asset(for: .original),
|
||||||
|
let result = asset(for: kind)
|
||||||
|
else { return nil }
|
||||||
|
let content = BeforeAfterComparisonContent(
|
||||||
|
beforeURL: original.displayURL,
|
||||||
|
afterURL: result.displayURL
|
||||||
|
)
|
||||||
|
return content.isValid ? content : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将素材映射为原图及实际存在的 AI 精修、氛围感结果图。
|
||||||
|
init(material: TravelAlbumMaterial) {
|
||||||
|
originalMaterialId = material.id
|
||||||
|
aiRetouchBatchId = material.aiRetouchBatchId
|
||||||
|
var mappedAssets = [
|
||||||
|
TravelAlbumPreviewAsset(
|
||||||
|
id: "original-\(material.id)",
|
||||||
|
kind: .original,
|
||||||
|
fileURL: material.fileUrl,
|
||||||
|
coverURL: material.coverUrl,
|
||||||
|
fileName: material.fileName,
|
||||||
|
fileSize: material.fileSize
|
||||||
|
),
|
||||||
|
]
|
||||||
|
let refinedURL = material.aiRefinedURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !refinedURL.isEmpty {
|
||||||
|
mappedAssets.append(
|
||||||
|
TravelAlbumPreviewAsset(
|
||||||
|
id: "retouched-\(material.id)",
|
||||||
|
kind: .retouched,
|
||||||
|
fileURL: refinedURL,
|
||||||
|
coverURL: refinedURL,
|
||||||
|
fileName: material.fileName,
|
||||||
|
fileSize: material.fileSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let atmosphereURL = material.aiAtmosphereURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !atmosphereURL.isEmpty {
|
||||||
|
mappedAssets.append(
|
||||||
|
TravelAlbumPreviewAsset(
|
||||||
|
id: "atmosphere-\(material.id)",
|
||||||
|
kind: .atmosphere,
|
||||||
|
fileURL: atmosphereURL,
|
||||||
|
coverURL: atmosphereURL,
|
||||||
|
fileName: material.fileName,
|
||||||
|
fileSize: material.fileSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assets = mappedAssets
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建包含关联图片的项目,主要供适配器和测试使用。
|
||||||
|
init(originalMaterialId: Int, aiRetouchBatchId: Int = 0, assets: [TravelAlbumPreviewAsset]) {
|
||||||
|
self.originalMaterialId = originalMaterialId
|
||||||
|
self.aiRetouchBatchId = aiRetouchBatchId
|
||||||
|
var seenKinds = Set<TravelAlbumPreviewAssetKind>()
|
||||||
|
self.assets = assets.filter { seenKinds.insert($0.kind).inserted }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据是否已有 AI 结果生成首次修图或双模板覆盖重修工作流。
|
||||||
|
func aiRetouchWorkflow(
|
||||||
|
albumId: Int,
|
||||||
|
selectedKind: TravelAlbumPreviewAssetKind
|
||||||
|
) -> TravelAlbumAIRetouchWorkflow? {
|
||||||
|
guard hasVariants else {
|
||||||
|
return .initial(albumId: albumId, materialIds: [originalMaterialId])
|
||||||
|
}
|
||||||
|
guard selectedKind != .cover else { return nil }
|
||||||
|
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TravelAlbumAIRetouchTemplate {
|
||||||
|
/// 使用接口返回的模板示例前后图构建对比页内容。
|
||||||
|
var comparisonContent: BeforeAfterComparisonContent? {
|
||||||
|
let content = BeforeAfterComparisonContent(beforeURL: beforeURL, afterURL: afterURL)
|
||||||
|
return content.isValid ? content : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览分页节点,记录当前图片所属项目及类型。
|
||||||
|
struct TravelAlbumPreviewNode: Sendable, Hashable {
|
||||||
|
let projectIndex: Int
|
||||||
|
let kind: TravelAlbumPreviewAssetKind
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览页纯状态工具,负责构建节点和处理分组滑动边界。
|
||||||
|
enum TravelAlbumPreviewNavigator {
|
||||||
|
/// 根据滑动模式生成页面节点。
|
||||||
|
static func nodes(
|
||||||
|
projects: [TravelAlbumPreviewProject],
|
||||||
|
mode: TravelAlbumPreviewSwipeMode
|
||||||
|
) -> [TravelAlbumPreviewNode] {
|
||||||
|
projects.enumerated().flatMap { index, project in
|
||||||
|
switch mode {
|
||||||
|
case .projectsOnly:
|
||||||
|
[TravelAlbumPreviewNode(projectIndex: index, kind: .original)]
|
||||||
|
case .includeVariants:
|
||||||
|
project.orderedAssets.map { TravelAlbumPreviewNode(projectIndex: index, kind: $0.kind) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理反向滑动边界:从项目原图向右滑时直接进入上一项目原图。
|
||||||
|
static func backwardTargetIndex(
|
||||||
|
nodes: [TravelAlbumPreviewNode],
|
||||||
|
currentIndex: Int
|
||||||
|
) -> Int {
|
||||||
|
guard nodes.indices.contains(currentIndex), currentIndex > 0 else { return max(0, currentIndex) }
|
||||||
|
let current = nodes[currentIndex]
|
||||||
|
guard current.kind == .original, current.projectIndex > 0 else { return currentIndex - 1 }
|
||||||
|
return nodes.firstIndex {
|
||||||
|
$0.projectIndex == current.projectIndex - 1 && $0.kind == .original
|
||||||
|
} ?? currentIndex - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回删除当前项目后应展示的项目索引;优先保持原索引以显示下一张,末项则回退上一张。
|
||||||
|
static func projectIndexAfterDeletion(
|
||||||
|
deletedProjectIndex: Int,
|
||||||
|
remainingProjectCount: Int
|
||||||
|
) -> Int? {
|
||||||
|
guard remainingProjectCount > 0 else { return nil }
|
||||||
|
return min(max(0, deletedProjectIndex), remainingProjectCount - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览删除操作执行结果,统一表达成功、失败与暂不可用状态。
|
||||||
|
enum TravelAlbumPreviewActionResult: Sendable, Equatable {
|
||||||
|
case success(String?)
|
||||||
|
case failure(String)
|
||||||
|
case unavailable(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览页删除操作协议,以原素材项目 ID 为目标。
|
||||||
|
protocol TravelAlbumPreviewActionHandling: AnyObject {
|
||||||
|
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览删除接口接入前的占位操作实现,不修改任何业务数据。
|
||||||
|
final class PlaceholderTravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
|
||||||
|
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
||||||
|
.unavailable("项目删除接口待接入")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,12 +52,6 @@ struct TravelAlbumOTGPhotoSection: Hashable, Sendable {
|
|||||||
let photos: [TravelAlbumOTGPhotoItem]
|
let photos: [TravelAlbumOTGPhotoItem]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 有线传输照片预览上下文,保存可预览照片顺序和点击照片位置。
|
|
||||||
struct TravelAlbumOTGPhotoPreviewContext: Equatable, Sendable {
|
|
||||||
let photos: [TravelAlbumOTGPhotoItem]
|
|
||||||
let initialIndex: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
/// OTG 传输页 Tab。
|
/// OTG 传输页 Tab。
|
||||||
enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable {
|
enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable {
|
||||||
case all
|
case all
|
||||||
@@ -86,16 +80,6 @@ enum TravelAlbumOTGTransferMode: String, CaseIterable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 模式选择弹窗中的业务说明。
|
|
||||||
var detailText: String {
|
|
||||||
switch self {
|
|
||||||
case .liveUpload:
|
|
||||||
return "相机拍摄后,照片自动传输并上传到当前相册"
|
|
||||||
case .postTransfer:
|
|
||||||
return "拍摄完成后,再选择照片批量传输"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 是否在新照片导入 App 后自动上传。
|
/// 是否在新照片导入 App 后自动上传。
|
||||||
var shouldAutoUploadNewImports: Bool {
|
var shouldAutoUploadNewImports: Bool {
|
||||||
self == .liveUpload
|
self == .liveUpload
|
||||||
@@ -129,18 +113,6 @@ extension TravelAlbumOTGPhotoItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension Array where Element == TravelAlbumOTGPhotoItem {
|
extension Array where Element == TravelAlbumOTGPhotoItem {
|
||||||
/// 按当前列表顺序生成预览上下文,不可预览照片不会占用左右滑动页码。
|
|
||||||
func previewContext(startingWith photoID: String) -> TravelAlbumOTGPhotoPreviewContext? {
|
|
||||||
let previewablePhotos = filter { $0.thumbnailURL != nil }
|
|
||||||
guard let initialIndex = previewablePhotos.firstIndex(where: { $0.id == photoID }) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return TravelAlbumOTGPhotoPreviewContext(
|
|
||||||
photos: previewablePhotos,
|
|
||||||
initialIndex: initialIndex
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 按 Android OTG 页规则构造半小时照片分段。
|
/// 按 Android OTG 页规则构造半小时照片分段。
|
||||||
func buildPhotoSections() -> [TravelAlbumOTGPhotoSection] {
|
func buildPhotoSections() -> [TravelAlbumOTGPhotoSection] {
|
||||||
let pairs = compactMap { photo -> (slotStart: Date, photo: TravelAlbumOTGPhotoItem)? in
|
let pairs = compactMap { photo -> (slotStart: Date, photo: TravelAlbumOTGPhotoItem)? in
|
||||||
|
|||||||
@@ -1,308 +0,0 @@
|
|||||||
//
|
|
||||||
// TravelAlbumAIEditResultStore.swift
|
|
||||||
// suixinkan
|
|
||||||
//
|
|
||||||
|
|
||||||
import CoreImage
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
/// AI 修图结果的内存数据源,在相册管理与照片预览之间共享本地演示结果。
|
|
||||||
@MainActor
|
|
||||||
final class TravelAlbumAIEditResultStore {
|
|
||||||
/// 单张照片的修图记录。
|
|
||||||
struct Record {
|
|
||||||
var state: TravelAlbumAIEditResultState
|
|
||||||
var editedImage: UIImage?
|
|
||||||
var atmosphereImage: UIImage?
|
|
||||||
var coverImage: UIImage?
|
|
||||||
}
|
|
||||||
|
|
||||||
static let shared = TravelAlbumAIEditResultStore()
|
|
||||||
static let didChangeNotification = Notification.Name("TravelAlbumAIEditResultStore.didChange")
|
|
||||||
static let materialIDUserInfoKey = "materialID"
|
|
||||||
|
|
||||||
private var recordsByMaterialID: [Int: Record] = [:]
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
/// 获取指定照片的修图状态。
|
|
||||||
func state(for materialID: Int) -> TravelAlbumAIEditResultState {
|
|
||||||
recordsByMaterialID[materialID]?.state ?? TravelAlbumAIEditResultState()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取指定照片最新的修图结果。
|
|
||||||
func editedImage(for materialID: Int) -> UIImage? {
|
|
||||||
recordsByMaterialID[materialID]?.editedImage
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取指定照片最新的氛围感修图结果。
|
|
||||||
func atmosphereImage(for materialID: Int) -> UIImage? {
|
|
||||||
recordsByMaterialID[materialID]?.atmosphereImage
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 获取指定照片最新的封面模板结果。
|
|
||||||
func coverImage(for materialID: Int) -> UIImage? {
|
|
||||||
recordsByMaterialID[materialID]?.coverImage
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将照片标记为修图中,已有结果继续保留到新结果完成。
|
|
||||||
func startProcessing(materialIDs: [Int]) {
|
|
||||||
materialIDs.forEach { materialID in
|
|
||||||
var record = recordsByMaterialID[materialID]
|
|
||||||
?? Record(
|
|
||||||
state: TravelAlbumAIEditResultState(),
|
|
||||||
editedImage: nil,
|
|
||||||
atmosphereImage: nil,
|
|
||||||
coverImage: nil
|
|
||||||
)
|
|
||||||
record.state.startProcessing()
|
|
||||||
recordsByMaterialID[materialID] = record
|
|
||||||
notifyChange(materialID: materialID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 保存最新结果并覆盖同一照片之前的修图结果。
|
|
||||||
func complete(
|
|
||||||
materialID: Int,
|
|
||||||
presetID: String,
|
|
||||||
atmosphereOptionID: String? = nil,
|
|
||||||
coverTemplateID: String? = nil,
|
|
||||||
editedImage: UIImage,
|
|
||||||
atmosphereImage: UIImage? = nil,
|
|
||||||
coverImage: UIImage? = nil
|
|
||||||
) {
|
|
||||||
var record = recordsByMaterialID[materialID]
|
|
||||||
?? Record(
|
|
||||||
state: TravelAlbumAIEditResultState(),
|
|
||||||
editedImage: nil,
|
|
||||||
atmosphereImage: nil,
|
|
||||||
coverImage: nil
|
|
||||||
)
|
|
||||||
record.state.complete(
|
|
||||||
presetID: presetID,
|
|
||||||
atmosphereOptionID: atmosphereOptionID,
|
|
||||||
coverTemplateID: coverTemplateID
|
|
||||||
)
|
|
||||||
record.editedImage = editedImage
|
|
||||||
record.atmosphereImage = atmosphereImage
|
|
||||||
record.coverImage = coverImage
|
|
||||||
recordsByMaterialID[materialID] = record
|
|
||||||
notifyChange(materialID: materialID)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 修图失败时恢复按钮可用状态,并保留之前成功的结果。
|
|
||||||
func failProcessing(materialID: Int) {
|
|
||||||
guard var record = recordsByMaterialID[materialID] else { return }
|
|
||||||
record.state.failProcessing()
|
|
||||||
recordsByMaterialID[materialID] = record
|
|
||||||
notifyChange(materialID: materialID)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 还原为原图时清除该照片的本地修图结果。
|
|
||||||
func restoreOriginal(materialID: Int) {
|
|
||||||
guard var record = recordsByMaterialID[materialID] else { return }
|
|
||||||
record.state.restoreOriginal()
|
|
||||||
record.editedImage = nil
|
|
||||||
record.atmosphereImage = nil
|
|
||||||
record.coverImage = nil
|
|
||||||
recordsByMaterialID[materialID] = record
|
|
||||||
notifyChange(materialID: materialID)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 切换指定照片当前展示的原图或修图后版本。
|
|
||||||
func selectDisplayMode(_ mode: TravelAlbumAIEditResultState.DisplayMode, materialID: Int) {
|
|
||||||
guard var record = recordsByMaterialID[materialID] else { return }
|
|
||||||
record.state.selectDisplayMode(mode)
|
|
||||||
recordsByMaterialID[materialID] = record
|
|
||||||
notifyChange(materialID: materialID)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除照片时同步清理其本地修图结果。
|
|
||||||
func remove(materialID: Int) {
|
|
||||||
recordsByMaterialID.removeValue(forKey: materialID)
|
|
||||||
notifyChange(materialID: materialID)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func notifyChange(materialID: Int) {
|
|
||||||
NotificationCenter.default.post(
|
|
||||||
name: Self.didChangeNotification,
|
|
||||||
object: self,
|
|
||||||
userInfo: [Self.materialIDUserInfoKey: materialID]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 本地 AI 修图效果生成器;后续接入服务端时可替换为结果图片下载。
|
|
||||||
@MainActor
|
|
||||||
enum TravelAlbumAIEditImageProcessor {
|
|
||||||
private static let context = CIContext()
|
|
||||||
|
|
||||||
/// 根据预设生成演示用修图图片。
|
|
||||||
static func render(
|
|
||||||
effect: TravelAlbumEditPreset.Effect,
|
|
||||||
source: UIImage
|
|
||||||
) -> UIImage {
|
|
||||||
guard effect != .original, let ciImage = CIImage(image: source) else { return source }
|
|
||||||
let output: CIImage
|
|
||||||
switch effect {
|
|
||||||
case .original:
|
|
||||||
output = ciImage
|
|
||||||
case .portrait:
|
|
||||||
output = ciImage.applyingFilter("CIColorControls", parameters: [
|
|
||||||
kCIInputSaturationKey: 0.86,
|
|
||||||
kCIInputBrightnessKey: 0.07,
|
|
||||||
kCIInputContrastKey: 1.08,
|
|
||||||
])
|
|
||||||
case .vintage:
|
|
||||||
output = ciImage.applyingFilter("CISepiaTone", parameters: [kCIInputIntensityKey: 0.45])
|
|
||||||
case .brocade:
|
|
||||||
output = ciImage.applyingFilter("CIColorControls", parameters: [
|
|
||||||
kCIInputSaturationKey: 1.22,
|
|
||||||
kCIInputBrightnessKey: 0.04,
|
|
||||||
kCIInputContrastKey: 1.1,
|
|
||||||
])
|
|
||||||
case .distantMountain:
|
|
||||||
output = ciImage.applyingFilter("CIColorControls", parameters: [
|
|
||||||
kCIInputSaturationKey: 0.72,
|
|
||||||
kCIInputBrightnessKey: 0.1,
|
|
||||||
kCIInputContrastKey: 0.9,
|
|
||||||
])
|
|
||||||
case .mist:
|
|
||||||
output = ciImage.applyingFilter("CIColorControls", parameters: [
|
|
||||||
kCIInputSaturationKey: 0.7,
|
|
||||||
kCIInputBrightnessKey: 0.14,
|
|
||||||
kCIInputContrastKey: 0.82,
|
|
||||||
])
|
|
||||||
case .summer:
|
|
||||||
output = ciImage.applyingFilter("CISepiaTone", parameters: [kCIInputIntensityKey: 0.24])
|
|
||||||
case .rich:
|
|
||||||
output = ciImage.applyingFilter("CIColorControls", parameters: [
|
|
||||||
kCIInputSaturationKey: 1.38,
|
|
||||||
kCIInputBrightnessKey: -0.02,
|
|
||||||
kCIInputContrastKey: 1.2,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
guard let cgImage = context.createCGImage(output, from: output.extent) else { return source }
|
|
||||||
return UIImage(cgImage: cgImage, scale: source.scale, orientation: source.imageOrientation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 本地封面模板效果生成器,让封面结果在缩略图和预览页中与普通照片明显区分。
|
|
||||||
@MainActor
|
|
||||||
enum TravelAlbumCoverTemplateImageProcessor {
|
|
||||||
/// 将照片渲染为指定封面排版;无原图时使用稳定的演示背景。
|
|
||||||
static func render(
|
|
||||||
template: TravelAlbumCoverTemplate,
|
|
||||||
source: UIImage?,
|
|
||||||
targetSize: CGSize = CGSize(width: 1200, height: 900)
|
|
||||||
) -> UIImage {
|
|
||||||
UIGraphicsImageRenderer(size: targetSize).image { context in
|
|
||||||
let bounds = CGRect(origin: .zero, size: targetSize)
|
|
||||||
UIColor(hex: 0xCAD8E8).setFill()
|
|
||||||
context.cgContext.fill(bounds)
|
|
||||||
if let source {
|
|
||||||
source.draw(in: aspectFillRect(imageSize: source.size, bounds: bounds))
|
|
||||||
}
|
|
||||||
|
|
||||||
let unit = min(targetSize.width / 232, targetSize.height / 208)
|
|
||||||
switch template.previewStyle {
|
|
||||||
case .travelMemoir:
|
|
||||||
UIColor.black.withAlphaComponent(0.24).setFill()
|
|
||||||
context.cgContext.fill(bounds)
|
|
||||||
UIColor.white.setStroke()
|
|
||||||
let oval = UIBezierPath(ovalIn: CGRect(
|
|
||||||
x: bounds.midX - 50 * unit,
|
|
||||||
y: 28 * unit,
|
|
||||||
width: 100 * unit,
|
|
||||||
height: 100 * unit
|
|
||||||
))
|
|
||||||
oval.lineWidth = 9 * unit
|
|
||||||
oval.stroke()
|
|
||||||
drawCaption(
|
|
||||||
"TRAVEL\nMEMOIR",
|
|
||||||
in: CGRect(x: 30 * unit, y: bounds.height - 62 * unit, width: bounds.width - 60 * unit, height: 46 * unit),
|
|
||||||
alignment: .center,
|
|
||||||
fontSize: 17 * unit
|
|
||||||
)
|
|
||||||
case .minimal:
|
|
||||||
UIColor.white.withAlphaComponent(0.9).setFill()
|
|
||||||
context.cgContext.fill(CGRect(x: bounds.width * 0.55, y: 0, width: bounds.width * 0.45, height: bounds.height))
|
|
||||||
drawCaption(
|
|
||||||
"LESS\nIS MORE",
|
|
||||||
in: CGRect(x: bounds.width * 0.62, y: bounds.midY - 27 * unit, width: bounds.width * 0.3, height: 54 * unit),
|
|
||||||
alignment: .left,
|
|
||||||
color: .black,
|
|
||||||
fontSize: 17 * unit
|
|
||||||
)
|
|
||||||
case .scenicStory:
|
|
||||||
UIColor.black.withAlphaComponent(0.2).setFill()
|
|
||||||
context.cgContext.fill(bounds)
|
|
||||||
UIColor.white.setStroke()
|
|
||||||
let inset = 20 * unit
|
|
||||||
let frame = UIBezierPath(rect: bounds.insetBy(dx: inset, dy: inset))
|
|
||||||
frame.lineWidth = 4 * unit
|
|
||||||
frame.stroke()
|
|
||||||
drawCaption(
|
|
||||||
"SCENIC STORY",
|
|
||||||
in: CGRect(x: 34 * unit, y: bounds.height - 58 * unit, width: bounds.width - 68 * unit, height: 24 * unit),
|
|
||||||
alignment: .center,
|
|
||||||
fontSize: 17 * unit
|
|
||||||
)
|
|
||||||
case .film:
|
|
||||||
let stripHeight = 24 * unit
|
|
||||||
UIColor.black.withAlphaComponent(0.72).setFill()
|
|
||||||
context.cgContext.fill(CGRect(x: 0, y: 0, width: bounds.width, height: stripHeight))
|
|
||||||
context.cgContext.fill(CGRect(x: 0, y: bounds.height - stripHeight, width: bounds.width, height: stripHeight))
|
|
||||||
UIColor.white.withAlphaComponent(0.9).setFill()
|
|
||||||
var x = 10 * unit
|
|
||||||
while x <= bounds.width - 16 * unit {
|
|
||||||
context.cgContext.fill(CGRect(x: x, y: 6 * unit, width: 14 * unit, height: 10 * unit))
|
|
||||||
context.cgContext.fill(CGRect(
|
|
||||||
x: x,
|
|
||||||
y: bounds.height - 16 * unit,
|
|
||||||
width: 14 * unit,
|
|
||||||
height: 10 * unit
|
|
||||||
))
|
|
||||||
x += 30 * unit
|
|
||||||
}
|
|
||||||
drawCaption(
|
|
||||||
"THE JOURNEY",
|
|
||||||
in: CGRect(x: 28 * unit, y: bounds.height - 60 * unit, width: bounds.width - 56 * unit, height: 24 * unit),
|
|
||||||
alignment: .center,
|
|
||||||
fontSize: 17 * unit
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func aspectFillRect(imageSize: CGSize, bounds: CGRect) -> CGRect {
|
|
||||||
guard imageSize.width > 0, imageSize.height > 0 else { return bounds }
|
|
||||||
let scale = max(bounds.width / imageSize.width, bounds.height / imageSize.height)
|
|
||||||
let drawSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
|
|
||||||
return CGRect(
|
|
||||||
x: bounds.midX - drawSize.width / 2,
|
|
||||||
y: bounds.midY - drawSize.height / 2,
|
|
||||||
width: drawSize.width,
|
|
||||||
height: drawSize.height
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func drawCaption(
|
|
||||||
_ text: String,
|
|
||||||
in rect: CGRect,
|
|
||||||
alignment: NSTextAlignment,
|
|
||||||
color: UIColor = .white,
|
|
||||||
fontSize: CGFloat
|
|
||||||
) {
|
|
||||||
let paragraph = NSMutableParagraphStyle()
|
|
||||||
paragraph.alignment = alignment
|
|
||||||
text.draw(
|
|
||||||
in: rect,
|
|
||||||
withAttributes: [
|
|
||||||
.font: UIFont.systemFont(ofSize: fontSize, weight: .bold),
|
|
||||||
.foregroundColor: color,
|
|
||||||
.paragraphStyle: paragraph,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//
|
||||||
|
// BeforeAfterComparisonViewModel.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 前后对比分隔线的共享运动参数与计算规则。
|
||||||
|
enum BeforeAfterComparisonMotion {
|
||||||
|
static let automaticCenter: CGFloat = 0.5
|
||||||
|
static let automaticAmplitude: CGFloat = 0.3
|
||||||
|
static let automaticSpeed: Double = 0.8
|
||||||
|
static let manualRange: ClosedRange<CGFloat> = 0.05 ... 0.95
|
||||||
|
|
||||||
|
/// 返回指定时间点的自动往返位置,运动范围固定为 20% 至 80%。
|
||||||
|
static func automaticFraction(elapsed: TimeInterval) -> CGFloat {
|
||||||
|
automaticCenter + CGFloat(sin(elapsed * automaticSpeed)) * automaticAmplitude
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将手动拖动位置限制在两侧 5% 的安全边界内。
|
||||||
|
static func clampedManualFraction(_ fraction: CGFloat) -> CGFloat {
|
||||||
|
min(manualRange.upperBound, max(manualRange.lowerBound, fraction))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算从当前位置恢复正向自动运动所需的正弦相位时间。
|
||||||
|
static func phaseTime(for fraction: CGFloat) -> TimeInterval {
|
||||||
|
let normalized = Double(
|
||||||
|
min(1, max(-1, (fraction - automaticCenter) / automaticAmplitude))
|
||||||
|
)
|
||||||
|
return asin(normalized) / automaticSpeed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 前后对比图片在可用区域内完整展示时的等比布局计算。
|
||||||
|
enum BeforeAfterComparisonLayout {
|
||||||
|
static let fallbackAspectRatio: CGFloat = 3.0 / 4.0
|
||||||
|
|
||||||
|
/// 从图片尺寸提取有效宽高比,异常尺寸回退为 3:4。
|
||||||
|
static func aspectRatio(for imageSize: CGSize) -> CGFloat {
|
||||||
|
guard imageSize.width > 0, imageSize.height > 0 else { return fallbackAspectRatio }
|
||||||
|
let ratio = imageSize.width / imageSize.height
|
||||||
|
return ratio.isFinite && ratio > 0 ? ratio : fallbackAspectRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在最大宽高内返回不裁剪、不拉伸的最大显示尺寸。
|
||||||
|
static func fittedSize(aspectRatio: CGFloat, maximumSize: CGSize) -> CGSize {
|
||||||
|
guard maximumSize.width > 0, maximumSize.height > 0 else { return .zero }
|
||||||
|
let ratio = aspectRatio.isFinite && aspectRatio > 0 ? aspectRatio : fallbackAspectRatio
|
||||||
|
if maximumSize.width / maximumSize.height > ratio {
|
||||||
|
return CGSize(width: maximumSize.height * ratio, height: maximumSize.height)
|
||||||
|
}
|
||||||
|
return CGSize(width: maximumSize.width, height: maximumSize.width / ratio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 前后图片对比页的只读状态,负责校验并解析两张远程图片地址。
|
||||||
|
final class BeforeAfterComparisonViewModel {
|
||||||
|
let content: BeforeAfterComparisonContent
|
||||||
|
|
||||||
|
/// 创建指定内容的前后对比页状态。
|
||||||
|
init(content: BeforeAfterComparisonContent) {
|
||||||
|
self.content = content
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 原图远程地址。
|
||||||
|
var beforeImageURL: URL? {
|
||||||
|
URL(string: content.beforeURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 效果图远程地址。
|
||||||
|
var afterImageURL: URL? {
|
||||||
|
URL(string: content.afterURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 两张图片地址都可供页面加载时返回 true。
|
||||||
|
var isValid: Bool {
|
||||||
|
content.isValid && beforeImageURL != nil && afterImageURL != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// AI 修图任务列表状态,负责筛选、游标分页、去重和静默刷新。
|
||||||
|
final class TravelAlbumAIJobListViewModel {
|
||||||
|
private(set) var items: [TravelAlbumAIJobSummary] = []
|
||||||
|
private(set) var selectedFilter: TravelAlbumAIJobFilter = .all
|
||||||
|
private(set) var isLoading = false
|
||||||
|
private(set) var isRefreshing = false
|
||||||
|
private(set) var isLoadingMore = false
|
||||||
|
private(set) var errorMessage: String?
|
||||||
|
private var nextCursor: String?
|
||||||
|
private var hasMore = false
|
||||||
|
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
|
||||||
|
var containsInProgressJobs: Bool { items.contains { $0.status.isInProgress } }
|
||||||
|
var canLoadMore: Bool { hasMore && !isLoadingMore }
|
||||||
|
|
||||||
|
/// 选择筛选项并重新加载第一页。
|
||||||
|
func selectFilter(_ filter: TravelAlbumAIJobFilter, api: any TravelAlbumServing) async {
|
||||||
|
guard selectedFilter != filter else { return }
|
||||||
|
selectedFilter = filter
|
||||||
|
items = []
|
||||||
|
notify()
|
||||||
|
await loadFirstPage(api: api)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首次加载或下拉刷新第一页。
|
||||||
|
func loadFirstPage(api: any TravelAlbumServing, refreshing: Bool = false, silent: Bool = false) async {
|
||||||
|
guard !isLoading && !isRefreshing else { return }
|
||||||
|
if refreshing { isRefreshing = true } else if !silent { isLoading = true }
|
||||||
|
errorMessage = nil
|
||||||
|
notify()
|
||||||
|
defer {
|
||||||
|
isLoading = false
|
||||||
|
isRefreshing = false
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
await request(cursor: nil, reset: true, api: api, allowCursorRecovery: false, silent: silent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载下一页。
|
||||||
|
func loadMore(api: any TravelAlbumServing) async {
|
||||||
|
guard canLoadMore, let cursor = nextCursor else { return }
|
||||||
|
isLoadingMore = true
|
||||||
|
notify()
|
||||||
|
defer { isLoadingMore = false; notify() }
|
||||||
|
await request(cursor: cursor, reset: false, api: api, allowCursorRecovery: true, silent: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func request(
|
||||||
|
cursor: String?,
|
||||||
|
reset: Bool,
|
||||||
|
api: any TravelAlbumServing,
|
||||||
|
allowCursorRecovery: Bool,
|
||||||
|
silent: Bool
|
||||||
|
) async {
|
||||||
|
do {
|
||||||
|
let response = try await api.aiRetouchJobList(
|
||||||
|
statusGroup: selectedFilter,
|
||||||
|
limit: 20,
|
||||||
|
cursor: cursor
|
||||||
|
)
|
||||||
|
if reset {
|
||||||
|
items = response.items
|
||||||
|
} else {
|
||||||
|
var known = Set(items.map(\.id))
|
||||||
|
items += response.items.filter { known.insert($0.id).inserted }
|
||||||
|
}
|
||||||
|
nextCursor = response.nextCursor
|
||||||
|
hasMore = response.hasMore && response.nextCursor?.isEmpty == false
|
||||||
|
errorMessage = nil
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
let message = error.localizedDescription
|
||||||
|
if allowCursorRecovery && message.localizedCaseInsensitiveContains("cursor") {
|
||||||
|
nextCursor = nil
|
||||||
|
hasMore = false
|
||||||
|
await request(cursor: nil, reset: true, api: api, allowCursorRecovery: false, silent: silent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if items.isEmpty { errorMessage = message.isEmpty ? "任务加载失败" : message }
|
||||||
|
if !silent { onShowMessage?(message.isEmpty ? "任务加载失败" : message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notify() { onStateChange?() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务详情状态,负责刷新、404 识别和终态判断。
|
||||||
|
final class TravelAlbumAIJobDetailViewModel {
|
||||||
|
private(set) var detail: TravelAlbumAIJobDetail?
|
||||||
|
private(set) var isLoading = false
|
||||||
|
private(set) var isRefreshing = false
|
||||||
|
private(set) var errorMessage: String?
|
||||||
|
private(set) var isNotFound = false
|
||||||
|
|
||||||
|
let batchId: Int
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
var onNotFound: (() -> Void)?
|
||||||
|
|
||||||
|
init(batchId: Int) { self.batchId = batchId }
|
||||||
|
|
||||||
|
var shouldPoll: Bool { detail?.status.isInProgress == true }
|
||||||
|
|
||||||
|
/// 拉取任务详情;静默刷新失败时保留现有内容。
|
||||||
|
func load(api: any TravelAlbumServing, refreshing: Bool = false, silent: Bool = false) async {
|
||||||
|
guard batchId > 0, !isLoading && !isRefreshing else {
|
||||||
|
if batchId <= 0 { markNotFound() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if refreshing { isRefreshing = true } else if detail == nil && !silent { isLoading = true }
|
||||||
|
errorMessage = nil
|
||||||
|
notify()
|
||||||
|
defer {
|
||||||
|
isLoading = false
|
||||||
|
isRefreshing = false
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
detail = try await api.aiRetouchJobInfo(batchId: batchId)
|
||||||
|
isNotFound = false
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch APIError.httpStatus(let status, _) where status == 404 {
|
||||||
|
markNotFound()
|
||||||
|
} catch {
|
||||||
|
let message = error.localizedDescription.isEmpty ? "任务详情加载失败" : error.localizedDescription
|
||||||
|
if detail == nil { errorMessage = message }
|
||||||
|
if !silent { onShowMessage?(message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func markNotFound() {
|
||||||
|
guard !isNotFound else { return }
|
||||||
|
isNotFound = true
|
||||||
|
onNotFound?()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notify() { onStateChange?() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumAIRetouchTemplateViewModel.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// AI 修图模板选择状态,负责实时加载、按工作流单选、校验和任务提交。
|
||||||
|
final class TravelAlbumAIRetouchTemplateViewModel {
|
||||||
|
private(set) var refinedTemplates: [TravelAlbumAIRetouchTemplate] = []
|
||||||
|
private(set) var atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = []
|
||||||
|
private(set) var coverTemplates: [TravelAlbumAIRetouchTemplate] = []
|
||||||
|
private(set) var selectedRefinedTemplateId: Int?
|
||||||
|
private(set) var selectedAtmosphereTemplateId: Int?
|
||||||
|
private(set) var selectedCoverTemplateId: Int?
|
||||||
|
private(set) var remainingQuota: Int?
|
||||||
|
private(set) var isLoading = false
|
||||||
|
private(set) var isSubmitting = false
|
||||||
|
private(set) var loadErrorMessage: String?
|
||||||
|
|
||||||
|
let scenicId: Int
|
||||||
|
let workflow: TravelAlbumAIRetouchWorkflow
|
||||||
|
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
var onSubmitted: ((TravelAlbumAIJobSubmission) -> Void)?
|
||||||
|
|
||||||
|
/// 创建首次 AI 修图模板状态;素材 ID 会排序并去重,确保提交稳定。
|
||||||
|
convenience init(albumId: Int, scenicId: Int, materialIds: [Int]) {
|
||||||
|
self.init(
|
||||||
|
scenicId: scenicId,
|
||||||
|
workflow: .initial(albumId: albumId, materialIds: materialIds)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用明确工作流创建模板选择状态。
|
||||||
|
init(scenicId: Int, workflow: TravelAlbumAIRetouchWorkflow) {
|
||||||
|
self.scenicId = scenicId
|
||||||
|
switch workflow {
|
||||||
|
case .initial(let albumId, let materialIds):
|
||||||
|
self.workflow = .initial(
|
||||||
|
albumId: albumId,
|
||||||
|
materialIds: Array(Set(materialIds)).sorted()
|
||||||
|
)
|
||||||
|
case .reretouch:
|
||||||
|
self.workflow = workflow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前工作流需要展示的模板分组。
|
||||||
|
var visibleCategories: [TravelAlbumAIRetouchTemplateCategory] {
|
||||||
|
workflow.visibleCategories
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前工作流选择的原始照片数量。
|
||||||
|
var selectedPhotoCount: Int {
|
||||||
|
switch workflow {
|
||||||
|
case .initial(_, let materialIds): materialIds.count
|
||||||
|
case .reretouch: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否展示首次修图的氛围感与封面生成说明。
|
||||||
|
var shouldShowInitialTips: Bool {
|
||||||
|
if case .initial = workflow { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首次修图页按照当前选图数量展示氛围感与封面生成规则。
|
||||||
|
var initialTipsText: String {
|
||||||
|
if visibleCategories.contains(.cover) {
|
||||||
|
return "Tips:氛围感修图为选填;封面风格为必选,将使用第一张照片另生成封面。"
|
||||||
|
}
|
||||||
|
return "Tips:氛围感修图为选填,选中后每张照片会额外生成1个独立结果。"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前分组是否为选填。
|
||||||
|
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
|
||||||
|
workflow.isOptional(category)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前选择预计消耗的修图次数;封面模板免费,不计入额度。
|
||||||
|
var requiredQuota: Int {
|
||||||
|
switch workflow {
|
||||||
|
case .initial(_, let materialIds):
|
||||||
|
let outputCount = 1 + (selectedAtmosphereTemplateId == nil ? 0 : 1)
|
||||||
|
return materialIds.count * outputCount
|
||||||
|
case .reretouch(_, _, let type):
|
||||||
|
switch type {
|
||||||
|
case .refined, .atmosphere:
|
||||||
|
return 1
|
||||||
|
case .all:
|
||||||
|
return (selectedRefinedTemplateId == nil ? 0 : 1)
|
||||||
|
+ (selectedAtmosphereTemplateId == nil ? 0 : 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前必选模板或业务参数缺失时用于底部提示的文案。
|
||||||
|
var validationMessage: String? {
|
||||||
|
guard !isLoading, loadErrorMessage == nil else { return nil }
|
||||||
|
guard workflow.isValid else {
|
||||||
|
if case .reretouch = workflow {
|
||||||
|
return "当前图片缺少修图批次,请刷新后重试"
|
||||||
|
}
|
||||||
|
return "请选择要修图的照片"
|
||||||
|
}
|
||||||
|
for category in visibleCategories where !isOptional(category) {
|
||||||
|
if templates(for: category).isEmpty || selectedTemplateId(for: category) == nil {
|
||||||
|
return unavailableMessage(for: category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if case .reretouch(_, _, .all) = workflow,
|
||||||
|
selectedRefinedTemplateId == nil,
|
||||||
|
selectedAtmosphereTemplateId == nil {
|
||||||
|
return "请至少选择一个修图模板"
|
||||||
|
}
|
||||||
|
guard let remainingQuota else {
|
||||||
|
return "剩余修图次数获取失败,请刷新后重试"
|
||||||
|
}
|
||||||
|
if requiredQuota > remainingQuota {
|
||||||
|
return "剩余修图次数不足,需要\(requiredQuota)次,当前剩余\(remainingQuota)次"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否满足提交条件。
|
||||||
|
var canSubmit: Bool {
|
||||||
|
!isLoading
|
||||||
|
&& !isSubmitting
|
||||||
|
&& loadErrorMessage == nil
|
||||||
|
&& workflow.isValid
|
||||||
|
&& validationMessage == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 实时拉取当前景区模板并按工作流设置默认选择。
|
||||||
|
func loadTemplates(api: any TravelAlbumServing) async {
|
||||||
|
guard scenicId > 0 else {
|
||||||
|
loadErrorMessage = "请先选择景区"
|
||||||
|
notifyStateChange()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !isLoading else { return }
|
||||||
|
isLoading = true
|
||||||
|
loadErrorMessage = nil
|
||||||
|
remainingQuota = nil
|
||||||
|
notifyStateChange()
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await api.aiRetouchTemplates(scenicId: scenicId)
|
||||||
|
refinedTemplates = response.refinedTemplates
|
||||||
|
atmosphereTemplates = response.atmosphereTemplates
|
||||||
|
coverTemplates = response.coverTemplates
|
||||||
|
remainingQuota = response.remainingQuota
|
||||||
|
selectedRefinedTemplateId = visibleCategories.contains(.refined) && !isOptional(.refined)
|
||||||
|
? refinedTemplates.first?.id
|
||||||
|
: nil
|
||||||
|
selectedAtmosphereTemplateId = visibleCategories.contains(.atmosphere) && !isOptional(.atmosphere)
|
||||||
|
? atmosphereTemplates.first?.id
|
||||||
|
: nil
|
||||||
|
selectedCoverTemplateId = visibleCategories.contains(.cover) ? coverTemplates.first?.id : nil
|
||||||
|
isLoading = false
|
||||||
|
notifyStateChange()
|
||||||
|
} catch is CancellationError {
|
||||||
|
isLoading = false
|
||||||
|
notifyStateChange()
|
||||||
|
} catch {
|
||||||
|
refinedTemplates = []
|
||||||
|
atmosphereTemplates = []
|
||||||
|
coverTemplates = []
|
||||||
|
selectedRefinedTemplateId = nil
|
||||||
|
selectedAtmosphereTemplateId = nil
|
||||||
|
selectedCoverTemplateId = nil
|
||||||
|
remainingQuota = nil
|
||||||
|
isLoading = false
|
||||||
|
loadErrorMessage = error.localizedDescription.isEmpty ? "模板加载失败" : error.localizedDescription
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回指定分组的模板。
|
||||||
|
func templates(for category: TravelAlbumAIRetouchTemplateCategory) -> [TravelAlbumAIRetouchTemplate] {
|
||||||
|
switch category {
|
||||||
|
case .refined: refinedTemplates
|
||||||
|
case .atmosphere: atmosphereTemplates
|
||||||
|
case .cover: coverTemplates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回指定分组当前选中的模板 ID。
|
||||||
|
func selectedTemplateId(for category: TravelAlbumAIRetouchTemplateCategory) -> Int? {
|
||||||
|
switch category {
|
||||||
|
case .refined: selectedRefinedTemplateId
|
||||||
|
case .atmosphere: selectedAtmosphereTemplateId
|
||||||
|
case .cover: selectedCoverTemplateId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选择模板;选填分组允许再次点击取消,必选分组保持单选。
|
||||||
|
func toggleTemplate(id: Int, category: TravelAlbumAIRetouchTemplateCategory) {
|
||||||
|
guard visibleCategories.contains(category),
|
||||||
|
templates(for: category).contains(where: { $0.id == id })
|
||||||
|
else { return }
|
||||||
|
switch category {
|
||||||
|
case .refined:
|
||||||
|
selectedRefinedTemplateId = isOptional(category) && selectedRefinedTemplateId == id ? nil : id
|
||||||
|
case .atmosphere:
|
||||||
|
selectedAtmosphereTemplateId = isOptional(category) && selectedAtmosphereTemplateId == id ? nil : id
|
||||||
|
case .cover:
|
||||||
|
selectedCoverTemplateId = id
|
||||||
|
}
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按当前工作流提交首次修图或覆盖重修任务。
|
||||||
|
func submit(api: any TravelAlbumServing) async {
|
||||||
|
guard !isSubmitting else { return }
|
||||||
|
guard canSubmit else {
|
||||||
|
onShowMessage?(validationMessage ?? "当前无法提交AI修图")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting = true
|
||||||
|
notifyStateChange()
|
||||||
|
defer {
|
||||||
|
isSubmitting = false
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let submission: TravelAlbumAIJobSubmission
|
||||||
|
switch workflow {
|
||||||
|
case .initial(let albumId, let materialIds):
|
||||||
|
guard let refinedTemplateId = selectedRefinedTemplateId else {
|
||||||
|
onShowMessage?("请选择原图精修模板")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submission = try await api.submitAIRetouch(
|
||||||
|
TravelAlbumAIRetouchRequest(
|
||||||
|
userEquityTravelId: albumId,
|
||||||
|
materialIds: materialIds,
|
||||||
|
refinedTemplateId: refinedTemplateId,
|
||||||
|
atmosphereTemplateId: selectedAtmosphereTemplateId,
|
||||||
|
coverTemplateId: selectedCoverTemplateId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
case .reretouch(let materialId, let batchId, let type):
|
||||||
|
submission = try await api.submitAIReretouch(
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: materialId,
|
||||||
|
aiRetouchBatchId: batchId,
|
||||||
|
type: type,
|
||||||
|
refinedTemplateId: type == .atmosphere ? nil : selectedRefinedTemplateId,
|
||||||
|
atmosphereTemplateId: type == .refined ? nil : selectedAtmosphereTemplateId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onSubmitted?(submission)
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription.isEmpty ? "AI修图任务提交失败" : error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func unavailableMessage(for category: TravelAlbumAIRetouchTemplateCategory) -> String {
|
||||||
|
switch category {
|
||||||
|
case .refined: "暂无可用的原图精修模板"
|
||||||
|
case .atmosphere: "暂无可用的氛围感修图模板"
|
||||||
|
case .cover: "暂无可用的封面风格模板"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyStateChange() {
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,18 +27,28 @@ final class TravelAlbumDetailViewModel {
|
|||||||
case .fileNameDesc: "文件名倒序"
|
case .fileNameDesc: "文件名倒序"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var compactTitle: String {
|
||||||
|
switch self {
|
||||||
|
case .createdAsc: "时间 ↑"
|
||||||
|
case .createdDesc: "时间 ↓"
|
||||||
|
case .fileNameAsc: "名称 ↑"
|
||||||
|
case .fileNameDesc: "名称 ↓"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private(set) var album: TravelAlbum?
|
private(set) var album: TravelAlbum?
|
||||||
private(set) var materials: [TravelAlbumMaterial] = []
|
private(set) var materials: [TravelAlbumMaterial] = []
|
||||||
private(set) var allPhotoCount = 0
|
private(set) var allPhotoCount = 0
|
||||||
|
private(set) var purchasedPhotoCount = 0
|
||||||
private(set) var selectedTab: Tab = .all
|
private(set) var selectedTab: Tab = .all
|
||||||
private(set) var sortOption: SortOption = .createdDesc
|
private(set) var sortOption: SortOption = .createdDesc
|
||||||
private(set) var isLoading = true
|
private(set) var isLoading = true
|
||||||
private(set) var isRefreshing = false
|
private(set) var isRefreshing = false
|
||||||
private(set) var isUsingPurchasedDemoData = 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)?
|
||||||
@@ -66,6 +76,7 @@ final class TravelAlbumDetailViewModel {
|
|||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
await loadAlbumInfo(api: api)
|
await loadAlbumInfo(api: api)
|
||||||
await loadAllPhotoCount(api: api)
|
await loadAllPhotoCount(api: api)
|
||||||
|
await loadPurchasedPhotoCount(api: api)
|
||||||
await loadMaterials(reset: true, api: api)
|
await loadMaterials(reset: true, api: api)
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
isLoading = false
|
isLoading = false
|
||||||
@@ -103,12 +114,58 @@ final class TravelAlbumDetailViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 拉取已购照片数量;失败时保留已有数量,不影响素材列表加载。
|
||||||
|
func loadPurchasedPhotoCount(api: any TravelAlbumServing) async {
|
||||||
|
do {
|
||||||
|
let response = try await api.materialList(
|
||||||
|
userEquityTravelId: albumId,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
orderBy: sortOption.rawValue,
|
||||||
|
isPurchased: 1
|
||||||
|
)
|
||||||
|
purchasedPhotoCount = response.total
|
||||||
|
notifyStateChange()
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 摘要卡封面地址,依次回退相册封面、首张素材封面和首张素材原图。
|
||||||
|
var displayCoverURL: String {
|
||||||
|
TravelAlbumDisplayFormatter.albumCoverURL(album: album, materials: materials)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前 Tab 对应的原图项目总数,供全屏预览索引使用。
|
||||||
|
var currentPhotoCount: Int {
|
||||||
|
selectedTab == .all ? allPhotoCount : purchasedPhotoCount
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前选中素材中的已购买数量。
|
||||||
|
var selectedPurchasedMaterialCount: Int {
|
||||||
|
selectedMaterialIds.count - selectedDeletableMaterialIds.count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除确认弹窗文案;选中已购素材时明确说明保护规则与实际删除数量。
|
||||||
|
var deleteSelectedConfirmationMessage: String {
|
||||||
|
let selectedCount = selectedMaterialIds.count
|
||||||
|
let purchasedCount = selectedPurchasedMaterialCount
|
||||||
|
guard purchasedCount > 0 else {
|
||||||
|
return "确定删除选中的 \(selectedCount) 张素材吗?"
|
||||||
|
}
|
||||||
|
let deletableCount = selectedCount - purchasedCount
|
||||||
|
return "已选择 \(selectedCount) 张素材,其中 \(purchasedCount) 张已购买。"
|
||||||
|
+ "不会删除已购买的项目,只会删除 \(deletableCount) 张未购买的项目。"
|
||||||
|
}
|
||||||
|
|
||||||
/// 切换素材 tab。
|
/// 切换素材 tab。
|
||||||
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
|
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
|
||||||
guard selectedTab != tab else { return }
|
guard selectedTab != tab else { return }
|
||||||
selectedTab = tab
|
selectedTab = tab
|
||||||
isSelectionMode = false
|
isSelectionMode = false
|
||||||
selectedMaterialIds = []
|
clearMaterialSelection()
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
await loadMaterials(reset: true, api: api)
|
await loadMaterials(reset: true, api: api)
|
||||||
}
|
}
|
||||||
@@ -127,7 +184,6 @@ final class TravelAlbumDetailViewModel {
|
|||||||
if reset {
|
if reset {
|
||||||
currentPage = 1
|
currentPage = 1
|
||||||
canLoadMore = false
|
canLoadMore = false
|
||||||
isUsingPurchasedDemoData = false
|
|
||||||
} else {
|
} else {
|
||||||
guard canLoadMore, !isLoadingMore else { return }
|
guard canLoadMore, !isLoadingMore else { return }
|
||||||
isLoadingMore = true
|
isLoadingMore = true
|
||||||
@@ -142,17 +198,12 @@ final class TravelAlbumDetailViewModel {
|
|||||||
orderBy: sortOption.rawValue,
|
orderBy: sortOption.rawValue,
|
||||||
isPurchased: selectedTab == .purchased ? 1 : nil
|
isPurchased: selectedTab == .purchased ? 1 : nil
|
||||||
)
|
)
|
||||||
if reset,
|
|
||||||
selectedTab == .purchased,
|
|
||||||
response.total == 0,
|
|
||||||
response.list.isEmpty {
|
|
||||||
applyPurchasedDemoMaterials()
|
|
||||||
} else {
|
|
||||||
materials = reset ? response.list : materials + response.list
|
materials = reset ? response.list : materials + response.list
|
||||||
canLoadMore = materials.count < response.total
|
canLoadMore = materials.count < response.total
|
||||||
}
|
|
||||||
if selectedTab == .all {
|
if selectedTab == .all {
|
||||||
allPhotoCount = response.total
|
allPhotoCount = response.total
|
||||||
|
} else {
|
||||||
|
purchasedPhotoCount = response.total
|
||||||
}
|
}
|
||||||
isLoadingMore = false
|
isLoadingMore = false
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
@@ -163,12 +214,6 @@ final class TravelAlbumDetailViewModel {
|
|||||||
currentPage -= 1
|
currentPage -= 1
|
||||||
}
|
}
|
||||||
isLoadingMore = false
|
isLoadingMore = false
|
||||||
if reset, selectedTab == .purchased {
|
|
||||||
// 已购接口暂不可用时继续提供稳定的本地演示数据,保证产品流程可演示。
|
|
||||||
applyPurchasedDemoMaterials()
|
|
||||||
notifyStateChange()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onShowMessage?(error.localizedDescription)
|
onShowMessage?(error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,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()
|
||||||
}
|
}
|
||||||
@@ -193,32 +251,58 @@ final class TravelAlbumDetailViewModel {
|
|||||||
/// 切换素材选中状态。
|
/// 切换素材选中状态。
|
||||||
func toggleMaterialSelection(_ material: TravelAlbumMaterial) {
|
func toggleMaterialSelection(_ material: TravelAlbumMaterial) {
|
||||||
guard isSelectionMode else { return }
|
guard isSelectionMode else { return }
|
||||||
guard material.status == 1 else {
|
|
||||||
onShowMessage?("仅未购买素材可删除")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if selectedMaterialIds.contains(material.id) {
|
if selectedMaterialIds.contains(material.id) {
|
||||||
selectedMaterialIds.remove(material.id)
|
selectedMaterialIds.remove(material.id)
|
||||||
|
selectedMaterialPurchaseStates.removeValue(forKey: material.id)
|
||||||
} else {
|
} else {
|
||||||
selectedMaterialIds.insert(material.id)
|
selectedMaterialIds.insert(material.id)
|
||||||
|
selectedMaterialPurchaseStates[material.id] = material.isPurchased
|
||||||
|
}
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务提交成功后退出选择模式并清空当前选择。
|
||||||
|
func completeAIRetouchSubmission() {
|
||||||
|
isSelectionMode = false
|
||||||
|
clearMaterialSelection()
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图提交成功后重置选择状态,并刷新相册摘要、数量与当前素材列表。
|
||||||
|
func refreshAfterAIRetouchSubmission(api: any TravelAlbumServing) async {
|
||||||
|
completeAIRetouchSubmission()
|
||||||
|
await refreshAll(api: api)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览页删除成功后移除对应素材,并同步全部/已购计数。
|
||||||
|
func removeMaterialAfterPreviewDeletion(id: Int) {
|
||||||
|
guard let index = materials.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
let material = materials.remove(at: index)
|
||||||
|
selectedMaterialIds.remove(id)
|
||||||
|
selectedMaterialPurchaseStates.removeValue(forKey: id)
|
||||||
|
allPhotoCount = max(0, allPhotoCount - 1)
|
||||||
|
if material.isPurchased {
|
||||||
|
purchasedPhotoCount = max(0, purchasedPhotoCount - 1)
|
||||||
}
|
}
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除已选素材。
|
/// 删除已选素材。
|
||||||
func deleteSelectedMaterials(api: any TravelAlbumServing) async {
|
func deleteSelectedMaterials(api: any TravelAlbumServing) async {
|
||||||
let ids = Array(selectedMaterialIds)
|
guard !selectedMaterialIds.isEmpty else {
|
||||||
guard !ids.isEmpty else {
|
|
||||||
onShowMessage?("请选择要删除的素材")
|
onShowMessage?("请选择要删除的素材")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
do {
|
let ids = selectedDeletableMaterialIds
|
||||||
for id in ids {
|
guard !ids.isEmpty else {
|
||||||
try await api.deleteMaterial(id: id)
|
onShowMessage?("选中的素材均已购买,未删除任何项目")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
do {
|
||||||
|
try await api.batchDeleteMaterials(ids: ids)
|
||||||
onShowMessage?("删除成功")
|
onShowMessage?("删除成功")
|
||||||
isSelectionMode = false
|
isSelectionMode = false
|
||||||
selectedMaterialIds = []
|
clearMaterialSelection()
|
||||||
await refreshAll(api: api)
|
await refreshAll(api: api)
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
return
|
||||||
@@ -240,45 +324,21 @@ final class TravelAlbumDetailViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 已购照片接口无数据时生成稳定的本地素材;真实接口一旦返回数据即不再使用。
|
|
||||||
private func applyPurchasedDemoMaterials() {
|
|
||||||
let demoItems: [(fileName: String, fileSize: Int, assetName: String, createdAt: String)] = [
|
|
||||||
("IMG_0724_0001.jpeg", 2_684_928, "purchased_alpine_lake", "2026-07-24 11:25:18"),
|
|
||||||
("IMG_0724_0002.jpeg", 3_215_360, "purchased_forest_waterfall", "2026-07-24 11:27:46"),
|
|
||||||
("IMG_0724_0003.jpeg", 2_936_832, "purchased_grassland_path", "2026-07-24 11:31:09"),
|
|
||||||
("IMG_0724_0004.jpeg", 3_481_600, "purchased_lakeside_flowers", "2026-07-24 11:34:22"),
|
|
||||||
]
|
|
||||||
var materials = demoItems.enumerated().map { index, item in
|
|
||||||
TravelAlbumMaterial(
|
|
||||||
id: -((albumId * 10) + index + 1),
|
|
||||||
userEquityTravelId: albumId,
|
|
||||||
status: 2,
|
|
||||||
fileName: item.fileName,
|
|
||||||
fileType: 1,
|
|
||||||
fileUrl: TravelAlbumMaterial.bundledImageURL(assetName: item.assetName),
|
|
||||||
fileSize: item.fileSize,
|
|
||||||
coverUrl: TravelAlbumMaterial.bundledImageURL(assetName: item.assetName),
|
|
||||||
isPurchased: true,
|
|
||||||
createdAt: item.createdAt,
|
|
||||||
updatedAt: item.createdAt
|
|
||||||
)
|
|
||||||
}
|
|
||||||
switch sortOption {
|
|
||||||
case .createdAsc:
|
|
||||||
materials.sort { $0.createdAt < $1.createdAt }
|
|
||||||
case .createdDesc:
|
|
||||||
materials.sort { $0.createdAt > $1.createdAt }
|
|
||||||
case .fileNameAsc:
|
|
||||||
materials.sort { $0.fileName.localizedStandardCompare($1.fileName) == .orderedAscending }
|
|
||||||
case .fileNameDesc:
|
|
||||||
materials.sort { $0.fileName.localizedStandardCompare($1.fileName) == .orderedDescending }
|
|
||||||
}
|
|
||||||
self.materials = materials
|
|
||||||
canLoadMore = false
|
|
||||||
isUsingPurchasedDemoData = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func notifyStateChange() {
|
private func notifyStateChange() {
|
||||||
onStateChange?()
|
onStateChange?()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if !preservingContentOnFailure {
|
||||||
albums = []
|
albums = []
|
||||||
albumTotal = 0
|
albumTotal = 0
|
||||||
|
}
|
||||||
onShowMessage?(error.localizedDescription)
|
onShowMessage?(error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 用户下拉刷新相册列表;失败时保留当前内容,避免页面瞬间清空。
|
||||||
|
func refreshAlbums(api: any TravelAlbumServing) async {
|
||||||
|
guard !isLoading else { return }
|
||||||
|
await loadAlbums(api: api, preservingContentOnFailure: true)
|
||||||
|
}
|
||||||
|
|
||||||
/// 打开创建相册弹窗。
|
/// 打开创建相册弹窗。
|
||||||
func openCreateSheet(api: any TravelAlbumServing) async {
|
func openCreateSheet(api: any TravelAlbumServing) async {
|
||||||
guard !isCreating else { return }
|
guard !isCreating else { return }
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumPreviewActionHandler.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 图片预览页业务操作实现,负责将底部操作转发到旅拍相册服务。
|
||||||
|
final class TravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
|
||||||
|
private let api: any TravelAlbumServing
|
||||||
|
|
||||||
|
/// 使用旅拍相册服务创建操作处理器。
|
||||||
|
init(api: any TravelAlbumServing) {
|
||||||
|
self.api = api
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 调用素材删除接口,仅在服务端确认成功后返回成功结果。
|
||||||
|
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
||||||
|
do {
|
||||||
|
try await api.batchDeleteMaterials(ids: [originalMaterialId])
|
||||||
|
return .success("删除成功")
|
||||||
|
} catch is CancellationError {
|
||||||
|
return .failure("删除已取消")
|
||||||
|
} catch {
|
||||||
|
let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return .failure(message.isEmpty ? "删除失败" : message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,7 +94,6 @@ final class WiredCameraTransferViewModel {
|
|||||||
albumTitle: String,
|
albumTitle: String,
|
||||||
headerPhone: String,
|
headerPhone: String,
|
||||||
scenicSpotLabel: String? = nil,
|
scenicSpotLabel: String? = nil,
|
||||||
initialTransferMode: TravelAlbumOTGTransferMode? = nil,
|
|
||||||
connectionManager: (any WiredCameraConnectionManaging)? = nil,
|
connectionManager: (any WiredCameraConnectionManaging)? = nil,
|
||||||
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
|
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
|
||||||
uploader: (any TravelAlbumOTGUploading)? = nil,
|
uploader: (any TravelAlbumOTGUploading)? = nil,
|
||||||
@@ -113,11 +112,7 @@ final class WiredCameraTransferViewModel {
|
|||||||
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
||||||
self.appStore = appStore
|
self.appStore = appStore
|
||||||
self.userDefaults = userDefaults
|
self.userDefaults = userDefaults
|
||||||
// 从相册管理选择模式进入时,以本次选择为准;其他旧入口继续沿用上次记录。
|
self.transferMode = Self.persistedTransferMode(in: userDefaults)
|
||||||
self.transferMode = initialTransferMode ?? Self.persistedTransferMode(in: userDefaults)
|
|
||||||
if let initialTransferMode {
|
|
||||||
userDefaults.set(initialTransferMode.rawValue, forKey: Self.transferModeDefaultsKey)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 相机状态文案。
|
/// 相机状态文案。
|
||||||
|
|||||||
@@ -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)
|
||||||
|
if !viewModel.showsAIRetouchTaskAction {
|
||||||
make.bottom.lessThanOrEqualToSuperview().inset(24)
|
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?() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,938 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumAIRetouchTemplateViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Kingfisher
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// AI 修图模板选择 Sheet,按工作流展示所需横向模板列表与固定底部操作。
|
||||||
|
final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||||
|
/// 页面使用的 collection section。
|
||||||
|
private enum Section: Hashable {
|
||||||
|
case overview
|
||||||
|
case templates(TravelAlbumAIRetouchTemplateCategory)
|
||||||
|
case mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 页面使用的 diffable item。
|
||||||
|
private enum Item: Hashable {
|
||||||
|
case overview
|
||||||
|
case template(TravelAlbumAIRetouchTemplateCategory, TravelAlbumAIRetouchTemplate)
|
||||||
|
case mode
|
||||||
|
}
|
||||||
|
|
||||||
|
private let viewModel: TravelAlbumAIRetouchTemplateViewModel
|
||||||
|
private let api: any TravelAlbumServing
|
||||||
|
private let onSubmitted: (TravelAlbumAIJobSubmission) -> Void
|
||||||
|
|
||||||
|
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
||||||
|
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
|
private let statusContainer = UIView()
|
||||||
|
private let statusIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
|
private let statusLabel = UILabel()
|
||||||
|
private let retryButton = UIButton(type: .system)
|
||||||
|
private let bottomBar = UIView()
|
||||||
|
private let bottomDivider = UIView()
|
||||||
|
private let footerStack = UIStackView()
|
||||||
|
private let validationLabel = UILabel()
|
||||||
|
private let actionStack = UIStackView()
|
||||||
|
private let cancelButton = UIButton(type: .system)
|
||||||
|
private let confirmButton = AIRetouchGradientButton(type: .system)
|
||||||
|
|
||||||
|
/// 创建 AI 修图模板选择 Sheet。
|
||||||
|
init(
|
||||||
|
viewModel: TravelAlbumAIRetouchTemplateViewModel,
|
||||||
|
api: any TravelAlbumServing,
|
||||||
|
onSubmitted: @escaping (TravelAlbumAIJobSubmission) -> Void
|
||||||
|
) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
self.api = api
|
||||||
|
self.onSubmitted = onSubmitted
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
modalPresentationStyle = .pageSheet
|
||||||
|
configureSheetPresentation()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = AIRetouchTemplateStyle.pageBackground
|
||||||
|
view.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateSheet"
|
||||||
|
|
||||||
|
collectionView.backgroundColor = .clear
|
||||||
|
collectionView.showsVerticalScrollIndicator = false
|
||||||
|
collectionView.alwaysBounceVertical = true
|
||||||
|
collectionView.delegate = self
|
||||||
|
collectionView.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateCollection"
|
||||||
|
collectionView.register(
|
||||||
|
TravelAlbumAIRetouchTemplateCell.self,
|
||||||
|
forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier
|
||||||
|
)
|
||||||
|
collectionView.register(
|
||||||
|
TravelAlbumAIRetouchOverviewCell.self,
|
||||||
|
forCellWithReuseIdentifier: TravelAlbumAIRetouchOverviewCell.reuseIdentifier
|
||||||
|
)
|
||||||
|
collectionView.register(
|
||||||
|
TravelAlbumAIRetouchModeCell.self,
|
||||||
|
forCellWithReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier
|
||||||
|
)
|
||||||
|
collectionView.register(
|
||||||
|
TravelAlbumAIRetouchSectionHeader.self,
|
||||||
|
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
|
||||||
|
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier
|
||||||
|
)
|
||||||
|
configureDataSource()
|
||||||
|
|
||||||
|
statusLabel.textColor = AIRetouchTemplateStyle.textSecondary
|
||||||
|
statusLabel.font = .systemFont(ofSize: 14)
|
||||||
|
statusLabel.textAlignment = .center
|
||||||
|
statusLabel.numberOfLines = 0
|
||||||
|
retryButton.setTitle("重试", for: .normal)
|
||||||
|
retryButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||||
|
retryButton.accessibilityIdentifier = "travelAlbum.aiRetouchRetryButton"
|
||||||
|
|
||||||
|
bottomBar.backgroundColor = .white
|
||||||
|
bottomBar.accessibilityIdentifier = "travelAlbum.aiRetouchBottomBar"
|
||||||
|
bottomDivider.backgroundColor = AIRetouchTemplateStyle.border
|
||||||
|
validationLabel.textColor = AIRetouchTemplateStyle.danger
|
||||||
|
validationLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
|
validationLabel.textAlignment = .center
|
||||||
|
validationLabel.numberOfLines = 0
|
||||||
|
validationLabel.accessibilityIdentifier = "travelAlbum.aiRetouchValidationLabel"
|
||||||
|
|
||||||
|
footerStack.axis = .vertical
|
||||||
|
footerStack.spacing = 8
|
||||||
|
footerStack.alignment = .fill
|
||||||
|
actionStack.axis = .horizontal
|
||||||
|
actionStack.spacing = 12
|
||||||
|
actionStack.distribution = .fill
|
||||||
|
actionStack.alignment = .fill
|
||||||
|
configureCancelButton()
|
||||||
|
configureConfirmButton()
|
||||||
|
actionStack.addArrangedSubview(cancelButton)
|
||||||
|
actionStack.addArrangedSubview(confirmButton)
|
||||||
|
footerStack.addArrangedSubview(validationLabel)
|
||||||
|
footerStack.addArrangedSubview(actionStack)
|
||||||
|
|
||||||
|
view.addSubview(collectionView)
|
||||||
|
view.addSubview(statusContainer)
|
||||||
|
statusContainer.addSubview(statusIndicator)
|
||||||
|
statusContainer.addSubview(statusLabel)
|
||||||
|
statusContainer.addSubview(retryButton)
|
||||||
|
view.addSubview(bottomBar)
|
||||||
|
bottomBar.addSubview(bottomDivider)
|
||||||
|
bottomBar.addSubview(footerStack)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
bottomBar.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
bottomDivider.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview()
|
||||||
|
make.height.equalTo(0.5)
|
||||||
|
}
|
||||||
|
footerStack.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(10)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(18)
|
||||||
|
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||||
|
}
|
||||||
|
actionStack.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(56)
|
||||||
|
}
|
||||||
|
cancelButton.snp.makeConstraints { make in
|
||||||
|
make.width.equalTo(confirmButton.snp.width).multipliedBy(0.78)
|
||||||
|
}
|
||||||
|
collectionView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
make.bottom.equalTo(bottomBar.snp.top)
|
||||||
|
}
|
||||||
|
statusContainer.snp.makeConstraints { make in
|
||||||
|
make.edges.equalTo(collectionView)
|
||||||
|
}
|
||||||
|
statusIndicator.snp.makeConstraints { make in
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.centerY.equalToSuperview().offset(-32)
|
||||||
|
}
|
||||||
|
statusLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(statusIndicator.snp.bottom).offset(14)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(40)
|
||||||
|
}
|
||||||
|
retryButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(statusLabel.snp.bottom).offset(12)
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.height.equalTo(36)
|
||||||
|
make.bottom.lessThanOrEqualToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bindActions() {
|
||||||
|
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||||
|
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||||
|
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
|
||||||
|
viewModel.onStateChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.applyViewModel() }
|
||||||
|
}
|
||||||
|
viewModel.onShowMessage = { [weak self] message in
|
||||||
|
Task { @MainActor in self?.showToast(message) }
|
||||||
|
}
|
||||||
|
viewModel.onSubmitted = { [weak self] submission in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self else { return }
|
||||||
|
self.dismiss(animated: true) { self.onSubmitted(submission) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
applyViewModel()
|
||||||
|
Task { await viewModel.loadTemplates(api: api) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureSheetPresentation() {
|
||||||
|
guard let sheet = sheetPresentationController else { return }
|
||||||
|
sheet.detents = [.large()]
|
||||||
|
sheet.selectedDetentIdentifier = .large
|
||||||
|
sheet.prefersGrabberVisible = true
|
||||||
|
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureCancelButton() {
|
||||||
|
cancelButton.setTitle("取消", for: .normal)
|
||||||
|
cancelButton.setTitleColor(AIRetouchTemplateStyle.primary, for: .normal)
|
||||||
|
cancelButton.setTitleColor(AIRetouchTemplateStyle.primary.withAlphaComponent(0.45), for: .disabled)
|
||||||
|
cancelButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
cancelButton.backgroundColor = AIRetouchTemplateStyle.cardBackground
|
||||||
|
cancelButton.layer.cornerRadius = 12
|
||||||
|
cancelButton.layer.borderColor = AIRetouchTemplateStyle.primary.cgColor
|
||||||
|
cancelButton.layer.borderWidth = 1
|
||||||
|
cancelButton.accessibilityIdentifier = "travelAlbum.aiRetouchCancelButton"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureConfirmButton() {
|
||||||
|
confirmButton.setTitle("确定", for: .normal)
|
||||||
|
confirmButton.setTitleColor(.white, for: .normal)
|
||||||
|
confirmButton.setTitleColor(.white, for: .disabled)
|
||||||
|
confirmButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
confirmButton.accessibilityIdentifier = "travelAlbum.aiRetouchConfirmButton"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureDataSource() {
|
||||||
|
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) {
|
||||||
|
[weak self] collectionView, indexPath, item in
|
||||||
|
guard let self else { return nil }
|
||||||
|
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):
|
||||||
|
let cell = collectionView.dequeueReusableCell(
|
||||||
|
withReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as! TravelAlbumAIRetouchTemplateCell
|
||||||
|
cell.apply(
|
||||||
|
template: template,
|
||||||
|
selected: self.viewModel.selectedTemplateId(for: category) == template.id
|
||||||
|
)
|
||||||
|
cell.onPreviewTapped = { [weak self] in
|
||||||
|
self?.showPreview(for: template)
|
||||||
|
}
|
||||||
|
return cell
|
||||||
|
case .mode:
|
||||||
|
let cell = collectionView.dequeueReusableCell(
|
||||||
|
withReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as! TravelAlbumAIRetouchModeCell
|
||||||
|
cell.apply(
|
||||||
|
requiredQuota: self.viewModel.requiredQuota,
|
||||||
|
remainingQuota: self.viewModel.remainingQuota,
|
||||||
|
insufficient: self.viewModel.remainingQuota.map {
|
||||||
|
self.viewModel.requiredQuota > $0
|
||||||
|
} ?? false
|
||||||
|
)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
|
||||||
|
guard kind == UICollectionView.elementKindSectionHeader,
|
||||||
|
let self,
|
||||||
|
self.dataSource.snapshot().sectionIdentifiers.indices.contains(indexPath.section) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section]
|
||||||
|
let header = collectionView.dequeueReusableSupplementaryView(
|
||||||
|
ofKind: kind,
|
||||||
|
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as! TravelAlbumAIRetouchSectionHeader
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeLayout() -> UICollectionViewCompositionalLayout {
|
||||||
|
UICollectionViewCompositionalLayout { [weak self] sectionIndex, layoutEnvironment in
|
||||||
|
guard let self,
|
||||||
|
self.dataSource != nil,
|
||||||
|
self.dataSource.snapshot().sectionIdentifiers.indices.contains(sectionIndex) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
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:
|
||||||
|
let availableWidth = layoutEnvironment.container.effectiveContentSize.width
|
||||||
|
let cardWidth = min(118, floor((availableWidth - 56) / 3))
|
||||||
|
let itemSize = NSCollectionLayoutSize(
|
||||||
|
widthDimension: .absolute(cardWidth),
|
||||||
|
heightDimension: .absolute(156)
|
||||||
|
)
|
||||||
|
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||||
|
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
|
||||||
|
let section = NSCollectionLayoutSection(group: group)
|
||||||
|
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
|
||||||
|
section.interGroupSpacing = 10
|
||||||
|
section.contentInsets = NSDirectionalEdgeInsets(
|
||||||
|
top: 4,
|
||||||
|
leading: 18,
|
||||||
|
bottom: 14,
|
||||||
|
trailing: 18
|
||||||
|
)
|
||||||
|
section.boundarySupplementaryItems = [
|
||||||
|
NSCollectionLayoutBoundarySupplementaryItem(
|
||||||
|
layoutSize: NSCollectionLayoutSize(
|
||||||
|
widthDimension: .fractionalWidth(1),
|
||||||
|
heightDimension: .absolute(40)
|
||||||
|
),
|
||||||
|
elementKind: UICollectionView.elementKindSectionHeader,
|
||||||
|
alignment: .top
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return section
|
||||||
|
case .mode:
|
||||||
|
let itemSize = NSCollectionLayoutSize(
|
||||||
|
widthDimension: .fractionalWidth(1),
|
||||||
|
heightDimension: .absolute(78)
|
||||||
|
)
|
||||||
|
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||||
|
let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item])
|
||||||
|
let section = NSCollectionLayoutSection(group: group)
|
||||||
|
section.contentInsets = NSDirectionalEdgeInsets(
|
||||||
|
top: 8,
|
||||||
|
leading: 18,
|
||||||
|
bottom: 18,
|
||||||
|
trailing: 18
|
||||||
|
)
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func applyViewModel() {
|
||||||
|
let isLoadFailure = viewModel.loadErrorMessage != nil
|
||||||
|
statusContainer.isHidden = !viewModel.isLoading && !isLoadFailure
|
||||||
|
collectionView.isHidden = viewModel.isLoading || isLoadFailure
|
||||||
|
if viewModel.isLoading {
|
||||||
|
statusIndicator.startAnimating()
|
||||||
|
statusLabel.text = "正在加载修图模板…"
|
||||||
|
retryButton.isHidden = true
|
||||||
|
} else if let message = viewModel.loadErrorMessage {
|
||||||
|
statusIndicator.stopAnimating()
|
||||||
|
statusLabel.text = message
|
||||||
|
retryButton.isHidden = false
|
||||||
|
} else {
|
||||||
|
statusIndicator.stopAnimating()
|
||||||
|
statusLabel.text = nil
|
||||||
|
retryButton.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
validationLabel.text = viewModel.validationMessage
|
||||||
|
validationLabel.isHidden = viewModel.validationMessage == nil
|
||||||
|
cancelButton.isEnabled = !viewModel.isSubmitting
|
||||||
|
confirmButton.isEnabled = viewModel.canSubmit
|
||||||
|
confirmButton.alpha = viewModel.canSubmit ? 1 : 0.45
|
||||||
|
confirmButton.setGradientEnabled(viewModel.canSubmit)
|
||||||
|
confirmButton.setTitle(viewModel.isSubmitting ? "提交中" : "确定", for: .normal)
|
||||||
|
confirmButton.setLoading(viewModel.isSubmitting)
|
||||||
|
isModalInPresentation = viewModel.isSubmitting
|
||||||
|
applySnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applySnapshot() {
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
|
snapshot.appendSections([.overview])
|
||||||
|
snapshot.appendItems([.overview], toSection: .overview)
|
||||||
|
for category in viewModel.visibleCategories {
|
||||||
|
appendTemplates(category, to: &snapshot)
|
||||||
|
}
|
||||||
|
snapshot.appendSections([.mode])
|
||||||
|
snapshot.appendItems([.mode], toSection: .mode)
|
||||||
|
snapshot.reconfigureItems(snapshot.itemIdentifiers)
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func appendTemplates(
|
||||||
|
_ category: TravelAlbumAIRetouchTemplateCategory,
|
||||||
|
to snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>
|
||||||
|
) {
|
||||||
|
let section = Section.templates(category)
|
||||||
|
snapshot.appendSections([section])
|
||||||
|
snapshot.appendItems(
|
||||||
|
viewModel.templates(for: category).map { Item.template(category, $0) },
|
||||||
|
toSection: section
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelTapped() {
|
||||||
|
guard !viewModel.isSubmitting else { return }
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func confirmTapped() {
|
||||||
|
guard viewModel.canSubmit else {
|
||||||
|
if let message = viewModel.validationMessage { showToast(message) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task { await viewModel.submit(api: api) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func retryTapped() {
|
||||||
|
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 {
|
||||||
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
guard let item = dataSource.itemIdentifier(for: indexPath),
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 修图模板卡片,展示模板预览、名称和明确选中态。
|
||||||
|
final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
|
||||||
|
static let reuseIdentifier = "TravelAlbumAIRetouchTemplateCell"
|
||||||
|
|
||||||
|
private let previewImageView = UIImageView()
|
||||||
|
private let nameLabel = UILabel()
|
||||||
|
private let checkView = UIImageView()
|
||||||
|
private let previewButton = AIRetouchTemplatePreviewButton(type: .system)
|
||||||
|
|
||||||
|
var onPreviewTapped: (() -> Void)?
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
contentView.backgroundColor = AIRetouchTemplateStyle.cardBackground
|
||||||
|
contentView.layer.cornerRadius = 12
|
||||||
|
contentView.layer.borderWidth = 1
|
||||||
|
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.clipsToBounds = true
|
||||||
|
previewImageView.backgroundColor = UIColor(hex: 0xEDF2F8)
|
||||||
|
previewImageView.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateImage"
|
||||||
|
nameLabel.font = .systemFont(ofSize: 13, weight: .semibold)
|
||||||
|
nameLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||||||
|
nameLabel.textAlignment = .center
|
||||||
|
nameLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
checkView.image = UIImage(systemName: "checkmark.circle.fill")
|
||||||
|
checkView.tintColor = AIRetouchTemplateStyle.primary
|
||||||
|
checkView.backgroundColor = .white
|
||||||
|
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(nameLabel)
|
||||||
|
contentView.addSubview(checkView)
|
||||||
|
contentView.addSubview(previewButton)
|
||||||
|
previewImageView.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview()
|
||||||
|
make.height.equalTo(118)
|
||||||
|
}
|
||||||
|
nameLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(previewImageView.snp.bottom)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(6)
|
||||||
|
make.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
checkView.snp.makeConstraints { make in
|
||||||
|
make.top.trailing.equalToSuperview().inset(7)
|
||||||
|
make.size.equalTo(22)
|
||||||
|
}
|
||||||
|
previewButton.snp.makeConstraints { make in
|
||||||
|
make.trailing.bottom.equalTo(previewImageView).inset(4)
|
||||||
|
make.width.equalTo(48)
|
||||||
|
make.height.equalTo(28)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func prepareForReuse() {
|
||||||
|
super.prepareForReuse()
|
||||||
|
previewImageView.kf.cancelDownloadTask()
|
||||||
|
previewImageView.image = nil
|
||||||
|
onPreviewTapped = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新模板内容与选中状态。
|
||||||
|
func apply(template: TravelAlbumAIRetouchTemplate, selected: Bool) {
|
||||||
|
nameLabel.text = template.name
|
||||||
|
let placeholder = UIImage(named: "ai_retouch_template_placeholder")
|
||||||
|
if let url = URL(string: template.previewURL), !template.previewURL.isEmpty {
|
||||||
|
previewImageView.contentMode = .scaleAspectFill
|
||||||
|
previewImageView.kf.setImage(
|
||||||
|
with: url,
|
||||||
|
placeholder: placeholder
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
previewImageView.image = placeholder
|
||||||
|
previewImageView.contentMode = .scaleAspectFill
|
||||||
|
}
|
||||||
|
contentView.layer.borderColor = (
|
||||||
|
selected ? AIRetouchTemplateStyle.primary : AIRetouchTemplateStyle.border
|
||||||
|
).cgColor
|
||||||
|
contentView.layer.borderWidth = selected ? 2 : 1
|
||||||
|
layer.shadowOpacity = selected ? 0.16 : 0.08
|
||||||
|
checkView.isHidden = !selected
|
||||||
|
isSelected = selected
|
||||||
|
accessibilityLabel = template.name
|
||||||
|
accessibilityValue = selected ? "已选择" : "未选择"
|
||||||
|
accessibilityTraits = selected ? [.button, .selected] : [.button]
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func previewTapped() {
|
||||||
|
onPreviewTapped?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 扩大模板预览按钮的触摸区域,同时保持截图中的胶囊尺寸。
|
||||||
|
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 {
|
||||||
|
static let reuseIdentifier = "TravelAlbumAIRetouchSectionHeader"
|
||||||
|
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let badgeContainer = UIView()
|
||||||
|
private let badgeLabel = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||||||
|
badgeContainer.layer.cornerRadius = 10
|
||||||
|
badgeContainer.clipsToBounds = true
|
||||||
|
badgeLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
||||||
|
badgeLabel.textAlignment = .center
|
||||||
|
badgeLabel.numberOfLines = 1
|
||||||
|
|
||||||
|
addSubview(titleLabel)
|
||||||
|
addSubview(badgeContainer)
|
||||||
|
badgeContainer.addSubview(badgeLabel)
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(18)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
badgeContainer.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
|
||||||
|
make.centerY.equalTo(titleLabel)
|
||||||
|
make.height.equalTo(20)
|
||||||
|
make.trailing.lessThanOrEqualToSuperview().offset(-18)
|
||||||
|
}
|
||||||
|
badgeLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.equalToSuperview().inset(9)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新分组标题与业务标记。
|
||||||
|
fileprivate func apply(title: String, badge: AIRetouchTemplateSectionBadge?) {
|
||||||
|
titleLabel.text = title
|
||||||
|
badgeContainer.isHidden = badge == nil
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图模板页视觉常量。
|
||||||
|
private enum AIRetouchTemplateStyle {
|
||||||
|
static let primary = UIColor(hex: 0x1677FF)
|
||||||
|
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 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 danger = UIColor(hex: 0xE53935)
|
||||||
|
static let disabledStart = UIColor(hex: 0xA9C8EE)
|
||||||
|
static let disabledEnd = UIColor(hex: 0xC6DAF3)
|
||||||
|
}
|
||||||