Compare commits
6
Commits
b127495e52
..
1.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dca4bd5a20 | ||
|
|
eb57666033 | ||
|
|
2c4123214d | ||
|
|
8cd6c0aac8 | ||
|
|
1757ba5e36 | ||
|
|
c34c4e1923 |
@@ -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` 支持以下统一语义:
|
||||||
|
|
||||||
|
> 对本次传入模板对应的每种输出独立处理:已有结果则在新任务成功后覆盖,没有结果则新增;未传模板的类型不处理。不要因为某一种关联图历史上从未生成,就拒绝整个重新修图请求。
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 工作周报(2026.08.10—2026.08.14)
|
||||||
|
|
||||||
|
## 本周概况
|
||||||
|
|
||||||
|
本周围绕旅拍相册和 AI 修图功能开展开发,共完成 **23 次提交**,涉及 **37 个文件**,代码变更约新增 **11,495 行**、删除 **1,120 行**。
|
||||||
|
|
||||||
|
## 本周完成
|
||||||
|
|
||||||
|
### 1. 完善旅拍相册图片预览
|
||||||
|
|
||||||
|
- 新增 iOS 端全屏图片预览。
|
||||||
|
- 完善预览工具栏、图片索引胶囊、分段控件及关闭按钮样式。
|
||||||
|
- 支持原图与 AI 修图版本切换,并修复切换时出现黑屏的问题。
|
||||||
|
- 增加相册管理页下拉刷新。
|
||||||
|
- 接入旅拍相册批量删除接口。
|
||||||
|
|
||||||
|
### 2. 打通 AI 修图业务流程
|
||||||
|
|
||||||
|
- 从相册及图片预览页接入 AI 修图入口。
|
||||||
|
- 完成图片选择、模板选择、模板预览和任务提交流程。
|
||||||
|
- 优化修图标签、页面布局及提交反馈。
|
||||||
|
- 新增 AI 修图前后效果对比功能。
|
||||||
|
|
||||||
|
### 3. 新增 AI 修图任务中心
|
||||||
|
|
||||||
|
- 完成任务列表及任务详情页面。
|
||||||
|
- 接入 AI 修图任务相关接口和状态模型。
|
||||||
|
- 支持处理中、成功、失败等任务状态展示。
|
||||||
|
- 打通消息中心、推送通知与任务详情跳转链路。
|
||||||
|
|
||||||
|
### 4. 优化相册数据同步
|
||||||
|
|
||||||
|
- 修复从 OTG 页面返回后旅拍相册未及时刷新的问题。
|
||||||
|
- 优化修图提交后的页面反馈及状态更新。
|
||||||
|
- 完善异常状态和页面切换场景下的数据刷新逻辑。
|
||||||
|
|
||||||
|
### 5. 补充文档与测试
|
||||||
|
|
||||||
|
- 补充 AI 修图需求、接口和交互设计文档。
|
||||||
|
- 完善相册 API、数据模型、ViewModel、消息中心及推送相关单元测试。
|
||||||
|
- 增加图片预览、任务中心和修图流程的回归测试覆盖。
|
||||||
|
|
||||||
|
## 风险与待验证项
|
||||||
|
|
||||||
|
- AI 修图任务中心涉及接口、推送、消息中心和页面跳转,需要结合测试环境继续进行全链路验证。
|
||||||
|
- 需重点回归任务处理中、成功、失败及网络异常等状态。
|
||||||
|
- OTG 返回刷新、批量删除和图片版本切换需要在真机及大相册场景下继续验证。
|
||||||
|
|
||||||
|
## 下周计划
|
||||||
|
|
||||||
|
- 联调 AI 修图任务全生命周期及推送跳转。
|
||||||
|
- 完成旅拍相册与 AI 修图功能的真机回归。
|
||||||
|
- 优化大图加载、任务刷新和弱网场景下的交互体验。
|
||||||
|
- 根据测试反馈修复问题,推进功能验收与发布准备。
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 工作周报(2026.08.17—2026.08.21)
|
||||||
|
|
||||||
|
## 本周概况
|
||||||
|
|
||||||
|
本周围绕旅拍相册和 AI 重修流程开展功能完善与体验优化,共完成 **7 次提交**,涉及 **20 个文件**,代码及文档变更约新增 **1,101 行**、删除 **240 行**;同时完成版本号升级,为 **1.3.1** 版本发布做准备。
|
||||||
|
|
||||||
|
## 本周完成
|
||||||
|
|
||||||
|
### 1. 完善 AI 重修业务规则
|
||||||
|
|
||||||
|
- 根据是否已有 AI 结果区分首次修图与重新修图流程。
|
||||||
|
- 已有 AI 结果时,统一支持精修和氛围感模板按需选择,并校验至少选择一种模板。
|
||||||
|
- 优化单图、多图场景下的模板必选规则、生成数量与额度提示。
|
||||||
|
- 对齐 AI 重修请求参数、模板范围及结果覆盖规则,确保未选结果类型保持原有版本不变。
|
||||||
|
|
||||||
|
### 2. 优化相册素材状态与删除保护
|
||||||
|
|
||||||
|
- 将购买状态和 AI 修图状态拆分展示,避免不同业务状态互相覆盖。
|
||||||
|
- 扩展素材选择能力,支持跨相册 Tab 进入选择模式。
|
||||||
|
- 增加已购素材删除保护,批量删除时仅提交未购买素材。
|
||||||
|
- 优化删除确认提示,明确已选、已购及实际可删除数量;全部为已购素材时阻止删除并给出反馈。
|
||||||
|
|
||||||
|
### 3. 改进任务详情与图片预览体验
|
||||||
|
|
||||||
|
- 优化 AI 修图任务详情中的结果类型标签、状态区域和失败信息布局,提升信息辨识度。
|
||||||
|
- 调整任务目标标题、模板和操作按钮布局,改善长文本与不同状态下的适配表现。
|
||||||
|
- 完善图片预览初始版本选择,当前版本不可用时自动回退到有效图片。
|
||||||
|
- 新增高对比度延迟加载指示器,减少快速加载时的闪烁,并优化未知文件大小的展示。
|
||||||
|
|
||||||
|
### 4. 完善相册刷新能力
|
||||||
|
|
||||||
|
- 为旅拍相册入口增加下拉刷新。
|
||||||
|
- 补充刷新状态管理,确保成功、失败及取消场景均能正确结束刷新动画。
|
||||||
|
|
||||||
|
### 5. 补充文档与测试覆盖
|
||||||
|
|
||||||
|
- 新增 AI 重新修图接口优化需求文档,并同步更新 AI 修图需求及接口说明。
|
||||||
|
- 完善 AI 修图模板、重修请求、相册素材模型、删除保护及页面布局相关单元测试。
|
||||||
|
- 增加首次修图、已有结果重修、模板选填和异常状态等关键场景的回归覆盖。
|
||||||
|
|
||||||
|
### 6. 推进版本发布准备
|
||||||
|
|
||||||
|
- 将项目版本号升级至 **1.3.1**。
|
||||||
|
- 整理本轮 AI 重修规则、交互调整及测试范围,为后续联调验收提供依据。
|
||||||
|
|
||||||
|
## 进行中
|
||||||
|
|
||||||
|
- 补充 AI 重修模板占位图的多倍图资源配置。
|
||||||
|
- 继续验证 AI 重修、批量删除及图片预览在真机和真实数据下的完整表现。
|
||||||
|
|
||||||
|
## 风险与待验证项
|
||||||
|
|
||||||
|
- AI 重修规则涉及模板范围、额度计算和结果覆盖,需要结合测试环境验证服务端行为是否与最新规则一致。
|
||||||
|
- 已购与未购素材混合选择时,需要重点回归删除参数、数量提示及刷新后的数据一致性。
|
||||||
|
- 图片预览需继续验证弱网、大图及不同 AI 结果组合下的加载、回退和切换体验。
|
||||||
|
- 本周测试代码已补充,仍需在已连接的 iPhone 真机上完成完整测试回归并记录结果。
|
||||||
|
|
||||||
|
## 下周计划
|
||||||
|
|
||||||
|
- 完成 1.3.1 版本真机测试、问题修复及发布验收。
|
||||||
|
- 联调 AI 重修模板选择、额度扣减、任务生成与结果覆盖全链路。
|
||||||
|
- 回归相册下拉刷新、素材状态展示和已购素材删除保护。
|
||||||
|
- 补齐 AI 重修模板多倍图资源,优化弱网和大图加载体验。
|
||||||
@@ -436,7 +436,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1020101;
|
CURRENT_PROJECT_VERSION = 1030101;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||||
@@ -460,7 +460,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2.1;
|
MARKETING_VERSION = 1.3.1;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@@ -502,7 +502,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1020101;
|
CURRENT_PROJECT_VERSION = 1030101;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||||
@@ -526,7 +526,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2.1;
|
MARKETING_VERSION = 1.3.1;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@@ -741,7 +741,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1020101;
|
CURRENT_PROJECT_VERSION = 1030101;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -761,7 +761,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2.1;
|
MARKETING_VERSION = 1.3.1;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
|
|||||||
Vendored
+8
@@ -4,6 +4,14 @@
|
|||||||
"filename" : "ai_retouch_template_placeholder.png",
|
"filename" : "ai_retouch_template_placeholder.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
|
|||||||
@@ -86,10 +86,10 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
|
|||||||
|
|
||||||
| `scope` | 使用场景 |
|
| `scope` | 使用场景 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `batch` | 网格多选 AI 修图 |
|
| `batch` | 网格多选 AI 修图,或预览页尚无 AI 结果 Tab 时首次修图 |
|
||||||
| `all_variants` | 预览页无 Tab,或当前是原图 Tab |
|
| `all_variants` | 预览页已有 AI 结果 Tab;当前为原图、精修后或氛围感均使用此范围 |
|
||||||
| `refined_only` | 当前是精修后 Tab |
|
| `refined_only` | 仅重修精修结果的接口能力,当前客户端无独立入口 |
|
||||||
| `atmosphere_only` | 当前是氛围感 Tab |
|
| `atmosphere_only` | 仅重修氛围感结果的接口能力,当前客户端无独立入口 |
|
||||||
|
|
||||||
`user_equity_travel_id`、`scope` 和 `source_count` 均必填。`source_count` 用于计算封面模板是否显示和必选。
|
`user_equity_travel_id`、`scope` 和 `source_count` 均必填。`source_count` 用于计算封面模板是否显示和必选。
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@ Content-Type: application/json
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 5.4.2 预览页原图 Tab
|
#### 5.4.2 预览页已有 AI 结果 Tab
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -234,10 +234,6 @@ Content-Type: application/json
|
|||||||
"scope": "all_variants",
|
"scope": "all_variants",
|
||||||
"source_material_ids": ["2031"],
|
"source_material_ids": ["2031"],
|
||||||
"outputs": [
|
"outputs": [
|
||||||
{
|
|
||||||
"type": "refined",
|
|
||||||
"template_id": "tpl_refined_15"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "atmosphere",
|
"type": "atmosphere",
|
||||||
"template_id": "tpl_atmosphere_09"
|
"template_id": "tpl_atmosphere_09"
|
||||||
@@ -246,7 +242,7 @@ Content-Type: application/json
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
如用户未选氛围感模板,`outputs` 中不传 `atmosphere`。缺失表示“本次不处理”,不表示删除现有氛围感图。
|
已有精修或氛围感结果时,无论当前位于原图、精修后还是氛围感 Tab,精修和氛围感模板均为选填,但至少选择一种。`outputs` 只传本次选中的结果类型;缺失表示“本次不处理”,不表示删除或覆盖对应的已有结果。
|
||||||
|
|
||||||
#### 5.4.3 只重新精修
|
#### 5.4.3 只重新精修
|
||||||
|
|
||||||
@@ -285,7 +281,7 @@ Content-Type: application/json
|
|||||||
| `scope` | 原图数 | 精修 | 氛围感 | 封面 |
|
| `scope` | 原图数 | 精修 | 氛围感 | 封面 |
|
||||||
|---|---:|---|---|---|
|
|---|---:|---|---|---|
|
||||||
| `batch` | 1 至 50 | 必选 | 可选 | 少于 4 张禁止;4 张及以上必选 |
|
| `batch` | 1 至 50 | 必选 | 可选 | 少于 4 张禁止;4 张及以上必选 |
|
||||||
| `all_variants` | 必须为 1 | 必选 | 可选 | 禁止 |
|
| `all_variants` | 必须为 1 | 可选(与氛围感至少一项) | 可选(与精修至少一项) | 禁止 |
|
||||||
| `refined_only` | 必须为 1 | 必选 | 禁止 | 禁止 |
|
| `refined_only` | 必须为 1 | 必选 | 禁止 | 禁止 |
|
||||||
| `atmosphere_only` | 必须为 1 | 禁止 | 必选 | 禁止 |
|
| `atmosphere_only` | 必须为 1 | 禁止 | 必选 | 禁止 |
|
||||||
|
|
||||||
|
|||||||
@@ -114,9 +114,9 @@ Cell 左上角展示稳定的修图状态:
|
|||||||
| 当前情况 | 模板要求 | 生成与覆盖规则 |
|
| 当前情况 | 模板要求 | 生成与覆盖规则 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 不显示 Tab | 精修必选,氛围感可选 | 首次生成关联的精修图,可选生成氛围感图 |
|
| 不显示 Tab | 精修必选,氛围感可选 | 首次生成关联的精修图,可选生成氛围感图 |
|
||||||
| 已显示 Tab,当前为“原图” | 精修必选,氛围感可选 | 重新生成精修图;如选氛围感则也重新生成。新结果成功后原子替换对应旧结果 |
|
| 已显示 Tab,当前为“原图” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 |
|
||||||
| 已显示 Tab,当前为“精修后” | 精修必选 | 只重新生成精修图,成功后覆盖旧精修图,不影响氛围感图 |
|
| 已显示 Tab,当前为“精修后” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 |
|
||||||
| 已显示 Tab,当前为“氛围感” | 氛围感必选 | 只重新生成氛围感图,成功后覆盖旧氛围感图,不影响精修图 |
|
| 已显示 Tab,当前为“氛围感” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 |
|
||||||
|
|
||||||
重新修图期间应保留上一版成功图片可见;只有新结果成功时才原子替换当前版本。失败时继续保留旧版本,并返回可展示的错误信息。
|
重新修图期间应保留上一版成功图片可见;只有新结果成功时才原子替换当前版本。失败时继续保留旧版本,并返回可展示的错误信息。
|
||||||
|
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 相册素材网格角标类别,用于稳定映射文案优先级和语义颜色。
|
/// 相册素材网格角标类别,用于稳定映射独立业务状态和语义颜色。
|
||||||
enum TravelAlbumMaterialBadgeKind: Sendable, Equatable {
|
enum TravelAlbumMaterialBadgeKind: Sendable, Equatable {
|
||||||
case purchased
|
case purchased
|
||||||
case pending
|
case pending
|
||||||
@@ -241,14 +241,15 @@ struct TravelAlbumMaterialBadgePresentation: Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension TravelAlbumMaterial {
|
extension TravelAlbumMaterial {
|
||||||
/// 按 AI 修图状态和购买状态生成网格角标;返回 nil 时隐藏角标。
|
/// 生成购买状态角标;购买状态与修图状态互不覆盖。
|
||||||
var badgePresentation: TravelAlbumMaterialBadgePresentation? {
|
var purchaseBadgePresentation: TravelAlbumMaterialBadgePresentation? {
|
||||||
if aiRetouchStatus == 0 {
|
isPurchased
|
||||||
return isPurchased
|
|
||||||
? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
||||||
: nil
|
: nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 生成 AI 修图状态角标;未进入修图流程时返回 nil。
|
||||||
|
var aiRetouchBadgePresentation: TravelAlbumMaterialBadgePresentation? {
|
||||||
let statusName = aiRetouchStatusName.trimmingCharacters(in: .whitespacesAndNewlines)
|
let statusName = aiRetouchStatusName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
switch aiRetouchStatus {
|
switch aiRetouchStatus {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -386,14 +387,13 @@ enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 当前分组是否允许不选择;首次修图和原图 Tab 覆盖重修的氛围感模板选填。
|
/// 当前分组是否允许不选择;首次修图仅氛围感选填,已有 AI 结果的预览重修两类模板均选填。
|
||||||
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
|
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
|
||||||
guard category == .atmosphere else { return false }
|
|
||||||
switch self {
|
switch self {
|
||||||
case .initial:
|
case .initial:
|
||||||
return true
|
return category == .atmosphere
|
||||||
case .reretouch(_, _, .all):
|
case .reretouch(_, _, .all):
|
||||||
return true
|
return category == .refined || category == .atmosphere
|
||||||
case .reretouch:
|
case .reretouch:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
|||||||
self.assets = assets.filter { seenKinds.insert($0.kind).inserted }
|
self.assets = assets.filter { seenKinds.insert($0.kind).inserted }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 根据当前 Tab 生成首次修图或覆盖重修工作流。
|
/// 根据是否已有 AI 结果生成首次修图或双模板覆盖重修工作流。
|
||||||
func aiRetouchWorkflow(
|
func aiRetouchWorkflow(
|
||||||
albumId: Int,
|
albumId: Int,
|
||||||
selectedKind: TravelAlbumPreviewAssetKind
|
selectedKind: TravelAlbumPreviewAssetKind
|
||||||
@@ -183,16 +183,8 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
|||||||
guard hasVariants else {
|
guard hasVariants else {
|
||||||
return .initial(albumId: albumId, materialIds: [originalMaterialId])
|
return .initial(albumId: albumId, materialIds: [originalMaterialId])
|
||||||
}
|
}
|
||||||
switch selectedKind {
|
guard selectedKind != .cover else { return nil }
|
||||||
case .original:
|
|
||||||
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .all)
|
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .all)
|
||||||
case .retouched:
|
|
||||||
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .refined)
|
|
||||||
case .atmosphere:
|
|
||||||
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .atmosphere)
|
|
||||||
case .cover:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-4
@@ -90,7 +90,8 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
case .refined, .atmosphere:
|
case .refined, .atmosphere:
|
||||||
return 1
|
return 1
|
||||||
case .all:
|
case .all:
|
||||||
return 1 + (selectedAtmosphereTemplateId == nil ? 0 : 1)
|
return (selectedRefinedTemplateId == nil ? 0 : 1)
|
||||||
|
+ (selectedAtmosphereTemplateId == nil ? 0 : 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,6 +110,11 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
return unavailableMessage(for: category)
|
return unavailableMessage(for: category)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if case .reretouch(_, _, .all) = workflow,
|
||||||
|
selectedRefinedTemplateId == nil,
|
||||||
|
selectedAtmosphereTemplateId == nil {
|
||||||
|
return "请至少选择一个修图模板"
|
||||||
|
}
|
||||||
guard let remainingQuota else {
|
guard let remainingQuota else {
|
||||||
return "剩余修图次数获取失败,请刷新后重试"
|
return "剩余修图次数获取失败,请刷新后重试"
|
||||||
}
|
}
|
||||||
@@ -146,7 +152,9 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
atmosphereTemplates = response.atmosphereTemplates
|
atmosphereTemplates = response.atmosphereTemplates
|
||||||
coverTemplates = response.coverTemplates
|
coverTemplates = response.coverTemplates
|
||||||
remainingQuota = response.remainingQuota
|
remainingQuota = response.remainingQuota
|
||||||
selectedRefinedTemplateId = visibleCategories.contains(.refined) ? refinedTemplates.first?.id : nil
|
selectedRefinedTemplateId = visibleCategories.contains(.refined) && !isOptional(.refined)
|
||||||
|
? refinedTemplates.first?.id
|
||||||
|
: nil
|
||||||
selectedAtmosphereTemplateId = visibleCategories.contains(.atmosphere) && !isOptional(.atmosphere)
|
selectedAtmosphereTemplateId = visibleCategories.contains(.atmosphere) && !isOptional(.atmosphere)
|
||||||
? atmosphereTemplates.first?.id
|
? atmosphereTemplates.first?.id
|
||||||
: nil
|
: nil
|
||||||
@@ -188,14 +196,14 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 选择模板;仅选填分组允许再次点击取消,必选分组保持单选。
|
/// 选择模板;选填分组允许再次点击取消,必选分组保持单选。
|
||||||
func toggleTemplate(id: Int, category: TravelAlbumAIRetouchTemplateCategory) {
|
func toggleTemplate(id: Int, category: TravelAlbumAIRetouchTemplateCategory) {
|
||||||
guard visibleCategories.contains(category),
|
guard visibleCategories.contains(category),
|
||||||
templates(for: category).contains(where: { $0.id == id })
|
templates(for: category).contains(where: { $0.id == id })
|
||||||
else { return }
|
else { return }
|
||||||
switch category {
|
switch category {
|
||||||
case .refined:
|
case .refined:
|
||||||
selectedRefinedTemplateId = id
|
selectedRefinedTemplateId = isOptional(category) && selectedRefinedTemplateId == id ? nil : id
|
||||||
case .atmosphere:
|
case .atmosphere:
|
||||||
selectedAtmosphereTemplateId = isOptional(category) && selectedAtmosphereTemplateId == id ? nil : id
|
selectedAtmosphereTemplateId = isOptional(category) && selectedAtmosphereTemplateId == id ? nil : id
|
||||||
case .cover:
|
case .cover:
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ final class TravelAlbumDetailViewModel {
|
|||||||
private(set) var isRefreshing = false
|
private(set) var isRefreshing = false
|
||||||
private(set) var isSelectionMode = false
|
private(set) var isSelectionMode = false
|
||||||
private(set) var selectedMaterialIds: Set<Int> = []
|
private(set) var selectedMaterialIds: Set<Int> = []
|
||||||
|
private var selectedMaterialPurchaseStates: [Int: Bool] = [:]
|
||||||
|
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
var onShowMessage: ((String) -> Void)?
|
var onShowMessage: ((String) -> Void)?
|
||||||
@@ -142,12 +143,29 @@ final class TravelAlbumDetailViewModel {
|
|||||||
selectedTab == .all ? allPhotoCount : purchasedPhotoCount
|
selectedTab == .all ? allPhotoCount : purchasedPhotoCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 当前选中素材中的已购买数量。
|
||||||
|
var selectedPurchasedMaterialCount: Int {
|
||||||
|
selectedMaterialIds.count - selectedDeletableMaterialIds.count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除确认弹窗文案;选中已购素材时明确说明保护规则与实际删除数量。
|
||||||
|
var deleteSelectedConfirmationMessage: String {
|
||||||
|
let selectedCount = selectedMaterialIds.count
|
||||||
|
let purchasedCount = selectedPurchasedMaterialCount
|
||||||
|
guard purchasedCount > 0 else {
|
||||||
|
return "确定删除选中的 \(selectedCount) 张素材吗?"
|
||||||
|
}
|
||||||
|
let deletableCount = selectedCount - purchasedCount
|
||||||
|
return "已选择 \(selectedCount) 张素材,其中 \(purchasedCount) 张已购买。"
|
||||||
|
+ "不会删除已购买的项目,只会删除 \(deletableCount) 张未购买的项目。"
|
||||||
|
}
|
||||||
|
|
||||||
/// 切换素材 tab。
|
/// 切换素材 tab。
|
||||||
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
|
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
|
||||||
guard selectedTab != tab else { return }
|
guard selectedTab != tab else { return }
|
||||||
selectedTab = tab
|
selectedTab = tab
|
||||||
isSelectionMode = false
|
isSelectionMode = false
|
||||||
selectedMaterialIds = []
|
clearMaterialSelection()
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
await loadMaterials(reset: true, api: api)
|
await loadMaterials(reset: true, api: api)
|
||||||
}
|
}
|
||||||
@@ -223,10 +241,9 @@ final class TravelAlbumDetailViewModel {
|
|||||||
|
|
||||||
/// 切换选择模式。
|
/// 切换选择模式。
|
||||||
func toggleSelectionMode() {
|
func toggleSelectionMode() {
|
||||||
guard selectedTab == .all else { return }
|
|
||||||
isSelectionMode.toggle()
|
isSelectionMode.toggle()
|
||||||
if !isSelectionMode {
|
if !isSelectionMode {
|
||||||
selectedMaterialIds = []
|
clearMaterialSelection()
|
||||||
}
|
}
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
@@ -234,14 +251,12 @@ final class TravelAlbumDetailViewModel {
|
|||||||
/// 切换素材选中状态。
|
/// 切换素材选中状态。
|
||||||
func toggleMaterialSelection(_ material: TravelAlbumMaterial) {
|
func toggleMaterialSelection(_ material: TravelAlbumMaterial) {
|
||||||
guard isSelectionMode else { return }
|
guard isSelectionMode else { return }
|
||||||
guard material.status == 1 else {
|
|
||||||
onShowMessage?("仅未购买素材可删除")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if selectedMaterialIds.contains(material.id) {
|
if selectedMaterialIds.contains(material.id) {
|
||||||
selectedMaterialIds.remove(material.id)
|
selectedMaterialIds.remove(material.id)
|
||||||
|
selectedMaterialPurchaseStates.removeValue(forKey: material.id)
|
||||||
} else {
|
} else {
|
||||||
selectedMaterialIds.insert(material.id)
|
selectedMaterialIds.insert(material.id)
|
||||||
|
selectedMaterialPurchaseStates[material.id] = material.isPurchased
|
||||||
}
|
}
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
@@ -249,7 +264,7 @@ final class TravelAlbumDetailViewModel {
|
|||||||
/// AI 修图任务提交成功后退出选择模式并清空当前选择。
|
/// AI 修图任务提交成功后退出选择模式并清空当前选择。
|
||||||
func completeAIRetouchSubmission() {
|
func completeAIRetouchSubmission() {
|
||||||
isSelectionMode = false
|
isSelectionMode = false
|
||||||
selectedMaterialIds = []
|
clearMaterialSelection()
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +279,7 @@ final class TravelAlbumDetailViewModel {
|
|||||||
guard let index = materials.firstIndex(where: { $0.id == id }) else { return }
|
guard let index = materials.firstIndex(where: { $0.id == id }) else { return }
|
||||||
let material = materials.remove(at: index)
|
let material = materials.remove(at: index)
|
||||||
selectedMaterialIds.remove(id)
|
selectedMaterialIds.remove(id)
|
||||||
|
selectedMaterialPurchaseStates.removeValue(forKey: id)
|
||||||
allPhotoCount = max(0, allPhotoCount - 1)
|
allPhotoCount = max(0, allPhotoCount - 1)
|
||||||
if material.isPurchased {
|
if material.isPurchased {
|
||||||
purchasedPhotoCount = max(0, purchasedPhotoCount - 1)
|
purchasedPhotoCount = max(0, purchasedPhotoCount - 1)
|
||||||
@@ -273,16 +289,20 @@ final class TravelAlbumDetailViewModel {
|
|||||||
|
|
||||||
/// 删除已选素材。
|
/// 删除已选素材。
|
||||||
func deleteSelectedMaterials(api: any TravelAlbumServing) async {
|
func deleteSelectedMaterials(api: any TravelAlbumServing) async {
|
||||||
let ids = selectedMaterialIds.sorted()
|
guard !selectedMaterialIds.isEmpty else {
|
||||||
guard !ids.isEmpty else {
|
|
||||||
onShowMessage?("请选择要删除的素材")
|
onShowMessage?("请选择要删除的素材")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let ids = selectedDeletableMaterialIds
|
||||||
|
guard !ids.isEmpty else {
|
||||||
|
onShowMessage?("选中的素材均已购买,未删除任何项目")
|
||||||
|
return
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
try await api.batchDeleteMaterials(ids: ids)
|
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
|
||||||
@@ -307,4 +327,18 @@ final class TravelAlbumDetailViewModel {
|
|||||||
private func notifyStateChange() {
|
private func notifyStateChange() {
|
||||||
onStateChange?()
|
onStateChange?()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func clearMaterialSelection() {
|
||||||
|
selectedMaterialIds = []
|
||||||
|
selectedMaterialPurchaseStates = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectedDeletableMaterialIds: [Int] {
|
||||||
|
selectedMaterialIds
|
||||||
|
.filter { id in
|
||||||
|
guard selectedMaterialPurchaseStates[id] == false else { return false }
|
||||||
|
return materials.first(where: { $0.id == id })?.isPurchased != true
|
||||||
|
}
|
||||||
|
.sorted()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -533,7 +533,7 @@ private final class AIJobDetailAlbumCard: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 任务内容卡,以浅蓝标签展示各输出目标数量并补充额度结算。
|
/// 任务内容卡,以三色紧凑标签展示各输出目标数量并补充额度结算。
|
||||||
private final class AIJobDetailContentCard: UIView {
|
private final class AIJobDetailContentCard: UIView {
|
||||||
private let outputStack = UIStackView()
|
private let outputStack = UIStackView()
|
||||||
private let quotaLabel = UILabel()
|
private let quotaLabel = UILabel()
|
||||||
@@ -548,7 +548,8 @@ private final class AIJobDetailContentCard: UIView {
|
|||||||
titleLabel.textColor = AIJobDetailStyle.textPrimary
|
titleLabel.textColor = AIJobDetailStyle.textPrimary
|
||||||
outputStack.axis = .horizontal
|
outputStack.axis = .horizontal
|
||||||
outputStack.spacing = 10
|
outputStack.spacing = 10
|
||||||
outputStack.distribution = .fillEqually
|
outputStack.distribution = .fill
|
||||||
|
outputStack.alignment = .center
|
||||||
quotaLabel.font = .systemFont(ofSize: 12)
|
quotaLabel.font = .systemFont(ofSize: 12)
|
||||||
quotaLabel.textColor = AIJobDetailStyle.textSecondary
|
quotaLabel.textColor = AIJobDetailStyle.textSecondary
|
||||||
quotaLabel.numberOfLines = 0
|
quotaLabel.numberOfLines = 0
|
||||||
@@ -565,6 +566,12 @@ private final class AIJobDetailContentCard: UIView {
|
|||||||
func apply(_ detail: TravelAlbumAIJobDetail) {
|
func apply(_ detail: TravelAlbumAIJobDetail) {
|
||||||
outputStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
outputStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
detail.outputs.forEach { outputStack.addArrangedSubview(makeChip($0)) }
|
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
|
outputStack.isHidden = detail.outputs.isEmpty
|
||||||
let quota = detail.quotaSettlement
|
let quota = detail.quotaSettlement
|
||||||
quotaLabel.text = "额度:预占 \(quota.reservedUnits) · 消耗 \(quota.consumedUnits) · 释放 \(quota.releasedUnits)"
|
quotaLabel.text = "额度:预占 \(quota.reservedUnits) · 消耗 \(quota.consumedUnits) · 释放 \(quota.releasedUnits)"
|
||||||
@@ -573,32 +580,42 @@ private final class AIJobDetailContentCard: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func makeChip(_ output: TravelAlbumAIJobOutput) -> UIView {
|
private func makeChip(_ output: TravelAlbumAIJobOutput) -> UIView {
|
||||||
let container = UIView()
|
AIJobDetailOutputChip(
|
||||||
container.backgroundColor = AppColor.primaryLight
|
text: "\(output.type.shortTitle) \(output.count)张",
|
||||||
container.layer.cornerRadius = 9
|
backgroundColor: output.type.chipBackgroundColor,
|
||||||
let iconView = UIImageView(image: UIImage(systemName: output.type.symbolName))
|
accessibilityIdentifier: "aiRetouchJob.contentChip.\(output.type.chipIdentifier)"
|
||||||
iconView.tintColor = AppColor.primary
|
)
|
||||||
iconView.contentMode = .scaleAspectFit
|
}
|
||||||
let label = UILabel()
|
}
|
||||||
label.text = "\(output.type.shortTitle) \(output.count) 张"
|
|
||||||
|
/// 依据文字固有宽度展示的任务输出类型标签。
|
||||||
|
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.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
label.textColor = AIJobDetailStyle.textPrimary
|
label.textColor = AIJobDetailStyle.textPrimary
|
||||||
label.adjustsFontSizeToFitWidth = true
|
label.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
label.minimumScaleFactor = 0.75
|
addSubview(label)
|
||||||
container.addSubview(iconView)
|
|
||||||
container.addSubview(label)
|
|
||||||
iconView.snp.makeConstraints { make in
|
|
||||||
make.leading.equalToSuperview().offset(10)
|
|
||||||
make.centerY.equalToSuperview()
|
|
||||||
make.size.equalTo(18)
|
|
||||||
}
|
|
||||||
label.snp.makeConstraints { make in
|
label.snp.makeConstraints { make in
|
||||||
make.leading.equalTo(iconView.snp.trailing).offset(6)
|
make.leading.trailing.equalToSuperview().inset(12)
|
||||||
make.trailing.equalToSuperview().inset(8)
|
|
||||||
make.centerY.equalToSuperview()
|
make.centerY.equalToSuperview()
|
||||||
}
|
}
|
||||||
container.snp.makeConstraints { $0.height.equalTo(44) }
|
snp.makeConstraints { $0.height.equalTo(44) }
|
||||||
return container
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,6 +680,9 @@ private final class AIJobDetailTargetRow: UIView {
|
|||||||
private let templateLabel = UILabel()
|
private let templateLabel = UILabel()
|
||||||
private let statusIconView = UIImageView()
|
private let statusIconView = UIImageView()
|
||||||
private let statusLabel = UILabel()
|
private let statusLabel = UILabel()
|
||||||
|
private let statusStack = UIStackView()
|
||||||
|
private let statusRow = UIStackView()
|
||||||
|
private let contentStack = UIStackView()
|
||||||
private let resultButton = UIButton(type: .system)
|
private let resultButton = UIButton(type: .system)
|
||||||
private let errorContainer = UIView()
|
private let errorContainer = UIView()
|
||||||
private let errorIconView = UIImageView(image: UIImage(systemName: "exclamationmark.circle.fill"))
|
private let errorIconView = UIImageView(image: UIImage(systemName: "exclamationmark.circle.fill"))
|
||||||
@@ -679,8 +699,11 @@ private final class AIJobDetailTargetRow: UIView {
|
|||||||
titleLabel.font = .systemFont(ofSize: 15, weight: .medium)
|
titleLabel.font = .systemFont(ofSize: 15, weight: .medium)
|
||||||
titleLabel.textColor = AIJobDetailStyle.textPrimary
|
titleLabel.textColor = AIJobDetailStyle.textPrimary
|
||||||
titleLabel.lineBreakMode = .byTruncatingMiddle
|
titleLabel.lineBreakMode = .byTruncatingMiddle
|
||||||
|
titleLabel.numberOfLines = 1
|
||||||
templateLabel.font = .systemFont(ofSize: 13)
|
templateLabel.font = .systemFont(ofSize: 13)
|
||||||
templateLabel.textColor = AIJobDetailStyle.textSecondary
|
templateLabel.textColor = AIJobDetailStyle.textSecondary
|
||||||
|
templateLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
templateLabel.numberOfLines = 1
|
||||||
statusIconView.contentMode = .scaleAspectFit
|
statusIconView.contentMode = .scaleAspectFit
|
||||||
statusLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
statusLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
resultButton.setTitle("查看结果", for: .normal)
|
resultButton.setTitle("查看结果", for: .normal)
|
||||||
@@ -693,49 +716,45 @@ private final class AIJobDetailTargetRow: UIView {
|
|||||||
errorLabel.textColor = AppColor.danger
|
errorLabel.textColor = AppColor.danger
|
||||||
errorLabel.numberOfLines = 0
|
errorLabel.numberOfLines = 0
|
||||||
|
|
||||||
let statusStack = UIStackView(arrangedSubviews: [statusIconView, statusLabel])
|
statusStack.addArrangedSubview(statusIconView)
|
||||||
|
statusStack.addArrangedSubview(statusLabel)
|
||||||
statusStack.axis = .horizontal
|
statusStack.axis = .horizontal
|
||||||
statusStack.spacing = 6
|
statusStack.spacing = 6
|
||||||
statusStack.alignment = .center
|
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) }
|
statusIconView.snp.makeConstraints { $0.size.equalTo(18) }
|
||||||
addSubview(thumbnailView)
|
addSubview(thumbnailView)
|
||||||
addSubview(titleLabel)
|
addSubview(contentStack)
|
||||||
addSubview(templateLabel)
|
|
||||||
addSubview(statusStack)
|
|
||||||
addSubview(resultButton)
|
|
||||||
addSubview(errorContainer)
|
|
||||||
errorContainer.addSubview(errorIconView)
|
errorContainer.addSubview(errorIconView)
|
||||||
errorContainer.addSubview(errorLabel)
|
errorContainer.addSubview(errorLabel)
|
||||||
|
|
||||||
thumbnailView.snp.makeConstraints { make in
|
thumbnailView.snp.makeConstraints { make in
|
||||||
make.leading.top.equalToSuperview().offset(4)
|
make.leading.top.equalToSuperview().offset(4)
|
||||||
make.size.equalTo(80)
|
make.size.equalTo(80)
|
||||||
|
make.bottom.lessThanOrEqualToSuperview().inset(12)
|
||||||
}
|
}
|
||||||
titleLabel.snp.makeConstraints { make in
|
contentStack.snp.makeConstraints { make in
|
||||||
make.leading.equalTo(thumbnailView.snp.trailing).offset(14)
|
make.leading.equalTo(thumbnailView.snp.trailing).offset(14)
|
||||||
make.top.equalTo(thumbnailView).offset(18)
|
make.top.equalTo(thumbnailView).offset(18)
|
||||||
make.trailing.lessThanOrEqualTo(statusStack.snp.leading).offset(-8)
|
|
||||||
}
|
|
||||||
templateLabel.snp.makeConstraints { make in
|
|
||||||
make.leading.equalTo(titleLabel)
|
|
||||||
make.top.equalTo(titleLabel.snp.bottom).offset(7)
|
|
||||||
make.trailing.lessThanOrEqualTo(statusStack.snp.leading).offset(-8)
|
|
||||||
}
|
|
||||||
statusStack.snp.makeConstraints { make in
|
|
||||||
make.trailing.equalToSuperview().inset(4)
|
|
||||||
make.centerY.equalTo(thumbnailView).offset(-7)
|
|
||||||
}
|
|
||||||
resultButton.snp.makeConstraints { make in
|
|
||||||
make.trailing.equalToSuperview().inset(4)
|
|
||||||
make.top.equalTo(statusStack.snp.bottom).offset(4)
|
|
||||||
make.height.equalTo(28)
|
|
||||||
}
|
|
||||||
errorContainer.snp.makeConstraints { make in
|
|
||||||
make.top.equalTo(templateLabel.snp.bottom).offset(12)
|
|
||||||
make.leading.equalTo(titleLabel)
|
|
||||||
make.trailing.equalToSuperview().inset(4)
|
make.trailing.equalToSuperview().inset(4)
|
||||||
make.bottom.equalToSuperview().inset(12)
|
make.bottom.equalToSuperview().inset(12)
|
||||||
make.height.greaterThanOrEqualTo(38)
|
|
||||||
}
|
}
|
||||||
errorIconView.snp.makeConstraints { make in
|
errorIconView.snp.makeConstraints { make in
|
||||||
make.leading.equalToSuperview().offset(10)
|
make.leading.equalToSuperview().offset(10)
|
||||||
@@ -780,21 +799,9 @@ private final class AIJobDetailTargetRow: UIView {
|
|||||||
errorLabel.text = target.displayFailureMessage.map { "失败原因:\($0)" }
|
errorLabel.text = target.displayFailureMessage.map { "失败原因:\($0)" }
|
||||||
let showsError = target.displayFailureMessage != nil
|
let showsError = target.displayFailureMessage != nil
|
||||||
errorContainer.isHidden = !showsError
|
errorContainer.isHidden = !showsError
|
||||||
errorContainer.snp.remakeConstraints { make in
|
titleLabel.accessibilityIdentifier = "aiRetouchJob.target.\(target.targetId).title"
|
||||||
if showsError {
|
templateLabel.accessibilityIdentifier = "aiRetouchJob.target.\(target.targetId).template"
|
||||||
make.top.equalTo(templateLabel.snp.bottom).offset(12)
|
statusStack.accessibilityIdentifier = "aiRetouchJob.target.\(target.targetId).status"
|
||||||
} else {
|
|
||||||
make.top.equalTo(thumbnailView.snp.bottom)
|
|
||||||
}
|
|
||||||
make.leading.equalTo(titleLabel)
|
|
||||||
make.trailing.equalToSuperview().inset(4)
|
|
||||||
make.bottom.equalToSuperview().inset(12)
|
|
||||||
if showsError {
|
|
||||||
make.height.greaterThanOrEqualTo(38)
|
|
||||||
} else {
|
|
||||||
make.height.equalTo(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
accessibilityLabel = [titleLabel.text, templateLabel.text, statusLabel.text, errorLabel.text]
|
accessibilityLabel = [titleLabel.text, templateLabel.text, statusLabel.text, errorLabel.text]
|
||||||
.compactMap { $0 }.joined(separator: ",")
|
.compactMap { $0 }.joined(separator: ",")
|
||||||
}
|
}
|
||||||
@@ -896,12 +903,21 @@ private extension TravelAlbumAIJobOutputType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var symbolName: String {
|
var chipBackgroundColor: UIColor {
|
||||||
switch self {
|
switch self {
|
||||||
case .refined: "wand.and.stars"
|
case .refined: UIColor(hex: 0xEAF3FF)
|
||||||
case .atmosphere: "sun.max"
|
case .atmosphere: UIColor(hex: 0xFFF2DC)
|
||||||
case .cover: "bookmark"
|
case .cover: UIColor(hex: 0xF2ECFF)
|
||||||
case .unknown: "sparkles"
|
case .unknown: UIColor(hex: 0xF1F5F9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var chipIdentifier: String {
|
||||||
|
switch self {
|
||||||
|
case .refined: "refined"
|
||||||
|
case .atmosphere: "atmosphere"
|
||||||
|
case .cover: "cover"
|
||||||
|
case .unknown: "unknown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -293,14 +293,11 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
sortButton.accessibilityValue = viewModel.sortOption.title
|
sortButton.accessibilityValue = viewModel.sortOption.title
|
||||||
sortButton.menu = makeSortMenu()
|
sortButton.menu = makeSortMenu()
|
||||||
|
|
||||||
let canSelectMaterials = viewModel.selectedTab == .all
|
|
||||||
selectButton.isHidden = false
|
selectButton.isHidden = false
|
||||||
selectButton.isEnabled = canSelectMaterials
|
selectButton.isEnabled = true
|
||||||
selectButton.alpha = canSelectMaterials ? 1 : 0.45
|
selectButton.alpha = 1
|
||||||
selectButton.setTitle(viewModel.isSelectionMode ? "完成" : "选择", for: .normal)
|
selectButton.setTitle(viewModel.isSelectionMode ? "完成" : "选择", for: .normal)
|
||||||
selectButton.accessibilityValue = canSelectMaterials
|
selectButton.accessibilityValue = viewModel.isSelectionMode ? "选择模式已开启" : "选择模式已关闭"
|
||||||
? (viewModel.isSelectionMode ? "选择模式已开启" : "选择模式已关闭")
|
|
||||||
: "已购照片不可删除"
|
|
||||||
|
|
||||||
let selectedCount = viewModel.selectedMaterialIds.count
|
let selectedCount = viewModel.selectedMaterialIds.count
|
||||||
let hasSelection = selectedCount > 0
|
let hasSelection = selectedCount > 0
|
||||||
@@ -487,7 +484,11 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
@objc private func deleteSelectedTapped() {
|
@objc private func deleteSelectedTapped() {
|
||||||
let count = viewModel.selectedMaterialIds.count
|
let count = viewModel.selectedMaterialIds.count
|
||||||
guard count > 0 else { return }
|
guard count > 0 else { return }
|
||||||
let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert)
|
let alert = UIAlertController(
|
||||||
|
title: "删除素材",
|
||||||
|
message: viewModel.deleteSelectedConfirmationMessage,
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
|
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -694,14 +695,16 @@ private final class TravelAlbumInfoCard: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 旅拍相册素材网格单元,展示正方形缩略图、文件名、大小和选择状态。
|
/// 旅拍相册素材网格单元,独立展示购买、修图和选择状态。
|
||||||
final class TravelAlbumMaterialCell: UICollectionViewCell {
|
final class TravelAlbumMaterialCell: UICollectionViewCell {
|
||||||
static let reuseIdentifier = "TravelAlbumMaterialCell"
|
static let reuseIdentifier = "TravelAlbumMaterialCell"
|
||||||
|
|
||||||
private let imageView = UIImageView()
|
private let imageView = UIImageView()
|
||||||
private let checkImageView = UIImageView()
|
private let checkImageView = UIImageView()
|
||||||
private let badgeView = UIView()
|
private let purchaseBadgeView = UIView()
|
||||||
private let badgeLabel = UILabel()
|
private let purchaseBadgeLabel = UILabel()
|
||||||
|
private let aiRetouchBadgeView = UIView()
|
||||||
|
private let aiRetouchBadgeLabel = UILabel()
|
||||||
private let nameLabel = UILabel()
|
private let nameLabel = UILabel()
|
||||||
private let sizeLabel = UILabel()
|
private let sizeLabel = UILabel()
|
||||||
|
|
||||||
@@ -715,18 +718,18 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
|
|||||||
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||||
checkImageView.layer.cornerRadius = 11
|
checkImageView.layer.cornerRadius = 11
|
||||||
checkImageView.accessibilityIdentifier = "travelAlbum.materialSelectionCheck"
|
checkImageView.accessibilityIdentifier = "travelAlbum.materialSelectionCheck"
|
||||||
badgeView.layer.cornerRadius = 5
|
configureBadge(
|
||||||
badgeView.clipsToBounds = true
|
purchaseBadgeView,
|
||||||
badgeView.isHidden = true
|
label: purchaseBadgeLabel,
|
||||||
badgeView.isAccessibilityElement = false
|
viewIdentifier: "travelAlbum.materialPurchaseBadge",
|
||||||
badgeView.accessibilityIdentifier = "travelAlbum.materialStatusBadge"
|
labelIdentifier: "travelAlbum.materialPurchaseBadgeLabel"
|
||||||
badgeLabel.font = .systemFont(ofSize: 10, weight: .semibold)
|
)
|
||||||
badgeLabel.textColor = .white
|
configureBadge(
|
||||||
badgeLabel.textAlignment = .center
|
aiRetouchBadgeView,
|
||||||
badgeLabel.isAccessibilityElement = false
|
label: aiRetouchBadgeLabel,
|
||||||
badgeLabel.accessibilityIdentifier = "travelAlbum.materialStatusBadgeLabel"
|
viewIdentifier: "travelAlbum.materialAIRetouchBadge",
|
||||||
badgeLabel.setContentHuggingPriority(.required, for: .horizontal)
|
labelIdentifier: "travelAlbum.materialAIRetouchBadgeLabel"
|
||||||
badgeLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
)
|
||||||
nameLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
nameLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
nameLabel.textColor = TravelAlbumDetailStyle.textPrimary
|
nameLabel.textColor = TravelAlbumDetailStyle.textPrimary
|
||||||
nameLabel.lineBreakMode = .byTruncatingMiddle
|
nameLabel.lineBreakMode = .byTruncatingMiddle
|
||||||
@@ -734,8 +737,8 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
|
|||||||
sizeLabel.textColor = TravelAlbumDetailStyle.textSecondary
|
sizeLabel.textColor = TravelAlbumDetailStyle.textSecondary
|
||||||
|
|
||||||
contentView.addSubview(imageView)
|
contentView.addSubview(imageView)
|
||||||
imageView.addSubview(badgeView)
|
imageView.addSubview(purchaseBadgeView)
|
||||||
badgeView.addSubview(badgeLabel)
|
imageView.addSubview(aiRetouchBadgeView)
|
||||||
imageView.addSubview(checkImageView)
|
imageView.addSubview(checkImageView)
|
||||||
contentView.addSubview(nameLabel)
|
contentView.addSubview(nameLabel)
|
||||||
contentView.addSubview(sizeLabel)
|
contentView.addSubview(sizeLabel)
|
||||||
@@ -747,14 +750,13 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
|
|||||||
make.top.trailing.equalToSuperview().inset(6)
|
make.top.trailing.equalToSuperview().inset(6)
|
||||||
make.size.equalTo(22)
|
make.size.equalTo(22)
|
||||||
}
|
}
|
||||||
badgeView.snp.makeConstraints { make in
|
aiRetouchBadgeView.snp.makeConstraints { make in
|
||||||
make.top.leading.equalToSuperview().inset(6)
|
make.top.leading.equalToSuperview().inset(6)
|
||||||
make.trailing.lessThanOrEqualTo(checkImageView.snp.leading).offset(-4)
|
make.trailing.lessThanOrEqualTo(checkImageView.snp.leading).offset(-4)
|
||||||
}
|
}
|
||||||
badgeLabel.snp.makeConstraints { make in
|
purchaseBadgeView.snp.makeConstraints { make in
|
||||||
make.edges.equalToSuperview().inset(
|
make.leading.bottom.equalToSuperview().inset(6)
|
||||||
UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6)
|
make.trailing.lessThanOrEqualToSuperview().inset(6)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
nameLabel.snp.makeConstraints { make in
|
nameLabel.snp.makeConstraints { make in
|
||||||
make.top.equalTo(imageView.snp.bottom).offset(6)
|
make.top.equalTo(imageView.snp.bottom).offset(6)
|
||||||
@@ -784,14 +786,54 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
|
|||||||
checkImageView.isHidden = !selectionMode
|
checkImageView.isHidden = !selectionMode
|
||||||
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||||
checkImageView.tintColor = selected ? TravelAlbumDetailStyle.primary : .white
|
checkImageView.tintColor = selected ? TravelAlbumDetailStyle.primary : .white
|
||||||
let badge = material.badgePresentation
|
let purchaseBadge = material.purchaseBadgePresentation
|
||||||
badgeView.isHidden = badge == nil
|
let aiRetouchBadge = material.aiRetouchBadgePresentation
|
||||||
badgeLabel.text = badge?.text
|
applyBadge(purchaseBadge, to: purchaseBadgeView, label: purchaseBadgeLabel)
|
||||||
badgeView.backgroundColor = badge.map { TravelAlbumDetailStyle.badgeColor(for: $0.kind) }
|
applyBadge(aiRetouchBadge, to: aiRetouchBadgeView, label: aiRetouchBadgeLabel)
|
||||||
nameLabel.text = material.fileName.isEmpty ? "未命名照片" : material.fileName
|
nameLabel.text = material.fileName.isEmpty ? "未命名照片" : material.fileName
|
||||||
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
|
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
|
||||||
let badgeAccessibilityText = badge.map { ",状态:\($0.text)" } ?? ""
|
let purchaseAccessibilityText = purchaseBadge.map { ",购买状态:\($0.text)" } ?? ""
|
||||||
accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")\(badgeAccessibilityText)"
|
let aiRetouchAccessibilityText = aiRetouchBadge.map { ",修图状态:\($0.text)" } ?? ""
|
||||||
|
accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")"
|
||||||
|
+ purchaseAccessibilityText
|
||||||
|
+ aiRetouchAccessibilityText
|
||||||
accessibilityValue = selectionMode ? (selected ? "已选择" : "未选择") : nil
|
accessibilityValue = selectionMode ? (selected ? "已选择" : "未选择") : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func configureBadge(
|
||||||
|
_ badgeView: UIView,
|
||||||
|
label: UILabel,
|
||||||
|
viewIdentifier: String,
|
||||||
|
labelIdentifier: String
|
||||||
|
) {
|
||||||
|
badgeView.layer.cornerRadius = 5
|
||||||
|
badgeView.clipsToBounds = true
|
||||||
|
badgeView.isHidden = true
|
||||||
|
badgeView.isAccessibilityElement = false
|
||||||
|
badgeView.accessibilityIdentifier = viewIdentifier
|
||||||
|
label.font = .systemFont(ofSize: 10, weight: .semibold)
|
||||||
|
label.textColor = .white
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.lineBreakMode = .byTruncatingTail
|
||||||
|
label.isAccessibilityElement = false
|
||||||
|
label.accessibilityIdentifier = labelIdentifier
|
||||||
|
label.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
label.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
||||||
|
badgeView.addSubview(label)
|
||||||
|
label.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(
|
||||||
|
UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyBadge(
|
||||||
|
_ presentation: TravelAlbumMaterialBadgePresentation?,
|
||||||
|
to badgeView: UIView,
|
||||||
|
label: UILabel
|
||||||
|
) {
|
||||||
|
badgeView.isHidden = presentation == nil
|
||||||
|
label.text = presentation?.text
|
||||||
|
badgeView.backgroundColor = presentation.map { TravelAlbumDetailStyle.badgeColor(for: $0.kind) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,15 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
self.allowsActions = allowsActions
|
self.allowsActions = allowsActions
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
modalPresentationStyle = .fullScreen
|
modalPresentationStyle = .fullScreen
|
||||||
rebuildNodes(keepingProjectIndex: max(0, min(startProjectIndex, projects.count - 1)), kind: startKind)
|
let resolvedProjectIndex = max(0, min(startProjectIndex, self.projects.count - 1))
|
||||||
|
let initialProject = self.projects.indices.contains(resolvedProjectIndex)
|
||||||
|
? self.projects[resolvedProjectIndex]
|
||||||
|
: nil
|
||||||
|
let resolvedStartKind = initialProject?.asset(for: startKind) == nil
|
||||||
|
? initialProject?.orderedAssets.first?.kind ?? .original
|
||||||
|
: startKind
|
||||||
|
selectedKind = resolvedStartKind
|
||||||
|
rebuildNodes(keepingProjectIndex: resolvedProjectIndex, kind: resolvedStartKind)
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
@@ -165,6 +173,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
sizeLabel.textColor = UIColor.white.withAlphaComponent(0.52)
|
sizeLabel.textColor = UIColor.white.withAlphaComponent(0.52)
|
||||||
sizeLabel.font = .systemFont(ofSize: 13, weight: .regular)
|
sizeLabel.font = .systemFont(ofSize: 13, weight: .regular)
|
||||||
sizeLabel.textAlignment = .center
|
sizeLabel.textAlignment = .center
|
||||||
|
sizeLabel.accessibilityIdentifier = "travelAlbum.previewFileSizeLabel"
|
||||||
|
|
||||||
counterLabel.textColor = UIColor.white.withAlphaComponent(0.82)
|
counterLabel.textColor = UIColor.white.withAlphaComponent(0.82)
|
||||||
counterLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .medium)
|
counterLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .medium)
|
||||||
@@ -423,7 +432,9 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
guard let node = currentNode else { return }
|
guard let node = currentNode else { return }
|
||||||
let asset = currentAsset
|
let asset = currentAsset
|
||||||
titleLabel.text = asset?.fileName.isEmpty == false ? asset?.fileName : "未命名照片"
|
titleLabel.text = asset?.fileName.isEmpty == false ? asset?.fileName : "未命名照片"
|
||||||
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(asset?.fileSize ?? 0)
|
let fileSize = asset?.fileSize ?? 0
|
||||||
|
sizeLabel.text = fileSize > 0 ? TravelAlbumDisplayFormatter.fileSizeText(fileSize) : nil
|
||||||
|
sizeLabel.isHidden = fileSize <= 0
|
||||||
counterLabel.text = "\(node.projectIndex + 1)/\(max(totalCount, projects.count))"
|
counterLabel.text = "\(node.projectIndex + 1)/\(max(totalCount, projects.count))"
|
||||||
counterLabel.accessibilityLabel = "第 \(node.projectIndex + 1) 张,共 \(max(totalCount, projects.count)) 张"
|
counterLabel.accessibilityLabel = "第 \(node.projectIndex + 1) 张,共 \(max(totalCount, projects.count)) 张"
|
||||||
highResolutionButton.isEnabled = asset != nil
|
highResolutionButton.isEnabled = asset != nil
|
||||||
@@ -982,6 +993,56 @@ extension TravelAlbumPhotoPreviewViewController: UICollectionViewDataSource, UIC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 深色图片预览中使用的高对比度延迟加载指示器。
|
||||||
|
@MainActor
|
||||||
|
private final class TravelAlbumPreviewLoadingIndicator: Indicator {
|
||||||
|
private let containerView = UIView()
|
||||||
|
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
|
private var revealTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
var view: IndicatorView { containerView }
|
||||||
|
|
||||||
|
init() {
|
||||||
|
containerView.backgroundColor = UIColor(red: 39 / 255, green: 39 / 255, blue: 42 / 255, alpha: 0.94)
|
||||||
|
containerView.layer.cornerRadius = 22
|
||||||
|
containerView.isAccessibilityElement = true
|
||||||
|
containerView.accessibilityIdentifier = "travelAlbum.previewLoadingIndicator"
|
||||||
|
containerView.accessibilityLabel = "图片加载中"
|
||||||
|
activityIndicator.color = UIColor.white.withAlphaComponent(0.92)
|
||||||
|
activityIndicator.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
|
||||||
|
containerView.addSubview(activityIndicator)
|
||||||
|
activityIndicator.snp.makeConstraints { $0.center.equalToSuperview() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func startAnimatingView() {
|
||||||
|
revealTask?.cancel()
|
||||||
|
containerView.alpha = 0
|
||||||
|
containerView.isHidden = true
|
||||||
|
revealTask = Task { @MainActor [weak self] in
|
||||||
|
try? await Task.sleep(for: .milliseconds(150))
|
||||||
|
guard !Task.isCancelled, let self else { return }
|
||||||
|
activityIndicator.startAnimating()
|
||||||
|
containerView.isHidden = false
|
||||||
|
UIView.animate(
|
||||||
|
withDuration: UIAccessibility.isReduceMotionEnabled ? 0 : 0.12,
|
||||||
|
animations: { self.containerView.alpha = 1 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopAnimatingView() {
|
||||||
|
revealTask?.cancel()
|
||||||
|
revealTask = nil
|
||||||
|
activityIndicator.stopAnimating()
|
||||||
|
containerView.alpha = 0
|
||||||
|
containerView.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func sizeStrategy(in imageView: KFCrossPlatformImageView) -> IndicatorSizeStrategy {
|
||||||
|
.size(CGSize(width: 44, height: 44))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 预览图片 Cell,使用 UIScrollView 提供远程加载、双击和双指缩放。
|
/// 预览图片 Cell,使用 UIScrollView 提供远程加载、双击和双指缩放。
|
||||||
private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate {
|
private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate {
|
||||||
static let reuseIdentifier = "TravelAlbumPreviewImageCell"
|
static let reuseIdentifier = "TravelAlbumPreviewImageCell"
|
||||||
@@ -1054,7 +1115,7 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
|
|||||||
|
|
||||||
imageView.contentMode = .scaleAspectFit
|
imageView.contentMode = .scaleAspectFit
|
||||||
imageView.backgroundColor = .black
|
imageView.backgroundColor = .black
|
||||||
imageView.kf.indicatorType = .activity
|
imageView.kf.indicatorType = .custom(indicator: TravelAlbumPreviewLoadingIndicator())
|
||||||
imageView.accessibilityIdentifier = "travelAlbum.previewImageView"
|
imageView.accessibilityIdentifier = "travelAlbum.previewImageView"
|
||||||
|
|
||||||
retryButton.setTitle("图片加载失败,点击重试", for: .normal)
|
retryButton.setTitle("图片加载失败,点击重试", for: .normal)
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testReretouchWorkflowShowsAndSubmitsOnlyRequiredCategories() async {
|
func testReretouchWorkflowAllowsAtmosphereOnlyFromOriginalTab() async {
|
||||||
let api = makeAPI()
|
let api = makeAPI()
|
||||||
let refined = TravelAlbumAIRetouchTemplateViewModel(
|
let refined = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
scenicId: 18,
|
scenicId: 18,
|
||||||
@@ -133,9 +133,19 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
await all.loadTemplates(api: api)
|
await all.loadTemplates(api: api)
|
||||||
XCTAssertEqual(all.visibleCategories, [.refined, .atmosphere])
|
XCTAssertEqual(all.visibleCategories, [.refined, .atmosphere])
|
||||||
XCTAssertEqual(all.selectedRefinedTemplateId, 11)
|
XCTAssertNil(all.selectedRefinedTemplateId)
|
||||||
XCTAssertNil(all.selectedAtmosphereTemplateId)
|
XCTAssertNil(all.selectedAtmosphereTemplateId)
|
||||||
|
XCTAssertTrue(all.isOptional(.refined))
|
||||||
XCTAssertTrue(all.isOptional(.atmosphere))
|
XCTAssertTrue(all.isOptional(.atmosphere))
|
||||||
|
XCTAssertEqual(all.requiredQuota, 0)
|
||||||
|
XCTAssertFalse(all.canSubmit)
|
||||||
|
XCTAssertEqual(all.validationMessage, "请至少选择一个修图模板")
|
||||||
|
|
||||||
|
all.toggleTemplate(id: 11, category: .refined)
|
||||||
|
XCTAssertEqual(all.selectedRefinedTemplateId, 11)
|
||||||
|
all.toggleTemplate(id: 11, category: .refined)
|
||||||
|
XCTAssertNil(all.selectedRefinedTemplateId)
|
||||||
|
all.toggleTemplate(id: 21, category: .atmosphere)
|
||||||
XCTAssertEqual(all.requiredQuota, 1)
|
XCTAssertEqual(all.requiredQuota, 1)
|
||||||
XCTAssertTrue(all.canSubmit)
|
XCTAssertTrue(all.canSubmit)
|
||||||
await all.submit(api: api)
|
await all.submit(api: api)
|
||||||
@@ -159,8 +169,8 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
|||||||
id: 9,
|
id: 9,
|
||||||
aiRetouchBatchId: 90,
|
aiRetouchBatchId: 90,
|
||||||
type: .all,
|
type: .all,
|
||||||
refinedTemplateId: 11,
|
refinedTemplateId: nil,
|
||||||
atmosphereTemplateId: nil
|
atmosphereTemplateId: 21
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
@@ -183,7 +193,7 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
|||||||
XCTAssertTrue(api.aiReretouchRequests.isEmpty)
|
XCTAssertTrue(api.aiReretouchRequests.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMissingOptionalAtmosphereDoesNotDisableAllReretouchWorkflow() async {
|
func testMissingOptionalAtmosphereStillAllowsSelectingRefinedInAllReretouchWorkflow() async {
|
||||||
let api = makeAPI()
|
let api = makeAPI()
|
||||||
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||||
refinedTemplates: [template(11, "清透")],
|
refinedTemplates: [template(11, "清透")],
|
||||||
@@ -198,6 +208,8 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
await viewModel.loadTemplates(api: api)
|
await viewModel.loadTemplates(api: api)
|
||||||
|
|
||||||
|
XCTAssertFalse(viewModel.canSubmit)
|
||||||
|
viewModel.toggleTemplate(id: 11, category: .refined)
|
||||||
XCTAssertTrue(viewModel.canSubmit)
|
XCTAssertTrue(viewModel.canSubmit)
|
||||||
XCTAssertNil(viewModel.validationMessage)
|
XCTAssertNil(viewModel.validationMessage)
|
||||||
XCTAssertEqual(viewModel.requiredQuota, 1)
|
XCTAssertEqual(viewModel.requiredQuota, 1)
|
||||||
@@ -213,7 +225,7 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
let reretouch = TravelAlbumAIRetouchTemplateViewModel(
|
let reretouch = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
scenicId: 18,
|
scenicId: 18,
|
||||||
workflow: .reretouch(materialId: 7, batchId: 70, type: .all)
|
workflow: .reretouch(materialId: 7, batchId: 70, type: .refined)
|
||||||
)
|
)
|
||||||
await reretouch.loadTemplates(api: refinedAPI)
|
await reretouch.loadTemplates(api: refinedAPI)
|
||||||
XCTAssertFalse(reretouch.canSubmit)
|
XCTAssertFalse(reretouch.canSubmit)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|||||||
let api = TravelAlbumMockAPI()
|
let api = TravelAlbumMockAPI()
|
||||||
let source = TravelAlbumAIJobSourceMaterial(
|
let source = TravelAlbumAIJobSourceMaterial(
|
||||||
id: 691,
|
id: 691,
|
||||||
fileName: "IMG_8293.JPG",
|
fileName: "beauty_1757851944526_这个文件标题特别长用于验证布局不会挤压状态.jpg",
|
||||||
thumbnailURL: ""
|
thumbnailURL: ""
|
||||||
)
|
)
|
||||||
let failedTarget = TravelAlbumAIJobTarget(
|
let failedTarget = TravelAlbumAIJobTarget(
|
||||||
@@ -104,6 +104,42 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|||||||
XCTAssertTrue(labels.contains("失败原因:AI服务处理超时,请重新修图"))
|
XCTAssertTrue(labels.contains("失败原因:AI服务处理超时,请重新修图"))
|
||||||
XCTAssertFalse(labels.contains("去相册处理"))
|
XCTAssertFalse(labels.contains("去相册处理"))
|
||||||
XCTAssertFalse(labels.contains { $0.contains("VENDOR_TIMEOUT") })
|
XCTAssertFalse(labels.contains { $0.contains("VENDOR_TIMEOUT") })
|
||||||
|
let contentCard = try XCTUnwrap(controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "aiRetouchJob.detail.contentCard"
|
||||||
|
})
|
||||||
|
let refinedChip = try XCTUnwrap(controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "aiRetouchJob.contentChip.refined"
|
||||||
|
})
|
||||||
|
let atmosphereChip = try XCTUnwrap(controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "aiRetouchJob.contentChip.atmosphere"
|
||||||
|
})
|
||||||
|
let coverChip = try XCTUnwrap(controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "aiRetouchJob.contentChip.cover"
|
||||||
|
})
|
||||||
|
XCTAssertEqual(refinedChip.backgroundColor?.travelAlbumTestHexRGB, 0xEAF3FF)
|
||||||
|
XCTAssertEqual(atmosphereChip.backgroundColor?.travelAlbumTestHexRGB, 0xFFF2DC)
|
||||||
|
XCTAssertEqual(coverChip.backgroundColor?.travelAlbumTestHexRGB, 0xF2ECFF)
|
||||||
|
XCTAssertNil(refinedChip.findSubview { $0 is UIImageView })
|
||||||
|
XCTAssertNil(atmosphereChip.findSubview { $0 is UIImageView })
|
||||||
|
XCTAssertNil(coverChip.findSubview { $0 is UIImageView })
|
||||||
|
XCTAssertGreaterThan(atmosphereChip.bounds.width, refinedChip.bounds.width)
|
||||||
|
let coverFrame = coverChip.convert(coverChip.bounds, to: contentCard)
|
||||||
|
XCTAssertLessThan(coverFrame.maxX, contentCard.bounds.maxX - 16)
|
||||||
|
let titleLabel = try XCTUnwrap(controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "aiRetouchJob.target.3.title"
|
||||||
|
})
|
||||||
|
let templateLabel = try XCTUnwrap(controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "aiRetouchJob.target.3.template"
|
||||||
|
})
|
||||||
|
let statusView = try XCTUnwrap(controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "aiRetouchJob.target.3.status"
|
||||||
|
})
|
||||||
|
let titleFrame = titleLabel.convert(titleLabel.bounds, to: controller.view)
|
||||||
|
let templateFrame = templateLabel.convert(templateLabel.bounds, to: controller.view)
|
||||||
|
let statusFrame = statusView.convert(statusView.bounds, to: controller.view)
|
||||||
|
XCTAssertLessThanOrEqual(titleFrame.maxX, controller.view.bounds.maxX - 18)
|
||||||
|
XCTAssertGreaterThan(statusFrame.minY, templateFrame.maxY)
|
||||||
|
XCTAssertLessThanOrEqual(statusFrame.maxX, controller.view.bounds.maxX - 18)
|
||||||
let detailScreenshot = UIGraphicsImageRenderer(bounds: window.bounds).image { context in
|
let detailScreenshot = UIGraphicsImageRenderer(bounds: window.bounds).image { context in
|
||||||
window.layer.render(in: context.cgContext)
|
window.layer.render(in: context.cgContext)
|
||||||
}
|
}
|
||||||
@@ -315,6 +351,56 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|||||||
XCTAssertEqual(counterContainer.bounds.width - counterLabel.frame.maxX, 12, accuracy: 0.5)
|
XCTAssertEqual(counterContainer.bounds.width - counterLabel.frame.maxX, 12, accuracy: 0.5)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPreviewAppliesRequestedInitialKindForCoverOnlyProject() throws {
|
||||||
|
let cover = TravelAlbumPreviewAsset(
|
||||||
|
id: "cover-897",
|
||||||
|
kind: .cover,
|
||||||
|
fileURL: "https://example.com/final.png",
|
||||||
|
coverURL: "https://example.com/final.png",
|
||||||
|
fileName: "AI封面",
|
||||||
|
fileSize: 0
|
||||||
|
)
|
||||||
|
let controller = TravelAlbumPhotoPreviewViewController(
|
||||||
|
projects: [TravelAlbumPreviewProject(originalMaterialId: 897, assets: [cover])],
|
||||||
|
totalCount: 1,
|
||||||
|
startProjectIndex: 0,
|
||||||
|
startKind: .cover,
|
||||||
|
allowsActions: false
|
||||||
|
)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||||
|
controller.view.layoutIfNeeded()
|
||||||
|
|
||||||
|
let highResolutionButton = try XCTUnwrap(
|
||||||
|
controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.previewHighResolutionButton"
|
||||||
|
} as? UIButton
|
||||||
|
)
|
||||||
|
let loadingIndicator = try XCTUnwrap(
|
||||||
|
controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.previewLoadingIndicator"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let activityIndicator = try XCTUnwrap(
|
||||||
|
loadingIndicator.findSubview { $0 is UIActivityIndicatorView } as? UIActivityIndicatorView
|
||||||
|
)
|
||||||
|
let fileSizeLabel = try XCTUnwrap(
|
||||||
|
controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.previewFileSizeLabel"
|
||||||
|
} as? UILabel
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "AI封面" })
|
||||||
|
XCTAssertTrue(highResolutionButton.isEnabled)
|
||||||
|
XCTAssertTrue(fileSizeLabel.isHidden)
|
||||||
|
XCTAssertNil(fileSizeLabel.text)
|
||||||
|
XCTAssertEqual(fileSizeLabel.bounds.height, 0, accuracy: 0.5)
|
||||||
|
XCTAssertEqual(loadingIndicator.bounds.size, CGSize(width: 44, height: 44))
|
||||||
|
XCTAssertEqual(loadingIndicator.backgroundColor?.travelAlbumTestHexRGB, 0x27272A)
|
||||||
|
XCTAssertEqual(loadingIndicator.accessibilityLabel, "图片加载中")
|
||||||
|
XCTAssertEqual(activityIndicator.color.travelAlbumTestHexRGB, 0xFFFFFF)
|
||||||
|
}
|
||||||
|
|
||||||
func testPreviewSuccessfulDeletionShowsNextProjectAndPreventsDuplicateRequests() async throws {
|
func testPreviewSuccessfulDeletionShowsNextProjectAndPreventsDuplicateRequests() async throws {
|
||||||
let api = TravelAlbumMockAPI()
|
let api = TravelAlbumMockAPI()
|
||||||
api.deleteMaterialDelayNanoseconds = 1_000_000
|
api.deleteMaterialDelayNanoseconds = 1_000_000
|
||||||
@@ -832,6 +918,43 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|||||||
XCTAssertEqual(quotaLabel.accessibilityLabel, "剩余8张修图额度")
|
XCTAssertEqual(quotaLabel.accessibilityLabel, "剩余8张修图额度")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testOriginalTabReretouchSheetShowsBothTemplateGroupsAsOptional() async throws {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||||
|
refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")],
|
||||||
|
atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")],
|
||||||
|
remainingQuota: 8
|
||||||
|
)
|
||||||
|
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||||
|
scenicId: 18,
|
||||||
|
workflow: .reretouch(materialId: 7, batchId: 70, type: .all)
|
||||||
|
)
|
||||||
|
let controller = TravelAlbumAIRetouchTemplateViewController(
|
||||||
|
viewModel: viewModel,
|
||||||
|
api: api,
|
||||||
|
onSubmitted: { _ in }
|
||||||
|
)
|
||||||
|
controller.loadViewIfNeeded()
|
||||||
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||||
|
await waitUntil { !viewModel.isLoading && api.aiRetouchTemplateScenicIds.count == 1 }
|
||||||
|
controller.view.layoutIfNeeded()
|
||||||
|
|
||||||
|
let confirmButton = try XCTUnwrap(
|
||||||
|
controller.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.aiRetouchConfirmButton"
|
||||||
|
} as? UIButton
|
||||||
|
)
|
||||||
|
let accessibleLabels = controller.view.allAccessibilityLabels()
|
||||||
|
XCTAssertTrue(accessibleLabels.contains("原图精修,选填"))
|
||||||
|
XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填"))
|
||||||
|
XCTAssertFalse(confirmButton.isEnabled)
|
||||||
|
|
||||||
|
viewModel.toggleTemplate(id: 2, category: .atmosphere)
|
||||||
|
await waitUntil { confirmButton.isEnabled }
|
||||||
|
XCTAssertNil(viewModel.selectedRefinedTemplateId)
|
||||||
|
XCTAssertEqual(viewModel.selectedAtmosphereTemplateId, 2)
|
||||||
|
}
|
||||||
|
|
||||||
func testAIRetouchTemplateCellExposesSelectedState() {
|
func testAIRetouchTemplateCellExposesSelectedState() {
|
||||||
let cell = TravelAlbumAIRetouchTemplateCell(frame: .zero)
|
let cell = TravelAlbumAIRetouchTemplateCell(frame: .zero)
|
||||||
let template = TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")
|
let template = TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")
|
||||||
@@ -953,7 +1076,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|||||||
XCTAssertEqual(comparisonButton.bounds.size, CGSize(width: 48, height: 48))
|
XCTAssertEqual(comparisonButton.bounds.size, CGSize(width: 48, height: 48))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndNotifiesSubmission() async throws {
|
func testPreviewAIRetouchFromRetouchedTabAllowsBothOptionalTemplates() async throws {
|
||||||
UIView.setAnimationsEnabled(false)
|
UIView.setAnimationsEnabled(false)
|
||||||
defer { UIView.setAnimationsEnabled(true) }
|
defer { UIView.setAnimationsEnabled(true) }
|
||||||
let api = TravelAlbumMockAPI()
|
let api = TravelAlbumMockAPI()
|
||||||
@@ -1020,7 +1143,16 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|||||||
$0.accessibilityIdentifier == "travelAlbum.aiRetouchConfirmButton"
|
$0.accessibilityIdentifier == "travelAlbum.aiRetouchConfirmButton"
|
||||||
} as? UIButton
|
} as? UIButton
|
||||||
)
|
)
|
||||||
await waitUntil { api.aiRetouchTemplateScenicIds.count == 1 && confirmButton.isEnabled }
|
let collectionView = try XCTUnwrap(
|
||||||
|
sheet.view.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplateCollection"
|
||||||
|
} as? UICollectionView
|
||||||
|
)
|
||||||
|
await waitUntil {
|
||||||
|
api.aiRetouchTemplateScenicIds.count == 1 &&
|
||||||
|
collectionView.numberOfSections > 2 &&
|
||||||
|
collectionView.numberOfItems(inSection: 2) == 1
|
||||||
|
}
|
||||||
sheet.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
sheet.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||||
sheet.view.layoutIfNeeded()
|
sheet.view.layoutIfNeeded()
|
||||||
|
|
||||||
@@ -1028,7 +1160,11 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|||||||
label.isHidden ? nil : label.text
|
label.isHidden ? nil : label.text
|
||||||
}
|
}
|
||||||
XCTAssertTrue(visibleLabelTexts.contains("原图精修"))
|
XCTAssertTrue(visibleLabelTexts.contains("原图精修"))
|
||||||
XCTAssertFalse(visibleLabelTexts.contains { $0.contains("氛围感修图") })
|
XCTAssertTrue(visibleLabelTexts.contains("氛围感修图"))
|
||||||
|
let accessibleLabels = sheet.view.allAccessibilityLabels()
|
||||||
|
XCTAssertTrue(accessibleLabels.contains("原图精修,选填"))
|
||||||
|
XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填"))
|
||||||
|
XCTAssertFalse(confirmButton.isEnabled)
|
||||||
let selectionCountLabel = try XCTUnwrap(
|
let selectionCountLabel = try XCTUnwrap(
|
||||||
sheet.view.findSubview {
|
sheet.view.findSubview {
|
||||||
$0.accessibilityIdentifier == "travelAlbum.aiRetouchSelectionCountLabel"
|
$0.accessibilityIdentifier == "travelAlbum.aiRetouchSelectionCountLabel"
|
||||||
@@ -1047,11 +1183,25 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|||||||
XCTAssertEqual(selectionCountLabel.text, "已选择 1 张照片")
|
XCTAssertEqual(selectionCountLabel.text, "已选择 1 张照片")
|
||||||
XCTAssertTrue(tipsContainer.isHidden)
|
XCTAssertTrue(tipsContainer.isHidden)
|
||||||
XCTAssertTrue(tipsLabel.isHidden)
|
XCTAssertTrue(tipsLabel.isHidden)
|
||||||
|
collectionView.delegate?.collectionView?(
|
||||||
|
collectionView,
|
||||||
|
didSelectItemAt: IndexPath(item: 0, section: 2)
|
||||||
|
)
|
||||||
|
await waitUntil { confirmButton.isEnabled }
|
||||||
confirmButton.sendActions(for: .touchUpInside)
|
confirmButton.sendActions(for: .touchUpInside)
|
||||||
await waitUntil { api.aiReretouchRequests.count == 1 }
|
await waitUntil { api.aiReretouchRequests.count == 1 }
|
||||||
await waitUntil { submissionNotified }
|
await waitUntil { submissionNotified }
|
||||||
|
|
||||||
XCTAssertEqual(api.aiReretouchRequests.first?.type, .refined)
|
XCTAssertEqual(
|
||||||
|
api.aiReretouchRequests.first,
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: 7,
|
||||||
|
aiRetouchBatchId: 70,
|
||||||
|
type: .all,
|
||||||
|
refinedTemplateId: nil,
|
||||||
|
atmosphereTemplateId: 21
|
||||||
|
)
|
||||||
|
)
|
||||||
XCTAssertTrue(submissionNotified)
|
XCTAssertTrue(submissionNotified)
|
||||||
XCTAssertTrue(window.rootViewController === controller)
|
XCTAssertTrue(window.rootViewController === controller)
|
||||||
}
|
}
|
||||||
@@ -1149,9 +1299,8 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|||||||
XCTAssertFalse(controller.view.allLabels().contains { $0.text == "刷新失败" })
|
XCTAssertFalse(controller.view.allLabels().contains { $0.text == "刷新失败" })
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMaterialCellShowsSemanticStatusBadgeWithoutOverlappingSelectionCheck() throws {
|
func testMaterialCellShowsIndependentPurchaseAndAIRetouchBadgesWithoutOverlappingSelectionCheck() throws {
|
||||||
let cases: [(TravelAlbumMaterial, String, UInt)] = [
|
let cases: [(TravelAlbumMaterial, String, UInt)] = [
|
||||||
(TravelAlbumMaterial(isPurchased: true), "已购", 0x475569),
|
|
||||||
(TravelAlbumMaterial(aiRetouchStatus: 1), "待处理", 0xB45309),
|
(TravelAlbumMaterial(aiRetouchStatus: 1), "待处理", 0xB45309),
|
||||||
(TravelAlbumMaterial(aiRetouchStatus: 2), "修图中", 0x1D4ED8),
|
(TravelAlbumMaterial(aiRetouchStatus: 2), "修图中", 0x1D4ED8),
|
||||||
(TravelAlbumMaterial(aiRetouchStatus: 3), "AI已修", 0x047857),
|
(TravelAlbumMaterial(aiRetouchStatus: 3), "AI已修", 0x047857),
|
||||||
@@ -1170,12 +1319,12 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|||||||
|
|
||||||
let badgeView = try XCTUnwrap(
|
let badgeView = try XCTUnwrap(
|
||||||
cell.findSubview {
|
cell.findSubview {
|
||||||
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadge"
|
$0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadge"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
let badgeLabel = try XCTUnwrap(
|
let badgeLabel = try XCTUnwrap(
|
||||||
cell.findSubview {
|
cell.findSubview {
|
||||||
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadgeLabel"
|
$0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadgeLabel"
|
||||||
} as? UILabel
|
} as? UILabel
|
||||||
)
|
)
|
||||||
let selectionCheck = try XCTUnwrap(
|
let selectionCheck = try XCTUnwrap(
|
||||||
@@ -1188,22 +1337,61 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
|||||||
XCTAssertEqual(badgeLabel.text, expectedText)
|
XCTAssertEqual(badgeLabel.text, expectedText)
|
||||||
XCTAssertEqual(badgeView.backgroundColor?.travelAlbumTestHexRGB, expectedColor)
|
XCTAssertEqual(badgeView.backgroundColor?.travelAlbumTestHexRGB, expectedColor)
|
||||||
XCTAssertLessThan(badgeView.frame.maxX, selectionCheck.frame.minX)
|
XCTAssertLessThan(badgeView.frame.maxX, selectionCheck.frame.minX)
|
||||||
XCTAssertTrue(cell.accessibilityLabel?.contains("状态:\(expectedText)") == true)
|
XCTAssertTrue(cell.accessibilityLabel?.contains("修图状态:\(expectedText)") == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let combinedCell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220))
|
||||||
|
combinedCell.apply(
|
||||||
|
material: TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 2),
|
||||||
|
selectionMode: true,
|
||||||
|
selected: false
|
||||||
|
)
|
||||||
|
combinedCell.layoutIfNeeded()
|
||||||
|
let purchaseBadge = try XCTUnwrap(
|
||||||
|
combinedCell.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.materialPurchaseBadge"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let purchaseLabel = try XCTUnwrap(
|
||||||
|
combinedCell.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.materialPurchaseBadgeLabel"
|
||||||
|
} as? UILabel
|
||||||
|
)
|
||||||
|
let aiRetouchBadge = try XCTUnwrap(
|
||||||
|
combinedCell.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadge"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
XCTAssertFalse(purchaseBadge.isHidden)
|
||||||
|
XCTAssertFalse(aiRetouchBadge.isHidden)
|
||||||
|
XCTAssertEqual(purchaseLabel.text, "已购")
|
||||||
|
XCTAssertEqual(purchaseBadge.backgroundColor?.travelAlbumTestHexRGB, 0x475569)
|
||||||
|
XCTAssertGreaterThan(purchaseBadge.frame.minY, aiRetouchBadge.frame.maxY)
|
||||||
|
XCTAssertEqual(purchaseBadge.frame.minX, 6, accuracy: 0.5)
|
||||||
|
XCTAssertEqual(purchaseBadge.frame.maxY, 170, accuracy: 0.5)
|
||||||
|
XCTAssertTrue(combinedCell.accessibilityLabel?.contains("购买状态:已购") == true)
|
||||||
|
XCTAssertTrue(combinedCell.accessibilityLabel?.contains("修图状态:修图中") == true)
|
||||||
|
|
||||||
let hiddenCell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220))
|
let hiddenCell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220))
|
||||||
hiddenCell.apply(
|
hiddenCell.apply(
|
||||||
material: TravelAlbumMaterial(aiRetouchStatus: 0, aiRetouchStatusName: "已上传"),
|
material: TravelAlbumMaterial(aiRetouchStatus: 0, aiRetouchStatusName: "已上传"),
|
||||||
selectionMode: false,
|
selectionMode: false,
|
||||||
selected: false
|
selected: false
|
||||||
)
|
)
|
||||||
let hiddenBadge = try XCTUnwrap(
|
let hiddenPurchaseBadge = try XCTUnwrap(
|
||||||
hiddenCell.findSubview {
|
hiddenCell.findSubview {
|
||||||
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadge"
|
$0.accessibilityIdentifier == "travelAlbum.materialPurchaseBadge"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
XCTAssertTrue(hiddenBadge.isHidden)
|
let hiddenAIRetouchBadge = try XCTUnwrap(
|
||||||
XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("状态:") == true)
|
hiddenCell.findSubview {
|
||||||
|
$0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadge"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
XCTAssertTrue(hiddenPurchaseBadge.isHidden)
|
||||||
|
XCTAssertTrue(hiddenAIRetouchBadge.isHidden)
|
||||||
|
XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("购买状态:") == true)
|
||||||
|
XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("修图状态:") == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func waitUntil(_ condition: @escaping () -> Bool) async {
|
private func waitUntil(_ condition: @escaping () -> Bool) async {
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ final class TravelAlbumModelsTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPreviewProjectMapsCurrentTabToMinimalAIRetouchWorkflow() {
|
func testPreviewProjectUsesAllReretouchWorkflowForEveryVisibleVariantTab() {
|
||||||
let originalOnly = makePreviewProject(id: 9, kinds: [.original])
|
let originalOnly = makePreviewProject(id: 9, kinds: [.original])
|
||||||
let variants = TravelAlbumPreviewProject(
|
let variants = TravelAlbumPreviewProject(
|
||||||
originalMaterialId: 10,
|
originalMaterialId: 10,
|
||||||
@@ -286,11 +286,11 @@ final class TravelAlbumModelsTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
variants.aiRetouchWorkflow(albumId: 7, selectedKind: .retouched),
|
variants.aiRetouchWorkflow(albumId: 7, selectedKind: .retouched),
|
||||||
.reretouch(materialId: 10, batchId: 88, type: .refined)
|
.reretouch(materialId: 10, batchId: 88, type: .all)
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
variants.aiRetouchWorkflow(albumId: 7, selectedKind: .atmosphere),
|
variants.aiRetouchWorkflow(albumId: 7, selectedKind: .atmosphere),
|
||||||
.reretouch(materialId: 10, batchId: 88, type: .atmosphere)
|
.reretouch(materialId: 10, batchId: 88, type: .all)
|
||||||
)
|
)
|
||||||
XCTAssertNil(variants.aiRetouchWorkflow(albumId: 7, selectedKind: .cover))
|
XCTAssertNil(variants.aiRetouchWorkflow(albumId: 7, selectedKind: .cover))
|
||||||
}
|
}
|
||||||
@@ -399,34 +399,48 @@ final class TravelAlbumModelsTests: XCTestCase {
|
|||||||
XCTAssertEqual(material.aiAtmosphereURL, "")
|
XCTAssertEqual(material.aiAtmosphereURL, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testTravelAlbumMaterialBadgePriorityAndFallbacks() {
|
func testTravelAlbumMaterialPurchaseAndAIRetouchBadgesAreIndependent() {
|
||||||
XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 0).badgePresentation)
|
XCTAssertNil(TravelAlbumMaterial().purchaseBadgePresentation)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 0, aiRetouchStatusName: "已上传")
|
TravelAlbumMaterial(isPurchased: true).purchaseBadgePresentation,
|
||||||
.badgePresentation,
|
TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
||||||
|
)
|
||||||
|
XCTAssertNil(TravelAlbumMaterial(isPurchased: true).aiRetouchBadgePresentation)
|
||||||
|
let purchasedAndProcessing = TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 2)
|
||||||
|
XCTAssertEqual(
|
||||||
|
purchasedAndProcessing.purchaseBadgePresentation,
|
||||||
TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
TravelAlbumMaterial(aiRetouchStatus: 1, aiRetouchStatusName: " 排队中 ").badgePresentation,
|
purchasedAndProcessing.aiRetouchBadgePresentation,
|
||||||
TravelAlbumMaterialBadgePresentation(kind: .pending, text: "排队中")
|
|
||||||
)
|
|
||||||
XCTAssertEqual(
|
|
||||||
TravelAlbumMaterial(aiRetouchStatus: 2, aiRetouchStatusName: " ").badgePresentation,
|
|
||||||
TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中")
|
TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中")
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
TravelAlbumMaterial(aiRetouchStatus: 3).badgePresentation,
|
TravelAlbumMaterial(aiRetouchStatus: 1, aiRetouchStatusName: " 排队中 ")
|
||||||
|
.aiRetouchBadgePresentation,
|
||||||
|
TravelAlbumMaterialBadgePresentation(kind: .pending, text: "排队中")
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
TravelAlbumMaterial(aiRetouchStatus: 2, aiRetouchStatusName: " ").aiRetouchBadgePresentation,
|
||||||
|
TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中")
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
TravelAlbumMaterial(aiRetouchStatus: 3).aiRetouchBadgePresentation,
|
||||||
TravelAlbumMaterialBadgePresentation(kind: .retouched, text: "AI已修")
|
TravelAlbumMaterialBadgePresentation(kind: .retouched, text: "AI已修")
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面").badgePresentation,
|
TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面")
|
||||||
|
.aiRetouchBadgePresentation,
|
||||||
TravelAlbumMaterialBadgePresentation(kind: .cover, text: "AI封面")
|
TravelAlbumMaterialBadgePresentation(kind: .cover, text: "AI封面")
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
TravelAlbumMaterial(aiRetouchStatus: 4).badgePresentation,
|
TravelAlbumMaterial(aiRetouchStatus: 4).aiRetouchBadgePresentation,
|
||||||
TravelAlbumMaterialBadgePresentation(kind: .failed, text: "失败")
|
TravelAlbumMaterialBadgePresentation(kind: .failed, text: "失败")
|
||||||
)
|
)
|
||||||
XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 99, aiRetouchStatusName: "未知").badgePresentation)
|
XCTAssertNil(
|
||||||
|
TravelAlbumMaterial(aiRetouchStatus: 99, aiRetouchStatusName: "未知")
|
||||||
|
.aiRetouchBadgePresentation
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDisplayFormatters() {
|
func testDisplayFormatters() {
|
||||||
|
|||||||
@@ -279,14 +279,22 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(TravelAlbumDisplayFormatter.creationTimeText(""), "--")
|
XCTAssertEqual(TravelAlbumDisplayFormatter.creationTimeText(""), "--")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOnlyUnpurchasedMaterialCanBeSelected() {
|
func testAllMaterialsCanBeSelectedAndPurchasedTabSupportsSelectionMode() async {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.materialListResponses = [TravelAlbumListResponse(total: 1, list: [])]
|
||||||
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||||
|
await viewModel.selectTab(.purchased, api: api)
|
||||||
viewModel.toggleSelectionMode()
|
viewModel.toggleSelectionMode()
|
||||||
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 2))
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, isPurchased: true))
|
||||||
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 2, isPurchased: false))
|
||||||
|
|
||||||
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 1))
|
XCTAssertTrue(viewModel.isSelectionMode)
|
||||||
XCTAssertEqual(viewModel.selectedMaterialIds, [1])
|
XCTAssertEqual(viewModel.selectedMaterialIds, [1, 2])
|
||||||
|
XCTAssertEqual(viewModel.selectedPurchasedMaterialCount, 1)
|
||||||
|
XCTAssertEqual(
|
||||||
|
viewModel.deleteSelectedConfirmationMessage,
|
||||||
|
"已选择 2 张素材,其中 1 张已购买。不会删除已购买的项目,只会删除 1 张未购买的项目。"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCompletingAIRetouchClearsSelectionModeAndSelectedMaterials() {
|
func testCompletingAIRetouchClearsSelectionModeAndSelectedMaterials() {
|
||||||
@@ -405,6 +413,42 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
|||||||
XCTAssertFalse(viewModel.isSelectionMode)
|
XCTAssertFalse(viewModel.isSelectionMode)
|
||||||
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDeleteSelectedMaterialsFiltersPurchasedItemsFromBatchRequest() async {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.materialListResponses = [
|
||||||
|
TravelAlbumListResponse(total: 0, list: []),
|
||||||
|
TravelAlbumListResponse(total: 0, list: []),
|
||||||
|
TravelAlbumListResponse(total: 0, list: []),
|
||||||
|
]
|
||||||
|
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
|
||||||
|
viewModel.toggleSelectionMode()
|
||||||
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 10, isPurchased: true))
|
||||||
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 11, isPurchased: false))
|
||||||
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 12, isPurchased: true))
|
||||||
|
|
||||||
|
await viewModel.deleteSelectedMaterials(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.deletedMaterialIDBatches, [[11]])
|
||||||
|
XCTAssertFalse(viewModel.isSelectionMode)
|
||||||
|
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeleteSelectedMaterialsDoesNotRequestAPIWhenAllSelectedItemsArePurchased() async {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
|
||||||
|
var message = ""
|
||||||
|
viewModel.onShowMessage = { message = $0 }
|
||||||
|
viewModel.toggleSelectionMode()
|
||||||
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 10, isPurchased: true))
|
||||||
|
|
||||||
|
await viewModel.deleteSelectedMaterials(api: api)
|
||||||
|
|
||||||
|
XCTAssertTrue(api.deletedMaterialIDBatches.isEmpty)
|
||||||
|
XCTAssertEqual(message, "选中的素材均已购买,未删除任何项目")
|
||||||
|
XCTAssertTrue(viewModel.isSelectionMode)
|
||||||
|
XCTAssertEqual(viewModel.selectedMaterialIds, [10])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 有线传输 ViewModel 测试。
|
/// 有线传输 ViewModel 测试。
|
||||||
|
|||||||
Reference in New Issue
Block a user