feat: add AI retouch task center

This commit is contained in:
2026-08-14 15:06:57 +08:00
parent 0f3b26991e
commit 30bfe0313f
26 changed files with 3679 additions and 50 deletions
+57 -1
View File
@@ -5,25 +5,32 @@ enum PushDestination: Sendable, Equatable {
case paymentRecord
case paymentDetails
case messageCenter
case aiRetouchTaskList
case aiRetouchTaskDetail(batchId: Int)
}
/// 极光/APNs payload 解析结果,仅提取业务消息类型用于点击路由。
struct PushPayload: Sendable, Equatable {
private let type: String
private let aiRetouchBatchId: Int?
/// 从系统通知 userInfo 提取顶层或推送包装层中的业务消息类型。
nonisolated init(userInfo: [AnyHashable: Any]) {
type = Self.extractType(from: userInfo)
aiRetouchBatchId = Self.extractBatchId(from: userInfo)
}
/// 从可发送的字符串字典创建 payload,仅保留业务消息类型。
nonisolated init(values: [String: String]) {
type = Self.normalizedType(values["type"])
aiRetouchBatchId = Int(values["ai_retouch_batch_id"] ?? "").flatMap { $0 > 0 ? $0 : nil }
}
/// 可发送的规范化字段快照。
nonisolated var normalizedValues: [String: String] {
type.isEmpty ? [:] : ["type": type]
var values = type.isEmpty ? [:] : ["type": type]
if let aiRetouchBatchId { values["ai_retouch_batch_id"] = String(aiRetouchBatchId) }
return values
}
/// 仅根据后端业务消息类型解析目标页面。
@@ -33,6 +40,11 @@ struct PushPayload: Sendable, Equatable {
return .paymentRecord
case "6":
return .paymentDetails
case "14":
if let aiRetouchBatchId, aiRetouchBatchId > 0 {
return .aiRetouchTaskDetail(batchId: aiRetouchBatchId)
}
return .aiRetouchTaskList
default:
return .messageCenter
}
@@ -80,6 +92,50 @@ struct PushPayload: Sendable, Equatable {
}
}
private nonisolated static func extractBatchId(
from dictionary: [AnyHashable: Any],
depth: Int = 0
) -> Int? {
guard depth <= 5 else { return nil }
if let value = normalizedPositiveInt(dictionary["ai_retouch_batch_id"]) { return value }
if let data = dictionary["data"] {
if let nested = data as? [AnyHashable: Any],
let value = extractBatchId(from: nested, depth: depth + 1) { return value }
if let text = data as? String,
let nested = jsonDictionary(text),
let value = extractBatchId(from: nested, depth: depth + 1) { return value }
}
for key in nestedTypeContainerKeys {
guard let value = dictionary[key] else { continue }
if let nested = value as? [AnyHashable: Any],
let batchId = extractBatchId(from: nested, depth: depth + 1) { return batchId }
if let text = value as? String,
let nested = jsonDictionary(text),
let batchId = extractBatchId(from: nested, depth: depth + 1) { return batchId }
}
return nil
}
private nonisolated static func normalizedPositiveInt(_ value: Any?) -> Int? {
let parsed: Int?
switch value {
case let number as NSNumber: parsed = number.intValue
case let string as String: parsed = Int(string.trimmingCharacters(in: .whitespacesAndNewlines))
default: parsed = nil
}
return parsed.flatMap { $0 > 0 ? $0 : nil }
}
private nonisolated static func jsonDictionary(_ text: String) -> [AnyHashable: Any]? {
guard let data = text.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data),
let dictionary = object as? [String: Any]
else { return nil }
return Dictionary(uniqueKeysWithValues: dictionary.map { (AnyHashable($0.key), $0.value) })
}
private nonisolated static let nestedTypeContainerKeys = [
"extras", "extra", "JMessageExtra", "n_extras",
]
@@ -30,6 +30,37 @@ enum MessageJSONValue: Decodable, Hashable, Sendable {
self = .object(try container.decode([String: MessageJSONValue].self))
}
}
/// 将消息字段转换为合法的正整数标识;兼容后端返回数字或数字字符串。
var positiveInt: Int? {
let value: Int?
switch self {
case let .number(number):
guard number.isFinite, number.rounded() == number else { return nil }
value = Int(exactly: number)
case let .string(string):
value = Int(string.trimmingCharacters(in: .whitespacesAndNewlines))
default:
value = nil
}
guard let value, value > 0 else { return nil }
return value
}
/// 返回对象类型的原始键值。
var objectValue: [String: MessageJSONValue]? {
switch self {
case let .object(value):
return value
case let .string(value):
guard let data = value.data(using: .utf8),
let decoded = try? JSONDecoder().decode(MessageJSONValue.self, from: data)
else { return nil }
return decoded.objectValue
default:
return nil
}
}
}
/// 消息未读数量响应,用于同步首页红点和桌面图标角标。
@@ -203,6 +234,20 @@ struct MessageItem: Decodable, Hashable, Sendable {
return MessageDateFormatter.formatDateTime(createdAt) ?? createdAt
}
/// 是否为 AI 修图任务通知;人工修图的 `type = 10` 不属于该类型。
var isAIRetouchTaskNotification: Bool {
type == 14
}
/// AI 修图批次 ID,兼容 `extra_data` 直接字段及 `extra_data.data` 包装结构。
var aiRetouchBatchId: Int? {
guard isAIRetouchTaskNotification, let extraData else { return nil }
if let batchId = extraData["ai_retouch_batch_id"]?.positiveInt {
return batchId
}
return extraData["data"]?.objectValue?["ai_retouch_batch_id"]?.positiveInt
}
/// 返回已读状态的消息副本。
func markedRead() -> MessageItem {
MessageItem(
@@ -163,6 +163,16 @@ final class MessageDetailViewModel {
self.message = message
}
/// AI 修图任务通知在详情页展示任务入口。
var showsAIRetouchTaskAction: Bool {
message.isAIRetouchTaskNotification
}
/// 当前消息携带的 AI 修图批次 ID;缺失时由页面降级进入任务列表。
var aiRetouchBatchId: Int? {
message.aiRetouchBatchId
}
/// 删除当前消息。
func delete(api: any MessageCenterServing) async {
guard message.id > 0 else {
@@ -0,0 +1,814 @@
# AI 修图任务中心需求与接口设计
> 文档状态:待产品、后端、Android、iOS 联审
> 更新日期:2026-08-14
> 适用范围:随心瞰商家版 AI 修图任务,不包含人工修图任务
## 1. 背景与目标
AI 修图属于异步长耗时任务。当前用户提交后只能等待或主动返回相册刷新,无法明确知道任务是否仍在排队、预计何时完成、哪些照片成功或失败。
本需求增加:
1. 当前账号全部相册的 AI 修图任务列表。
2. 单次 AI 修图任务详情。
3. AI 修图终态消息推送。
4. 从推送、任务列表和提交成功提示进入对应任务的完整导航链路。
本期解决“看得到进度、完成会通知、结果可直达”的问题,不增加任务取消、批量重试、历史版本管理或后台供应商诊断能力。
## 2. 核心产品决策
### 2.1 推送跳转结论
AI 修图推送优先跳转到对应任务详情页。
原因:
- 用户点击完成通知时,核心意图是确认这一次任务的结果,而不是重新查找任务。
- 详情页可以直接表达成功、部分成功和失败,减少一次列表定位操作。
- `ai_retouch_batch_id` 是稳定任务标识,可以支撑前台、后台和冷启动直达。
以下情况降级进入任务列表:
- 推送缺少 `ai_retouch_batch_id`。
- ID 类型异常、为 0 或负数。
- 详情接口返回任务不存在、已失效或当前账号无权访问。
- 未来客户端收到无法识别的 AI 修图终态数据。
### 2.2 推送类型
| `type` | 业务含义 | 本需求处理 |
|---:|---|---|
| `10` | 人工修图完成通知 | 保持原业务语义和原点击行为,不作复用 |
| `14` | AI 修图任务通知 | 新增,按 `ai_retouch_batch_id` 进入 AI 修图任务详情 |
后端需在 `PushMsg` 类型定义及类型名称映射中增加:
```text
14 => AI修图任务通知
```
### 2.3 任务唯一标识
列表、详情、推送和提交响应统一使用已有的 `ai_retouch_batch_id`,类型固定为正整数。不得再引入另一套 `job_id`,避免与素材接口和重新修图接口中的批次标识无法对应。
### 2.4 列表范围
任务列表展示当前登录账号有权限查看的全部相册任务,不要求用户先进入某个相册。任务卡必须显示所属相册信息。
## 3. 用户流程与入口
### 3.1 主流程
```text
相册管理选择照片
→ 提交 AI 修图
→ 后端返回 ai_retouch_batch_id
→ 客户端提示“AI修图任务已提交”并提供“查看任务”
→ 用户可离开页面
→ 任务进入终态后收到 type = 14 推送
→ 点击推送进入任务详情
→ 查看成功结果或进入相册处理失败项
```
### 3.2 页面入口
- 相册管理页导航栏右侧增加“修图任务”,进入当前账号的全局任务列表;新增相册页不展示该入口。
- AI 修图提交成功提示提供“查看任务”,直接进入本次任务详情。
- 任务列表点击卡片进入对应任务详情。
- `type = 14` 推送携带有效任务 ID 时直达详情,否则进入任务列表。
## 4. 任务状态定义
后端状态值必须稳定,客户端根据枚举映射中文,不依赖后端返回的中文状态名。
| 状态 | 是否终态 | 列表分组 | 中文展示 | 说明 |
|---|---|---|---|---|
| `queued` | 否 | 进行中 | 排队中 | 已受理,尚无子任务开始 |
| `processing` | 否 | 进行中 | 修图中 | 至少一个子任务开始,尚未全部结束 |
| `succeeded` | 是 | 已完成 | 已完成 | 所有子任务成功 |
| `partially_succeeded` | 是 | 已完成 | 部分完成 | 子任务全部结束,既有成功也有失败或取消 |
| `failed` | 是 | 失败 | 处理失败 | 子任务全部结束,没有成功结果 |
| `canceled` | 是 | 失败 | 已取消 | 任务因素材删除或后台操作被取消 |
进度必须满足:
```text
total = queued + processing + succeeded + failed + canceled
completed = succeeded + failed + canceled
```
客户端进度百分比只能通过 `completed / total` 计算;`total <= 0` 时隐藏进度百分比,不显示虚构进度。
## 5. 任务列表页
### 5.1 设计稿
![AI 修图任务列表设计稿](../../../docs/design/ai-retouch-task-center/01-task-list.png)
### 5.2 页面结构
1. 导航栏
- 返回按钮。
- 标题“AI修图任务”。
2. 状态筛选
- 全部。
- 进行中。
- 已完成。
- 失败。
3. 任务卡列表
- 所属相册名称。
- 用户脱敏手机号。
- 提交时间。
- 任务编号,例如“任务 #9521”。
- 1 至 3 张原图缩略图;多余图片通过数量表达,不继续横向堆叠。
- 输出摘要,例如“精修 4 张 · 氛围感 4 张 · 封面 1 张”。
- 状态图标与文字标签。
- 进度或终态结果摘要。
- 详情指示。
### 5.3 状态展示
| 状态 | 卡片主信息 | 辅助信息 |
|---|---|---|
| `queued` | 排队中 | 有 ETA 时显示预计完成时间,否则显示“完成后将通过消息通知” |
| `processing` | 已完成 N / M | 显示进度条与预计完成时间 |
| `succeeded` | N 张结果已生成 | 显示完成时间 |
| `partially_succeeded` | N 张成功 · M 张失败 | 使用橙色警示,不按整单失败展示 |
| `failed` | 处理失败 | 引导点击查看详情 |
| `canceled` | 已取消 | 显示取消时间;不展示供应商或内部日志 |
### 5.4 失败信息展示
- 列表只承担任务级概览,不逐张展开失败原因。
- `partially_succeeded` 使用“N 张成功 · M 张失败”作为唯一结果摘要;`failed` 使用“失败”,不再增加独立的失败摘要行。
- 列表卡整体可点击并保留“查看详情”指示,具体失败原因统一进入详情查看,避免与结果数量重复。
- 后端返回的 `failure_summary` 继续解析并保留,供通知、无逐项错误数据等降级场景使用;列表首版不直接展示。
- 详情里的逐照片 `error.message` 仍是用户查看失败原因的主要信息来源,不展示原始错误码。
### 5.5 刷新与分页
- 首次进入、下拉刷新和 App 回到前台时拉取第一页。
- 页面可见且存在 `queued` 或 `processing` 任务时,每 15 秒刷新第一页。
- 页面离开、App 进入后台或页面内所有任务终态后停止定时刷新。
- 加载更多使用不透明游标;客户端不得解析或拼接游标。
- 服务端排序固定为 `created_at DESC, ai_retouch_batch_id DESC`,避免同一时间创建的任务分页不稳定。
- 刷新第一页时按 `ai_retouch_batch_id` 合并,不能产生重复卡片。
### 5.6 空态和异常
| 场景 | 展示 |
|---|---|
| 账号从未提交任务 | “暂无AI修图任务”与“提交修图后可在这里查看进度” |
| 当前筛选无数据 | “暂无该状态的任务” |
| 首屏加载失败 | 错误说明和“重新加载” |
| 加载更多失败 | 保留现有列表,底部提供重试 |
## 6. 任务详情页
### 6.1 设计稿
![AI 修图任务详情设计稿 V2](../../../docs/design/ai-retouch-task-center/02-task-detail-v2.png)
### 6.2 页面结构
1. 导航栏
- 返回按钮。
- 标题“任务详情”。
- 手动刷新按钮。
2. 状态摘要卡
- 任务状态图标和文字。
- 完成数量与进度条。
- 预计完成时间或实际完成时间。
- 非终态展示“完成后将通过消息通知你”。
3. 相册与任务信息
- 相册封面、相册名称、脱敏手机号。
- 任务编号、提交时间、开始时间、完成时间或处理耗时。
4. 任务内容
- 精修、氛围感、封面的目标数量。
- 额度预占、实际消耗和释放数量。
5. 处理明细
- 按原图组织精修和氛围感子任务。
- 封面作为独立明细。
- 每项展示照片缩略图、文件名、模板名称和状态。
- 某个输出失败时,在该照片、该输出项内部紧邻状态展示后端失败原因。
6. 页面操作
- 成功结果:“查看结果”,进入照片预览对应 Tab。
- 失败结果:仅展示后端返回的用户可理解失败原因,不在失败原因后追加操作按钮。
- 页面底部始终可提供“查看相册”。
### 6.3 失败原因展示
失败原因必须和失败照片、失败输出类型绑定展示,不使用全局弹窗、Toast 或脱离上下文的页面顶部提示代替。
| 场景 | 展示规则 |
|---|---|
| 单个精修/氛围感失败 | 在对应输出项状态“生成失败”下方显示“失败原因:{error.message}” |
| 同一照片两种输出均失败 | 精修、氛围感分别显示各自原因,不合并为一个模糊原因 |
| 封面失败 | 在封面独立明细下方显示原因 |
| 任务仍在处理中但已有失败项 | 立即展示已确定的照片失败原因,不等整批任务终态 |
| 后端原因为空 | 展示客户端兜底“处理失败,请稍后重试” |
| 原因超过两行 | 默认显示两行并提供“查看完整原因”;辅助功能朗读完整文本 |
视觉规则:
- 使用红色错误图标、红色“生成失败”和淡红色原因容器;颜色不是唯一状态提示。
- `error.message` 使用正文级字号和足够对比度,不使用脚注小字弱化重要信息。
- 不展示 `error.code`、供应商名称、堆栈、请求 ID 或服务器路径。
- 无论 `retryable` 取值如何,失败原因区域均只展示原因;用户仍可通过详情页底部“查看相册”进入相册管理页。
- 用户返回页面或手动刷新后,失败原因随接口最新值更新。
### 6.4 刷新规则
- 详情首次出现时立即请求最新数据。
- 非终态且页面可见时每 8 秒刷新。
- 进入终态、页面离开或 App 进入后台时停止刷新。
- 手动刷新与定时刷新不能并发发起重复请求。
- 推送不作为唯一状态来源;用户关闭通知权限后仍可通过页面刷新获得终态。
### 6.5 查看结果规则
- `output_type = refined`:打开来源原图的照片预览,默认选中“精修后”。
- `output_type = atmosphere`:打开来源原图的照片预览,默认选中“氛围感”。
- `output_type = cover`:打开所属相册,并定位到生成的封面素材;无法定位时进入相册第一页。
- 结果资源已删除或不可用时,隐藏“查看结果”并展示“结果已失效”。
## 7. 视觉与可访问性规范
### 7.1 视觉 Token
| 用途 | 建议值 |
|---|---|
| 页面背景 | `#F5F7FB` |
| 卡片背景 | `#FFFFFF` |
| 品牌主色 | `#1677FF` |
| 蓝色高光 | `#3A91FF` |
| 主文字 | `#111827` |
| 次文字 | `#64748B` |
| 成功 | `#22A06B` |
| 警示/部分完成 | `#F59E0B` |
| 失败 | `#EF4444` |
| 卡片圆角 | 12–16 pt |
### 7.2 交互要求
- 状态必须同时使用图标、文字和颜色,不得只用颜色区分。
- 正文文字与背景对比度至少 4.5:1。
- 卡片、筛选项和操作按钮的最小触控区域为 44 × 44 pt。
- 动态字体至少覆盖系统默认至辅助功能常用档位;文字放大时允许卡片增高。
- 进度动画尊重“减弱动态效果”;关闭动画后保留静态进度与文字。
- 网络图片使用缩略图地址,并提供占位图和失败占位状态。
## 8. 后端接口总览
基础路径:
```text
/api/yf-handset-app/photog/travel-album
```
| 方法 | 路径 | 类型 | 用途 |
|---|---|---|---|
| `POST` | `/ai-retouch` | 扩展现有响应 | 首次/批量提交后返回任务标识 |
| `POST` | `/ai-reretouch` | 扩展现有响应 | 重新修图提交后返回任务标识 |
| `GET` | `/ai-retouch-job-list` | 新增 | 获取当前账号全部 AI 修图任务 |
| `GET` | `/ai-retouch-job-info` | 实现并统一 | 获取指定任务详情 |
时间字段统一使用 ISO 8601 UTC,例如:
```text
2026-08-14T06:26:12.123Z
```
所有 ID 的 JSON 类型必须稳定;本需求中的 `ai_retouch_batch_id`、`user_equity_travel_id` 和素材 ID 均使用整数。
## 9. 扩展 AI 修图提交响应
现有 `/ai-retouch` 和 `/ai-reretouch` 请求体保持不变,成功后统一返回可供跳转和立即展示的任务摘要。
HTTP 状态码:`202 Accepted`
```json
{
"code": 100000,
"msg": "AI修图任务已提交",
"data": {
"ai_retouch_batch_id": 9521,
"user_equity_travel_id": 88,
"status": "queued",
"progress": {
"total": 9,
"queued": 9,
"processing": 0,
"succeeded": 0,
"failed": 0,
"canceled": 0
},
"created_at": "2026-08-14T06:26:12.123Z"
}
}
```
必有字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| `ai_retouch_batch_id` | int | 大于 0 的任务唯一标识 |
| `user_equity_travel_id` | int | 所属相册 ID |
| `status` | string | 提交成功时通常为 `queued` |
| `progress` | object | 提交时的真实子任务数量 |
| `created_at` | string | 服务端受理时间 |
客户端超时后使用原幂等键重试时,后端必须返回同一个 `ai_retouch_batch_id`,不得创建重复任务。
## 10. 获取 AI 修图任务列表
### 10.1 请求
```http
GET /api/yf-handset-app/photog/travel-album/ai-retouch-job-list?status_group=in_progress&limit=20&cursor=<opaque_cursor>
Authorization: Bearer <token>
```
| 参数 | 必填 | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
| `status_group` | 否 | string | `all` | `all`、`in_progress`、`completed`、`failed` |
| `limit` | 否 | int | `20` | 最小 1,最大 50 |
| `cursor` | 否 | string | — | 服务端返回的不透明游标,第一页不传 |
服务端分组映射:
| `status_group` | 包含状态 |
|---|---|
| `all` | 全部六种状态 |
| `in_progress` | `queued`、`processing` |
| `completed` | `succeeded`、`partially_succeeded` |
| `failed` | `failed`、`canceled` |
### 10.2 响应
```json
{
"code": 100000,
"msg": "success",
"data": {
"items": [
{
"ai_retouch_batch_id": 9521,
"user_equity_travel_id": 88,
"scope": "batch",
"status": "processing",
"album": {
"id": 88,
"name": "旅拍相册",
"user_phone": "13812348000",
"cover_url": "https://cdn.example.com/albums/88/cover.jpg"
},
"source_count": 4,
"outputs": [
{
"type": "refined",
"count": 4
},
{
"type": "atmosphere",
"count": 4
},
{
"type": "cover",
"count": 1
}
],
"preview_images": [
{
"material_id": 2031,
"thumbnail_url": "https://cdn.example.com/albums/88/2031_thumb.jpg"
},
{
"material_id": 2032,
"thumbnail_url": "https://cdn.example.com/albums/88/2032_thumb.jpg"
},
{
"material_id": 2033,
"thumbnail_url": "https://cdn.example.com/albums/88/2033_thumb.jpg"
}
],
"progress": {
"total": 9,
"queued": 3,
"processing": 1,
"succeeded": 5,
"failed": 0,
"canceled": 0
},
"estimated_finish_at": "2026-08-14T06:32:00.000Z",
"failure_summary": null,
"created_at": "2026-08-14T06:26:12.123Z",
"started_at": "2026-08-14T06:26:18.000Z",
"finished_at": null
}
],
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0xNFQwNjoyNjoxMi4xMjNaIiwiaWQiOjk1MjF9",
"has_more": true
}
}
```
### 10.3 列表字段约定
| 字段 | 必有 | 说明 |
|---|---|---|
| `album` | 是 | 服务端按登录态校验后返回,客户端不再逐任务查询相册 |
| `album.user_phone` | 否 | 返回原始手机号时客户端脱敏;没有时返回空字符串,不返回多种类型 |
| `outputs` | 是 | 各输出类型的计划生成数量 |
| `preview_images` | 是 | 最多返回 3 项,允许为空数组 |
| `progress` | 是 | 六种子任务数量必须满足进度恒等式 |
| `estimated_finish_at` | 否 | 无法估算或已终态时返回 `null` |
| `failure_summary` | 否 | 任务级可展示摘要;`failed` 时必有,其他状态存在失败子任务时建议返回 |
| `next_cursor` | 是 | 无下一页时为 `null` |
| `has_more` | 是 | 与 `next_cursor` 语义一致 |
`estimated_finish_at` 是动态估算而非 SLA。估算变化时允许更新;客户端只展示最新值,不做倒计时承诺。
`failure_summary` 不代替逐照片失败原因。存在多个不同失败原因时,列表建议返回“N 张照片处理失败,点击查看原因”;只有单一且简短的原因时才直接返回该原因。建议限制在 60 个中文字符以内。
## 11. 获取 AI 修图任务详情
### 11.1 请求
```http
GET /api/yf-handset-app/photog/travel-album/ai-retouch-job-info?ai_retouch_batch_id=9521
Authorization: Bearer <token>
```
| 参数 | 必填 | 类型 | 说明 |
|---|---|---|---|
| `ai_retouch_batch_id` | 是 | int | 大于 0 的 AI 修图批次 ID |
接口不要求客户端再传 `user_equity_travel_id`。后端应从登录态和任务归属完成权限校验,并在响应中返回相册信息。
### 11.2 响应
```json
{
"code": 100000,
"msg": "success",
"data": {
"ai_retouch_batch_id": 9521,
"user_equity_travel_id": 88,
"scope": "batch",
"status": "processing",
"album": {
"id": 88,
"name": "旅拍相册",
"user_phone": "13812348000",
"cover_url": "https://cdn.example.com/albums/88/cover.jpg"
},
"source_count": 4,
"outputs": [
{
"type": "refined",
"count": 4
},
{
"type": "atmosphere",
"count": 4
},
{
"type": "cover",
"count": 1
}
],
"progress": {
"total": 9,
"queued": 2,
"processing": 1,
"succeeded": 5,
"failed": 1,
"canceled": 0
},
"quota_settlement": {
"status": "partially_settled",
"reserved_units": 8,
"consumed_units": 5,
"released_units": 1,
"cover_units": 0
},
"targets": [
{
"target_id": 30101,
"source_material": {
"id": 2031,
"file_name": "IMG_8291.JPG",
"thumbnail_url": "https://cdn.example.com/albums/88/2031_thumb.jpg"
},
"input_material_ids": [2031],
"output_type": "refined",
"template": {
"id": 11,
"name": "自然通透"
},
"status": "succeeded",
"result_asset": {
"id": 9201,
"material_id": 2031,
"url": "https://cdn.example.com/albums/88/2031_refined_v2.jpg",
"thumbnail_url": "https://cdn.example.com/albums/88/2031_refined_v2_thumb.jpg"
},
"error": null,
"created_at": "2026-08-14T06:26:12.123Z",
"started_at": "2026-08-14T06:26:18.000Z",
"finished_at": "2026-08-14T06:28:40.000Z"
},
{
"target_id": 30102,
"source_material": {
"id": 2032,
"file_name": "IMG_8292.JPG",
"thumbnail_url": "https://cdn.example.com/albums/88/2032_thumb.jpg"
},
"input_material_ids": [2032],
"output_type": "atmosphere",
"template": {
"id": 21,
"name": "暖阳氛围"
},
"status": "processing",
"result_asset": null,
"error": null,
"created_at": "2026-08-14T06:26:12.123Z",
"started_at": "2026-08-14T06:29:02.000Z",
"finished_at": null
},
{
"target_id": 30103,
"source_material": {
"id": 2033,
"file_name": "IMG_8293.JPG",
"thumbnail_url": "https://cdn.example.com/albums/88/2033_thumb.jpg"
},
"input_material_ids": [2033],
"output_type": "refined",
"template": {
"id": 11,
"name": "自然通透"
},
"status": "failed",
"result_asset": null,
"error": {
"code": "AI_PROVIDER_TIMEOUT",
"message": "AI服务处理超时,请重新修图",
"retryable": true
},
"created_at": "2026-08-14T06:26:12.123Z",
"started_at": "2026-08-14T06:27:10.000Z",
"finished_at": "2026-08-14T06:29:30.000Z"
},
{
"target_id": 30109,
"source_material": null,
"input_material_ids": [2031, 2032, 2033, 2034],
"output_type": "cover",
"template": {
"id": 31,
"name": "旅拍拼贴"
},
"status": "queued",
"result_asset": null,
"error": null,
"created_at": "2026-08-14T06:26:12.123Z",
"started_at": null,
"finished_at": null
}
],
"estimated_finish_at": "2026-08-14T06:32:00.000Z",
"created_at": "2026-08-14T06:26:12.123Z",
"started_at": "2026-08-14T06:26:18.000Z",
"finished_at": null,
"duration_seconds": null
}
}
```
### 11.3 失败明细
失败子任务的 `error` 结构:
```json
{
"code": "AI_PROVIDER_TIMEOUT",
"message": "AI服务处理超时,请重新修图",
"retryable": true
}
```
约定:
- `status = failed` 时 `error` 和非空 `error.message` 必须返回,不能只返回错误码。
- `message` 必须是经过后端转换、可直接展示给用户的中文文案,建议不超过 120 个中文字符。
- `code` 用于客户端判断与联调排查,不直接展示。
- 不得返回供应商密钥、内部请求、堆栈、服务器路径或原始异常。
- `retryable` 只表达业务上是否允许重新提交,本期详情页不直接发起批量重试。
- `queued`、`processing`、`succeeded` 时 `error` 固定返回 `null`,不要返回空对象。
- 同一来源照片的精修和氛围感必须分别返回自己的 `error`,客户端不通过素材级公共错误猜测具体失败输出。
### 11.4 额度结算
| 状态 | 说明 |
|---|---|
| `reserved` | 已预占,尚无子任务完成 |
| `partially_settled` | 部分预占已转为消耗或释放 |
| `settled` | 所有额度完成结算 |
必须满足:
```text
reserved_units >= consumed_units + released_units
```
终态时应满足:
```text
reserved_units = consumed_units + released_units
```
封面不消耗额度,`cover_units` 固定为 0。
## 12. 接口错误码
| HTTP | 业务码 | 场景 | 客户端行为 |
|---:|---|---|---|
| 400 | `INVALID_RETOUCH_STATUS_GROUP` | 列表筛选参数错误 | 回退“全部”并记录联调日志 |
| 400 | `INVALID_CURSOR` | 游标无效或过期 | 清空游标并重新加载第一页 |
| 400 | `INVALID_RETOUCH_BATCH_ID` | 任务 ID 非法 | 进入任务列表 |
| 401 | `UNAUTHORIZED` | 登录态失效 | 走现有重新登录流程,登录后继续待处理路由 |
| 404 | `AI_RETOUCH_JOB_NOT_FOUND` | 不存在、已失效或无权访问 | 提示“任务不存在或已失效”,进入任务列表 |
| 429 | `TOO_MANY_REQUESTS` | 刷新过于频繁 | 停止本轮轮询,按服务端建议时间重试 |
| 500 | `AI_RETOUCH_JOB_QUERY_FAILED` | 服务端异常 | 保留已有数据并提供重试 |
权限不足建议统一返回 404,避免泄露其他账号任务是否存在。
## 13. AI 修图推送协议
### 13.1 发送条件
- 只在任务第一次从非终态进入 `succeeded`、`partially_succeeded` 或 `failed` 时发送。
- `canceled` 默认不发送通知。
- 同一 `ai_retouch_batch_id` 只发送一次终态推送。
- 后端应通过事务字段或唯一记录保证幂等,例如 `terminal_push_sent_at`。
- 设备没有有效极光 Registration ID 时不影响任务结算;用户仍可在任务中心查看结果。
### 13.2 统一业务结构
```json
{
"title": "AI修图已完成",
"content": "「旅拍相册」的9张结果已生成,点击查看。",
"msg_id": 2014,
"type": 14,
"data": {
"ai_retouch_batch_id": 9521,
"user_equity_travel_id": 88,
"status": "succeeded"
}
}
```
字段约定:
| 字段 | 必有 | 类型 | 说明 |
|---|---|---|---|
| `type` | 是 | int | 固定为 `14` |
| `msg_id` | 是 | int | 后端业务消息 ID,不是极光 `_j_msgid` |
| `data` | 是 | object | 不得发送为 JSON 字符串 |
| `ai_retouch_batch_id` | 是 | int | 大于 0;点击详情的主键 |
| `user_equity_travel_id` | 是 | int | 用于降级列表和联调排查 |
| `status` | 是 | string | 仅允许三个会推送的终态值 |
### 13.3 通知文案
| 状态 | 标题 | 正文模板 |
|---|---|---|
| `succeeded` | AI修图已完成 | `「{相册名}」的{成功数}张结果已生成,点击查看。` |
| `partially_succeeded` | AI修图部分完成 | `{成功数}张成功,{失败数}张失败,点击查看详情。` |
| `failed` | AI修图未完成 | `本次任务处理失败,点击查看原因。` |
相册名称为空时,正文使用“本次AI修图任务”,不得出现空书名号。
### 13.4 iOS 示例
```json
{
"aps": {
"alert": {
"title": "AI修图已完成",
"body": "「旅拍相册」的9张结果已生成,点击查看。"
},
"sound": "default"
},
"msg_id": 2014,
"type": 14,
"data": {
"ai_retouch_batch_id": 9521,
"user_equity_travel_id": 88,
"status": "succeeded"
}
}
```
Android 的 `extras` 中放入同一业务对象;字段名、字段类型和业务含义必须与 iOS 一致。
## 14. 推送点击路由
### 14.1 路由规则
```text
收到通知点击
→ 解析业务 type
→ type != 14:继续走现有类型路由
→ type = 14:解析 data.ai_retouch_batch_id
→ 合法:打开 AI 修图任务详情
→ 缺失/非法:打开 AI 修图任务列表
→ 详情请求 404:提示后返回任务列表
```
### 14.2 生命周期
- 前台、后台点击和冷启动使用同一套 `type + data` 解析规则。
- 未登录时暂存 `ai_retouch_batch_id`,登录成功且主 Tab 建立后继续路由。
- 账号切换或退出登录时清除上一账号尚未执行的待处理路由。
- 同一系统通知的 request identifier 只处理一次。
- 客户端不得从标题或正文解析任务 ID。
### 14.3 向后兼容
- 旧客户端无法识别 `type = 14` 时按既有默认逻辑进入消息中心,不应崩溃。
- 新客户端收到 `type = 10` 时仍按人工修图处理,不进入 AI 修图任务中心。
- 对 `data` 中新增的未知字段,客户端应忽略。
### 14.4 站内消息详情入口
- `type = 14` 的站内消息详情页在消息正文下方展示品牌蓝主按钮“查看任务详情”。
- 消息 `extra_data.ai_retouch_batch_id` 合法时进入对应任务详情;兼容 `extra_data.data.ai_retouch_batch_id` 包装结构。
- 任务 ID 缺失、无法解析或小于等于 0 时,按钮仍保留,点击后降级进入 AI 修图任务列表。
- `type = 10` 人工修图及其他类型消息不展示该入口,原有消息详情行为保持不变。
- 底部“删除并返回”继续作为独立的破坏性操作,不承担业务跳转职责。
## 15. 数据、安全与一致性
- 列表和详情只能返回当前登录账号有权限查看的任务。
- 后端从登录态确定账号范围,不接受客户端传入用户 ID 扩大查询范围。
- 手机号仅用于业务识别,客户端统一脱敏展示。
- 缩略图和结果 URL 应使用受控 CDN 地址;如需签名,过期时间应覆盖合理浏览时长。
- 任务状态和所有子任务状态必须在同一份一致性快照中返回。
- 推送发送失败不得回滚已完成的 AI 任务或额度结算。
- 已删除且不再允许客户端展示的素材,不应继续通过任务详情暴露原图或结果 URL。
- 如果任务已整体不可见,详情统一返回 `AI_RETOUCH_JOB_NOT_FOUND`。
## 16. 验收标准
### 16.1 页面
- 能从相册管理页进入当前账号全部 AI 修图任务列表,新增相册页不展示该入口。
- 四种筛选与六种状态映射正确。
- 进行中任务显示真实完成数量;有 ETA 才显示预计完成时间。
- 详情可展示任务摘要、相册信息、额度和逐输出明细。
- 单照片、单输出失败时,失败原因展示在对应明细内部,不需要用户从任务级摘要猜测失败对象。
- 后端原因超过两行时可查看完整内容;原因缺失时有稳定兜底文案。
- 成功结果可进入对应照片结果,失败项可进入所属相册。
- 页面离开或任务终态后停止轮询。
### 16.2 接口
- 两个提交接口返回稳定且可跳转的 `ai_retouch_batch_id`。
- 游标分页在任务新增和状态更新期间不重复、不漏项。
- 进度数量满足恒等式,任务终态与子任务状态一致。
- ETA 缺失时返回 `null`,不返回空字符串或 0 时间。
- `status = failed` 的子任务必有非空 `error.message`,且详情能够按照片和输出类型准确展示。
- 失败信息不包含供应商、堆栈、服务器路径等内部实现。
### 16.3 推送
- `type = 14` 未被其他业务占用,后端类型名称映射完整。
- `type = 10` 人工修图和 `type = 14` AI 修图互不影响。
- 成功、部分成功、失败各只发送一次终态通知。
- 前台、后台、冷启动、未登录和重复点击均符合路由规则。
- 缺少任务 ID、非法 ID、任务失效时均安全降级到任务列表。
## 17. 本期不做
- 从任务中心取消 AI 修图任务。
- 一键批量重试失败子任务。
- 展示 AI 供应商、内部错误堆栈或链路日志。
- 展示同一照片的历史修图版本。
- 为 ETA 提供 SLA 倒计时承诺。
- 修改 `type = 10` 人工修图协议。
## 18. 设计稿生成说明
两张设计稿使用内置 ImageGen 生成,参考现有 AI 修图 V2 的浅色相册管理页面、旅行摄影素材和状态视觉语言。
- 任务列表:853 × 1844,展示进行中、已完成、部分完成三种代表性卡片。
- 任务详情 V2:852 × 1846,展示处理中的任务、6/9 进度、ETA、消息通知说明,以及单照片“生成失败 + 失败原因”的内联状态。
- 设计稿用于产品和研发对齐;实际 UIKit 实现应使用项目内颜色、字体、SnapKit 和 SF Symbols,不把设计稿作为页面背景切图。
@@ -51,10 +51,20 @@ protocol TravelAlbumServing {
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse
/// 提交相册素材 AI 修图任务。
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission
/// 提交单张素材重新修图任务。
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws -> TravelAlbumAIJobSubmission
/// 拉取当前账号的 AI 修图任务列表。
func aiRetouchJobList(
statusGroup: TravelAlbumAIJobFilter,
limit: Int,
cursor: String?
) async throws -> TravelAlbumAIJobListResponse
/// 拉取指定 AI 修图任务详情。
func aiRetouchJobInfo(batchId: Int) async throws -> TravelAlbumAIJobDetail
}
@MainActor
@@ -197,17 +207,42 @@ final class TravelAlbumAPI: TravelAlbumServing {
)
}
/// 提交相册素材 AI 修图任务;服务端 data 内容无需客户端消费。
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request)
/// 提交相册素材 AI 修图任务并返回任务摘要。
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission {
try await client.send(APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request))
}
/// 提交重新修图任务并返回任务摘要。
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws -> TravelAlbumAIJobSubmission {
try await client.send(APIRequest(method: .post, path: "\(basePath)/ai-reretouch", body: request))
}
/// 拉取当前账号的 AI 修图任务列表。
func aiRetouchJobList(
statusGroup: TravelAlbumAIJobFilter,
limit: Int = 20,
cursor: String? = nil
) async throws -> TravelAlbumAIJobListResponse {
var queryItems = [
URLQueryItem(name: "status_group", value: statusGroup.rawValue),
URLQueryItem(name: "limit", value: String(min(50, max(1, limit)))),
]
if let cursor = cursor?.trimmingCharacters(in: .whitespacesAndNewlines), !cursor.isEmpty {
queryItems.append(URLQueryItem(name: "cursor", value: cursor))
}
return try await client.send(
APIRequest(method: .get, path: "\(basePath)/ai-retouch-job-list", queryItems: queryItems)
)
}
/// 提交重新修图任务;服务端返回的批次与额度信息当前无需消费。
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "\(basePath)/ai-reretouch", body: request)
/// 拉取指定 AI 修图任务详情。
func aiRetouchJobInfo(batchId: Int) async throws -> TravelAlbumAIJobDetail {
try await client.send(
APIRequest(
method: .get,
path: "\(basePath)/ai-retouch-job-info",
queryItems: [URLQueryItem(name: "ai_retouch_batch_id", value: String(batchId))]
)
)
}
}
@@ -0,0 +1,380 @@
import Foundation
/// AI 修图任务筛选分组,对应任务列表接口的 `status_group`。
enum TravelAlbumAIJobFilter: String, CaseIterable, Sendable, Hashable {
case all
case inProgress = "in_progress"
case completed
case failed
var title: String {
switch self {
case .all: "全部"
case .inProgress: "进行中"
case .completed: "已完成"
case .failed: "失败"
}
}
}
/// AI 修图任务及子任务状态;未知值安全降级,避免新增后端状态导致整页解码失败。
enum TravelAlbumAIJobStatus: Sendable, Hashable {
case queued
case processing
case succeeded
case partiallySucceeded
case failed
case canceled
case unknown(String)
init(rawValue: String) {
switch rawValue {
case "queued": self = .queued
case "processing": self = .processing
case "succeeded": self = .succeeded
case "partially_succeeded": self = .partiallySucceeded
case "failed": self = .failed
case "canceled": self = .canceled
default: self = .unknown(rawValue)
}
}
var rawValue: String {
switch self {
case .queued: "queued"
case .processing: "processing"
case .succeeded: "succeeded"
case .partiallySucceeded: "partially_succeeded"
case .failed: "failed"
case .canceled: "canceled"
case .unknown(let value): value
}
}
var isInProgress: Bool { self == .queued || self == .processing }
var isTerminal: Bool { !isInProgress && !isUnknown }
private var isUnknown: Bool { if case .unknown = self { true } else { false } }
var title: String {
switch self {
case .queued: "排队中"
case .processing: "修图中"
case .succeeded: "已完成"
case .partiallySucceeded: "部分完成"
case .failed: "失败"
case .canceled: "已取消"
case .unknown: "状态更新中"
}
}
}
extension TravelAlbumAIJobStatus: Decodable {
init(from decoder: Decoder) throws {
self.init(rawValue: (try? decoder.singleValueContainer().decode(String.self)) ?? "")
}
}
/// AI 修图输出类型。
enum TravelAlbumAIJobOutputType: Sendable, Hashable {
case refined
case atmosphere
case cover
case unknown(String)
init(rawValue: String) {
switch rawValue {
case "refined": self = .refined
case "atmosphere": self = .atmosphere
case "cover": self = .cover
default: self = .unknown(rawValue)
}
}
var title: String {
switch self {
case .refined: "原图精修"
case .atmosphere: "氛围感"
case .cover: "封面"
case .unknown: "其他结果"
}
}
var previewKind: TravelAlbumPreviewAssetKind? {
switch self {
case .refined: .retouched
case .atmosphere: .atmosphere
case .cover: .cover
case .unknown: nil
}
}
}
extension TravelAlbumAIJobOutputType: Decodable {
init(from decoder: Decoder) throws {
self.init(rawValue: (try? decoder.singleValueContainer().decode(String.self)) ?? "")
}
}
/// AI 修图子任务数量进度。
struct TravelAlbumAIJobProgress: Decodable, Sendable, Equatable, Hashable {
let total: Int
let queued: Int
let processing: Int
let succeeded: Int
let failed: Int
let canceled: Int
var completed: Int { min(total, succeeded + failed + canceled) }
var fraction: Double { total > 0 ? min(1, Double(completed) / Double(total)) : 0 }
}
/// AI 修图提交成功后返回的任务摘要。
struct TravelAlbumAIJobSubmission: Decodable, Sendable, Equatable {
let aiRetouchBatchId: Int
let userEquityTravelId: Int
let status: TravelAlbumAIJobStatus
let progress: TravelAlbumAIJobProgress
let createdAt: String
enum CodingKeys: String, CodingKey {
case aiRetouchBatchId = "ai_retouch_batch_id"
case userEquityTravelId = "user_equity_travel_id"
case status, progress
case createdAt = "created_at"
}
}
/// 任务所属相册摘要。
struct TravelAlbumAIJobAlbum: Decodable, Sendable, Equatable, Hashable {
let id: Int
let name: String
let userPhone: String
let coverURL: String
enum CodingKeys: String, CodingKey {
case id, name
case userPhone = "user_phone"
case coverURL = "cover_url"
}
}
/// 任务计划输出数量。
struct TravelAlbumAIJobOutput: Decodable, Sendable, Equatable, Hashable {
let type: TravelAlbumAIJobOutputType
let count: Int
}
/// 任务列表缩略图。
struct TravelAlbumAIJobPreviewImage: Decodable, Sendable, Equatable, Hashable {
let materialId: Int
let thumbnailURL: String
enum CodingKeys: String, CodingKey {
case materialId = "material_id"
case thumbnailURL = "thumbnail_url"
}
}
/// AI 修图任务列表项。
struct TravelAlbumAIJobSummary: Decodable, Sendable, Equatable, Hashable, Identifiable {
var id: Int { aiRetouchBatchId }
let aiRetouchBatchId: Int
let userEquityTravelId: Int
let scope: String
let status: TravelAlbumAIJobStatus
let album: TravelAlbumAIJobAlbum
let sourceCount: Int
let outputs: [TravelAlbumAIJobOutput]
let previewImages: [TravelAlbumAIJobPreviewImage]
let progress: TravelAlbumAIJobProgress
let estimatedFinishAt: String?
let failureSummary: String?
let createdAt: String
let startedAt: String?
let finishedAt: String?
enum CodingKeys: String, CodingKey {
case aiRetouchBatchId = "ai_retouch_batch_id"
case userEquityTravelId = "user_equity_travel_id"
case scope, status, album, outputs, progress
case sourceCount = "source_count"
case previewImages = "preview_images"
case estimatedFinishAt = "estimated_finish_at"
case failureSummary = "failure_summary"
case createdAt = "created_at"
case startedAt = "started_at"
case finishedAt = "finished_at"
}
var displayFailureSummary: String? {
guard status == .failed || status == .partiallySucceeded || progress.failed > 0 else { return nil }
let value = failureSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return value.isEmpty ? "部分照片处理失败,点击查看原因" : value
}
}
/// AI 修图任务游标分页响应。
struct TravelAlbumAIJobListResponse: Decodable, Sendable, Equatable {
let items: [TravelAlbumAIJobSummary]
let nextCursor: String?
let hasMore: Bool
enum CodingKeys: String, CodingKey {
case items
case nextCursor = "next_cursor"
case hasMore = "has_more"
}
}
/// AI 修图额度结算信息。
struct TravelAlbumAIJobQuotaSettlement: Decodable, Sendable, Equatable {
let status: String
let reservedUnits: Int
let consumedUnits: Int
let releasedUnits: Int
let coverUnits: Int
enum CodingKeys: String, CodingKey {
case status
case reservedUnits = "reserved_units"
case consumedUnits = "consumed_units"
case releasedUnits = "released_units"
case coverUnits = "cover_units"
}
}
/// AI 修图目标的来源素材。
struct TravelAlbumAIJobSourceMaterial: Decodable, Sendable, Equatable, Hashable {
let id: Int
let fileName: String
let thumbnailURL: String
enum CodingKeys: String, CodingKey {
case id
case fileName = "file_name"
case thumbnailURL = "thumbnail_url"
}
}
/// AI 修图目标使用的模板摘要。
struct TravelAlbumAIJobTemplate: Decodable, Sendable, Equatable, Hashable {
let id: Int
let name: String
}
/// AI 修图成功结果资源。
struct TravelAlbumAIJobResultAsset: Decodable, Sendable, Equatable, Hashable {
let id: Int
let materialId: Int
let url: String
let thumbnailURL: String
enum CodingKeys: String, CodingKey {
case id
case materialId = "material_id"
case url
case thumbnailURL = "thumbnail_url"
}
}
/// 可直接展示给用户的 AI 修图失败信息。
struct TravelAlbumAIJobError: Decodable, Sendable, Equatable, Hashable {
let code: String
let message: String
let retryable: Bool
var displayMessage: String {
let value = message.trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? "处理失败,请前往相册重新修图" : value
}
}
/// 单个输出目标的处理明细。
struct TravelAlbumAIJobTarget: Decodable, Sendable, Equatable, Hashable, Identifiable {
var id: Int { targetId }
let targetId: Int
let sourceMaterial: TravelAlbumAIJobSourceMaterial?
let inputMaterialIds: [Int]
let outputType: TravelAlbumAIJobOutputType
let template: TravelAlbumAIJobTemplate?
let status: TravelAlbumAIJobStatus
let resultAsset: TravelAlbumAIJobResultAsset?
let error: TravelAlbumAIJobError?
let createdAt: String
let startedAt: String?
let finishedAt: String?
enum CodingKeys: String, CodingKey {
case targetId = "target_id"
case sourceMaterial = "source_material"
case inputMaterialIds = "input_material_ids"
case outputType = "output_type"
case template, status, error
case resultAsset = "result_asset"
case createdAt = "created_at"
case startedAt = "started_at"
case finishedAt = "finished_at"
}
var displayFailureMessage: String? {
guard status == .failed else { return nil }
return error?.displayMessage ?? "处理失败,请前往相册重新修图"
}
}
/// AI 修图任务完整详情。
struct TravelAlbumAIJobDetail: Decodable, Sendable, Equatable {
let aiRetouchBatchId: Int
let userEquityTravelId: Int
let scope: String
let status: TravelAlbumAIJobStatus
let album: TravelAlbumAIJobAlbum
let sourceCount: Int
let outputs: [TravelAlbumAIJobOutput]
let progress: TravelAlbumAIJobProgress
let quotaSettlement: TravelAlbumAIJobQuotaSettlement
let targets: [TravelAlbumAIJobTarget]
let estimatedFinishAt: String?
let createdAt: String
let startedAt: String?
let finishedAt: String?
let durationSeconds: Int?
enum CodingKeys: String, CodingKey {
case aiRetouchBatchId = "ai_retouch_batch_id"
case userEquityTravelId = "user_equity_travel_id"
case scope, status, album, outputs, progress, targets
case sourceCount = "source_count"
case quotaSettlement = "quota_settlement"
case estimatedFinishAt = "estimated_finish_at"
case createdAt = "created_at"
case startedAt = "started_at"
case finishedAt = "finished_at"
case durationSeconds = "duration_seconds"
}
}
/// AI 修图任务日期展示工具。
enum TravelAlbumAIJobDateFormatter {
private static let internetFormatter = ISO8601DateFormatter()
private static let preciseFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
static func date(_ value: String?) -> Date? {
guard let value, !value.isEmpty else { return nil }
return preciseFormatter.date(from: value) ?? internetFormatter.date(from: value)
}
static func display(_ value: String?) -> String {
guard let date = date(value) else { return "--" }
return date.formatted(.dateTime.month().day().hour().minute())
}
static func time(_ value: String?) -> String {
guard let date = date(value) else { return "--" }
return date.formatted(.dateTime.hour().minute())
}
}
@@ -0,0 +1,144 @@
import Foundation
/// AI 修图任务列表状态,负责筛选、游标分页、去重和静默刷新。
final class TravelAlbumAIJobListViewModel {
private(set) var items: [TravelAlbumAIJobSummary] = []
private(set) var selectedFilter: TravelAlbumAIJobFilter = .all
private(set) var isLoading = false
private(set) var isRefreshing = false
private(set) var isLoadingMore = false
private(set) var errorMessage: String?
private var nextCursor: String?
private var hasMore = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var containsInProgressJobs: Bool { items.contains { $0.status.isInProgress } }
var canLoadMore: Bool { hasMore && !isLoadingMore }
/// 选择筛选项并重新加载第一页。
func selectFilter(_ filter: TravelAlbumAIJobFilter, api: any TravelAlbumServing) async {
guard selectedFilter != filter else { return }
selectedFilter = filter
items = []
notify()
await loadFirstPage(api: api)
}
/// 首次加载或下拉刷新第一页。
func loadFirstPage(api: any TravelAlbumServing, refreshing: Bool = false, silent: Bool = false) async {
guard !isLoading && !isRefreshing else { return }
if refreshing { isRefreshing = true } else if !silent { isLoading = true }
errorMessage = nil
notify()
defer {
isLoading = false
isRefreshing = false
notify()
}
await request(cursor: nil, reset: true, api: api, allowCursorRecovery: false, silent: silent)
}
/// 加载下一页。
func loadMore(api: any TravelAlbumServing) async {
guard canLoadMore, let cursor = nextCursor else { return }
isLoadingMore = true
notify()
defer { isLoadingMore = false; notify() }
await request(cursor: cursor, reset: false, api: api, allowCursorRecovery: true, silent: false)
}
private func request(
cursor: String?,
reset: Bool,
api: any TravelAlbumServing,
allowCursorRecovery: Bool,
silent: Bool
) async {
do {
let response = try await api.aiRetouchJobList(
statusGroup: selectedFilter,
limit: 20,
cursor: cursor
)
if reset {
items = response.items
} else {
var known = Set(items.map(\.id))
items += response.items.filter { known.insert($0.id).inserted }
}
nextCursor = response.nextCursor
hasMore = response.hasMore && response.nextCursor?.isEmpty == false
errorMessage = nil
} catch is CancellationError {
return
} catch {
let message = error.localizedDescription
if allowCursorRecovery && message.localizedCaseInsensitiveContains("cursor") {
nextCursor = nil
hasMore = false
await request(cursor: nil, reset: true, api: api, allowCursorRecovery: false, silent: silent)
return
}
if items.isEmpty { errorMessage = message.isEmpty ? "任务加载失败" : message }
if !silent { onShowMessage?(message.isEmpty ? "任务加载失败" : message) }
}
}
private func notify() { onStateChange?() }
}
/// AI 修图任务详情状态,负责刷新、404 识别和终态判断。
final class TravelAlbumAIJobDetailViewModel {
private(set) var detail: TravelAlbumAIJobDetail?
private(set) var isLoading = false
private(set) var isRefreshing = false
private(set) var errorMessage: String?
private(set) var isNotFound = false
let batchId: Int
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onNotFound: (() -> Void)?
init(batchId: Int) { self.batchId = batchId }
var shouldPoll: Bool { detail?.status.isInProgress == true }
/// 拉取任务详情;静默刷新失败时保留现有内容。
func load(api: any TravelAlbumServing, refreshing: Bool = false, silent: Bool = false) async {
guard batchId > 0, !isLoading && !isRefreshing else {
if batchId <= 0 { markNotFound() }
return
}
if refreshing { isRefreshing = true } else if detail == nil && !silent { isLoading = true }
errorMessage = nil
notify()
defer {
isLoading = false
isRefreshing = false
notify()
}
do {
detail = try await api.aiRetouchJobInfo(batchId: batchId)
isNotFound = false
} catch is CancellationError {
return
} catch APIError.httpStatus(let status, _) where status == 404 {
markNotFound()
} catch {
let message = error.localizedDescription.isEmpty ? "任务详情加载失败" : error.localizedDescription
if detail == nil { errorMessage = message }
if !silent { onShowMessage?(message) }
}
}
private func markNotFound() {
guard !isNotFound else { return }
isNotFound = true
onNotFound?()
}
private func notify() { onStateChange?() }
}
@@ -23,7 +23,7 @@ final class TravelAlbumAIRetouchTemplateViewModel {
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onSubmitted: (() -> Void)?
var onSubmitted: ((TravelAlbumAIJobSubmission) -> Void)?
/// 创建首次 AI 修图模板状态;素材 ID 会排序并去重,确保提交稳定。
convenience init(albumId: Int, scenicId: Int, materialIds: [Int]) {
@@ -217,13 +217,14 @@ final class TravelAlbumAIRetouchTemplateViewModel {
}
do {
let submission: TravelAlbumAIJobSubmission
switch workflow {
case .initial(let albumId, let materialIds):
guard let refinedTemplateId = selectedRefinedTemplateId else {
onShowMessage?("请选择原图精修模板")
return
}
try await api.submitAIRetouch(
submission = try await api.submitAIRetouch(
TravelAlbumAIRetouchRequest(
userEquityTravelId: albumId,
materialIds: materialIds,
@@ -233,7 +234,7 @@ final class TravelAlbumAIRetouchTemplateViewModel {
)
)
case .reretouch(let materialId, let batchId, let type):
try await api.submitAIReretouch(
submission = try await api.submitAIReretouch(
TravelAlbumAIReretouchRequest(
id: materialId,
aiRetouchBatchId: batchId,
@@ -243,7 +244,7 @@ final class TravelAlbumAIRetouchTemplateViewModel {
)
)
}
onSubmitted?()
onSubmitted?(submission)
} catch is CancellationError {
return
} catch {
@@ -81,6 +81,10 @@ final class MainTabBarController: UITabBarController {
controller = PaymentCollectionDetailsViewController()
case .messageCenter:
controller = MessageCenterViewController()
case .aiRetouchTaskList:
controller = TravelAlbumAIJobListViewController()
case .aiRetouchTaskDetail(let batchId):
controller = TravelAlbumAIJobDetailViewController(batchId: batchId)
}
if let controller {
@@ -12,6 +12,7 @@ final class MessageDetailViewController: BaseViewController {
private let viewModel: MessageDetailViewModel
private let api: any MessageCenterServing
private let travelAlbumAPI: any TravelAlbumServing
private let scrollView = UIScrollView()
private let contentView = UIView()
@@ -20,13 +21,19 @@ final class MessageDetailViewController: BaseViewController {
private let timeContainer = UIView()
private let timeLabel = UILabel()
private let bodyLabel = UILabel()
private let taskDetailButton = UIButton(type: .system)
private let bottomBar = UIView()
private let deleteButton = UIButton(type: .system)
/// 初始化消息详情页。
init(message: MessageItem, api: (any MessageCenterServing)? = nil) {
init(
message: MessageItem,
api: (any MessageCenterServing)? = nil,
travelAlbumAPI: (any TravelAlbumServing)? = nil
) {
viewModel = MessageDetailViewModel(message: message)
self.api = api ?? NetworkServices.shared.messageCenterAPI
self.travelAlbumAPI = travelAlbumAPI ?? NetworkServices.shared.travelAlbumAPI
super.init(nibName: nil, bundle: nil)
}
@@ -67,6 +74,15 @@ final class MessageDetailViewController: BaseViewController {
bodyLabel.numberOfLines = 0
bodyLabel.textAlignment = .left
taskDetailButton.setTitle("查看任务详情", for: .normal)
taskDetailButton.setTitleColor(.white, for: .normal)
taskDetailButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
taskDetailButton.backgroundColor = AppColor.primary
taskDetailButton.layer.cornerRadius = 10
taskDetailButton.clipsToBounds = true
taskDetailButton.isHidden = !viewModel.showsAIRetouchTaskAction
taskDetailButton.accessibilityLabel = "查看AI修图任务详情"
bottomBar.backgroundColor = .white
deleteButton.setTitle("删除并返回", for: .normal)
deleteButton.setTitleColor(.white, for: .normal)
@@ -77,7 +93,7 @@ final class MessageDetailViewController: BaseViewController {
view.addSubview(scrollView)
scrollView.addSubview(contentView)
[typeImageView, titleLabel, timeContainer, bodyLabel].forEach(contentView.addSubview)
[typeImageView, titleLabel, timeContainer, bodyLabel, taskDetailButton].forEach(contentView.addSubview)
timeContainer.addSubview(timeLabel)
view.addSubview(bottomBar)
bottomBar.addSubview(deleteButton)
@@ -121,12 +137,23 @@ final class MessageDetailViewController: BaseViewController {
bodyLabel.snp.makeConstraints { make in
make.top.equalTo(timeContainer.snp.bottom).offset(24)
make.leading.trailing.equalToSuperview().inset(32)
make.bottom.lessThanOrEqualToSuperview().inset(24)
if !viewModel.showsAIRetouchTaskAction {
make.bottom.lessThanOrEqualToSuperview().inset(24)
}
}
if viewModel.showsAIRetouchTaskAction {
taskDetailButton.snp.makeConstraints { make in
make.top.equalTo(bodyLabel.snp.bottom).offset(24)
make.leading.trailing.equalToSuperview().inset(32)
make.height.equalTo(52)
make.bottom.lessThanOrEqualToSuperview().inset(24)
}
}
}
override func bindActions() {
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
taskDetailButton.addTarget(self, action: #selector(taskDetailTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in
Task { @MainActor in
self?.applyState()
@@ -145,6 +172,16 @@ final class MessageDetailViewController: BaseViewController {
}
}
@objc private func taskDetailTapped() {
let target: UIViewController
if let batchId = viewModel.aiRetouchBatchId {
target = TravelAlbumAIJobDetailViewController(batchId: batchId, api: travelAlbumAPI)
} else {
target = TravelAlbumAIJobListViewController(api: travelAlbumAPI)
}
navigationController?.pushViewController(target, animated: true)
}
@objc private func deleteTapped() {
let alert = UIAlertController(title: "删除消息", message: "确定要删除这条消息吗?", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
@@ -349,7 +349,7 @@ final class BeforeAfterComparisonViewController: UIViewController {
).cgPath
dividerLine.frame = CGRect(x: x - 1.5, y: 0, width: 3, height: bounds.height)
dividerHandle.frame = CGRect(x: x - 24, y: bounds.midY - 24, width: 48, height: 48)
dividerHandle.accessibilityValue = "原图占比 (Int((dividerFraction * 100).rounded()))%"
dividerHandle.accessibilityValue = "原图占比 \(Int((dividerFraction * 100).rounded()))%"
}
private func updateDivider(locationX: CGFloat) {
@@ -0,0 +1,903 @@
import Kingfisher
import SnapKit
import UIKit
/// AI 修图任务详情页,按设计稿展示状态总览、相册信息、任务内容与逐输出处理明细。
final class TravelAlbumAIJobDetailViewController: BaseViewController {
private let viewModel: TravelAlbumAIJobDetailViewModel
private let api: any TravelAlbumServing
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let refreshControl = UIRefreshControl()
private let emptyView = AIJobDetailEmptyView()
private var pollingTask: Task<Void, Never>?
private var isVisible = false
private var isPresentingLoading = false
/// 创建指定批次的任务详情。
init(batchId: Int, api: (any TravelAlbumServing)? = nil) {
viewModel = TravelAlbumAIJobDetailViewModel(batchId: batchId)
self.api = api ?? NetworkServices.shared.travelAlbumAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func setupNavigationBar() {
let titleLabel = UILabel()
titleLabel.text = "任务详情"
titleLabel.textColor = AIJobDetailStyle.textPrimary
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
navigationItem.titleView = titleLabel
navigationItem.backButtonDisplayMode = .minimal
navigationItem.rightBarButtonItem = UIBarButtonItem(
image: UIImage(systemName: "arrow.clockwise"),
style: .plain,
target: self,
action: #selector(manualRefresh)
)
navigationItem.rightBarButtonItem?.tintColor = AIJobDetailStyle.textPrimary
navigationItem.rightBarButtonItem?.accessibilityLabel = "刷新任务详情"
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = .white
appearance.shadowColor = .clear
navigationItem.standardAppearance = appearance
navigationItem.scrollEdgeAppearance = appearance
navigationItem.compactAppearance = appearance
navigationController?.setNavigationBarHidden(false, animated: false)
}
override func setupUI() {
view.backgroundColor = AIJobDetailStyle.pageBackground
scrollView.backgroundColor = .clear
scrollView.alwaysBounceVertical = true
scrollView.refreshControl = refreshControl
contentStack.axis = .vertical
contentStack.spacing = 14
emptyView.onRetry = { [weak self] in self?.reload() }
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
view.addSubview(emptyView)
}
override func setupConstraints() {
scrollView.snp.makeConstraints { $0.edges.equalToSuperview() }
contentStack.snp.makeConstraints { make in
make.top.equalTo(scrollView.contentLayoutGuide).offset(12)
make.leading.trailing.equalTo(scrollView.frameLayoutGuide).inset(18)
make.bottom.equalTo(scrollView.contentLayoutGuide).inset(24)
}
emptyView.snp.makeConstraints { $0.edges.equalToSuperview() }
}
override func bindActions() {
refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } }
viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } }
viewModel.onNotFound = { [weak self] in Task { @MainActor in self?.presentNotFound() } }
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationBecameActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationEnteredBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.load(api: api) }
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
isVisible = true
updateLoadingPresentation()
updatePolling()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
isVisible = false
setLoadingPresented(false)
stopPolling()
}
deinit {
pollingTask?.cancel()
NotificationCenter.default.removeObserver(self)
}
@MainActor
private func applyState() {
refreshControl.endRefreshing()
let unavailable = viewModel.detail == nil && !viewModel.isLoading
emptyView.isHidden = !unavailable
emptyView.apply(
title: "任务详情加载失败",
message: viewModel.errorMessage ?? "暂时无法获取任务信息,请稍后重试。"
)
scrollView.isHidden = viewModel.detail == nil
if let detail = viewModel.detail { rebuildContent(detail) }
updateLoadingPresentation()
updatePolling()
}
@MainActor
private func rebuildContent(_ detail: TravelAlbumAIJobDetail) {
contentStack.arrangedSubviews.forEach {
contentStack.removeArrangedSubview($0)
$0.removeFromSuperview()
}
let statusCard = AIJobDetailStatusCard()
statusCard.apply(detail)
contentStack.addArrangedSubview(statusCard)
let albumCard = AIJobDetailAlbumCard()
albumCard.apply(detail)
contentStack.addArrangedSubview(albumCard)
let contentCard = AIJobDetailContentCard()
contentCard.apply(detail)
contentStack.addArrangedSubview(contentCard)
let processingCard = AIJobDetailProcessingCard()
processingCard.apply(
detail.targets,
onViewResult: { [weak self] target in self?.openResult(target) }
)
contentStack.addArrangedSubview(processingCard)
let albumButton = AIJobDetailAlbumButton()
albumButton.addTarget(self, action: #selector(openAlbumTapped), for: .touchUpInside)
contentStack.addArrangedSubview(albumButton)
albumButton.snp.makeConstraints { $0.height.equalTo(56) }
}
@MainActor
private func updateLoadingPresentation() {
setLoadingPresented(isVisible && viewModel.isLoading && viewModel.detail == nil)
}
@MainActor
private func setLoadingPresented(_ presented: Bool) {
guard isPresentingLoading != presented else { return }
isPresentingLoading = presented
presented ? showLoading() : hideLoading()
}
private func reload() { Task { await viewModel.load(api: api) } }
@objc private func refreshTriggered() { Task { await viewModel.load(api: api, refreshing: true) } }
@objc private func manualRefresh() { Task { await viewModel.load(api: api, refreshing: true) } }
@objc private func applicationBecameActive() { updatePolling() }
@objc private func applicationEnteredBackground() { stopPolling() }
@objc private func openAlbumTapped() { openAlbum() }
private func updatePolling() {
guard isVisible,
UIApplication.shared.applicationState == .active,
viewModel.shouldPoll,
pollingTask == nil
else {
if !viewModel.shouldPoll { stopPolling() }
return
}
pollingTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(8))
guard !Task.isCancelled, let self else { break }
await self.viewModel.load(api: self.api, silent: true)
}
}
}
private func stopPolling() {
pollingTask?.cancel()
pollingTask = nil
}
@MainActor
private func presentNotFound() {
guard presentedViewController == nil else { return }
let alert = UIAlertController(title: nil, message: "任务不存在或已失效", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "知道了", style: .default) { [weak self] _ in
self?.replaceWithTaskList()
})
present(alert, animated: true)
}
private func replaceWithTaskList() {
guard let navigationController else { return }
if let existing = navigationController.viewControllers.first(where: { $0 is TravelAlbumAIJobListViewController }) {
navigationController.popToViewController(existing, animated: true)
return
}
var stack = navigationController.viewControllers.filter { $0 !== self }
stack.append(TravelAlbumAIJobListViewController(api: api))
navigationController.setViewControllers(stack, animated: true)
}
private func openAlbum() {
guard let id = viewModel.detail?.userEquityTravelId, id > 0 else { return }
navigationController?.pushViewController(TravelAlbumDetailViewController(albumId: id, api: api), animated: true)
}
private func openResult(_ target: TravelAlbumAIJobTarget) {
guard let kind = target.outputType.previewKind,
let asset = target.resultAsset,
!asset.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
showToast("结果资源已失效")
return
}
Task {
var project: TravelAlbumPreviewProject
if kind == .cover {
project = TravelAlbumPreviewProject(
originalMaterialId: asset.materialId,
aiRetouchBatchId: viewModel.batchId,
assets: [TravelAlbumPreviewAsset(
id: "cover-\(asset.id)",
kind: .cover,
fileURL: asset.url,
coverURL: asset.thumbnailURL,
fileName: "AI封面",
fileSize: 0
)]
)
} else if let materialId = target.sourceMaterial?.id {
do {
let material = try await api.materialInfo(
userEquityTravelId: viewModel.detail?.userEquityTravelId ?? 0,
materialId: materialId
)
let current = TravelAlbumPreviewProject(material: material)
let result = TravelAlbumPreviewAsset(
id: "job-result-\(asset.id)",
kind: kind,
fileURL: asset.url,
coverURL: asset.thumbnailURL,
fileName: material.fileName,
fileSize: material.fileSize
)
project = TravelAlbumPreviewProject(
originalMaterialId: material.id,
aiRetouchBatchId: viewModel.batchId,
assets: current.assets + [result]
)
} catch {
await MainActor.run { self.showToast("结果资源加载失败,请前往相册查看") }
return
}
} else {
await MainActor.run { self.showToast("结果资源已失效") }
return
}
await MainActor.run {
self.present(
TravelAlbumPhotoPreviewViewController(
projects: [project],
totalCount: 1,
startProjectIndex: 0,
startKind: kind,
allowsActions: false
),
animated: true
)
}
}
}
}
/// AI 修图详情页专用视觉常量。
private enum AIJobDetailStyle {
static let pageBackground = UIColor(hex: 0xF5F7FB)
static let textPrimary = UIColor(hex: 0x111827)
static let textSecondary = UIColor(hex: 0x64748B)
static let border = UIColor(hex: 0xE4EAF3)
static func styleCard(_ view: UIView) {
view.backgroundColor = .white
view.layer.cornerRadius = 14
view.layer.borderWidth = 1
view.layer.borderColor = border.cgColor
view.layer.shadowColor = UIColor(hex: 0x315B94).withAlphaComponent(0.08).cgColor
view.layer.shadowOpacity = 1
view.layer.shadowRadius = 8
view.layer.shadowOffset = CGSize(width: 0, height: 3)
}
}
/// 任务状态总览卡,包含环形状态、完成进度、ETA 和通知说明。
private final class AIJobDetailStatusCard: UIView {
private let ringView = AIJobCircularProgressView()
private let statusLabel = UILabel()
private let completedLabel = UILabel()
private let progressView = UIProgressView(progressViewStyle: .default)
private let percentLabel = UILabel()
private let timeIcon = UIImageView(image: UIImage(systemName: "clock"))
private let timeLabel = UILabel()
private let notificationIcon = UIImageView(image: UIImage(systemName: "bell"))
private let notificationLabel = UILabel()
private let timeRow = UIStackView()
private let notificationRow = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
AIJobDetailStyle.styleCard(self)
accessibilityIdentifier = "aiRetouchJob.detail.statusCard"
statusLabel.font = .systemFont(ofSize: 23, weight: .semibold)
completedLabel.font = .systemFont(ofSize: 15)
completedLabel.textColor = AIJobDetailStyle.textSecondary
progressView.trackTintColor = UIColor(hex: 0xE5EAF2)
progressView.layer.cornerRadius = 3
progressView.clipsToBounds = true
percentLabel.font = .systemFont(ofSize: 14, weight: .medium)
percentLabel.textColor = AIJobDetailStyle.textSecondary
percentLabel.textAlignment = .right
[timeIcon, notificationIcon].forEach {
$0.tintColor = AppColor.primary
$0.contentMode = .scaleAspectFit
$0.snp.makeConstraints { $0.size.equalTo(17) }
}
timeLabel.font = .systemFont(ofSize: 14)
timeLabel.textColor = AIJobDetailStyle.textSecondary
notificationLabel.font = .systemFont(ofSize: 14)
notificationLabel.textColor = AIJobDetailStyle.textSecondary
timeRow.axis = .horizontal
timeRow.spacing = 8
timeRow.alignment = .center
timeRow.addArrangedSubview(timeIcon)
timeRow.addArrangedSubview(timeLabel)
notificationRow.axis = .horizontal
notificationRow.spacing = 8
notificationRow.alignment = .center
notificationRow.addArrangedSubview(notificationIcon)
notificationRow.addArrangedSubview(notificationLabel)
let progressRow = UIStackView(arrangedSubviews: [progressView, percentLabel])
progressRow.axis = .horizontal
progressRow.spacing = 10
progressRow.alignment = .center
progressView.snp.makeConstraints { $0.height.equalTo(7) }
percentLabel.snp.makeConstraints { $0.width.greaterThanOrEqualTo(38) }
let rightStack = UIStackView(arrangedSubviews: [statusLabel, completedLabel, progressRow, timeRow, notificationRow])
rightStack.axis = .vertical
rightStack.spacing = 7
addSubview(ringView)
addSubview(rightStack)
ringView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
make.size.equalTo(92)
}
rightStack.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(16)
make.leading.equalTo(ringView.snp.trailing).offset(20)
make.trailing.equalToSuperview().inset(18)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(_ detail: TravelAlbumAIJobDetail) {
let color = detail.status.semanticColor
statusLabel.text = detail.status.title
statusLabel.textColor = color
completedLabel.text = "已完成 \(detail.progress.completed) / \(detail.progress.total)"
progressView.progressTintColor = color
progressView.progress = Float(detail.progress.fraction)
percentLabel.text = "\(Int((detail.progress.fraction * 100).rounded()))%"
ringView.apply(progress: detail.progress.fraction, color: color, status: detail.status)
if detail.status.isInProgress {
timeLabel.text = detail.estimatedFinishAt.map {
"预计 \(TravelAlbumAIJobDateFormatter.time($0)) 前完成"
} ?? "完成时间暂无法预估"
notificationLabel.text = "完成后将通过消息通知你"
notificationRow.isHidden = false
} else {
timeLabel.text = "完成时间 \(TravelAlbumAIJobDateFormatter.display(detail.finishedAt))"
notificationRow.isHidden = true
}
accessibilityLabel = [statusLabel.text, completedLabel.text, percentLabel.text, timeLabel.text, notificationLabel.text]
.compactMap { $0 }.joined(separator: ",")
}
}
/// 环形进度组件,使用图形、文字与颜色共同表达任务状态。
private final class AIJobCircularProgressView: UIView {
private let trackLayer = CAShapeLayer()
private let progressLayer = CAShapeLayer()
private let iconView = UIImageView(image: UIImage(systemName: "sparkles"))
override init(frame: CGRect) {
super.init(frame: frame)
layer.addSublayer(trackLayer)
layer.addSublayer(progressLayer)
[trackLayer, progressLayer].forEach {
$0.fillColor = UIColor.clear.cgColor
$0.lineWidth = 8
$0.lineCap = .round
}
trackLayer.strokeColor = UIColor(hex: 0xE7F0FF).cgColor
iconView.contentMode = .scaleAspectFit
addSubview(iconView)
iconView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(37)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() {
super.layoutSubviews()
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let radius = max(0, min(bounds.width, bounds.height) / 2 - 6)
let path = UIBezierPath(
arcCenter: center,
radius: radius,
startAngle: -.pi / 2,
endAngle: .pi * 1.5,
clockwise: true
).cgPath
trackLayer.path = path
progressLayer.path = path
}
func apply(progress: Double, color: UIColor, status: TravelAlbumAIJobStatus) {
progressLayer.strokeColor = color.cgColor
progressLayer.strokeEnd = max(0.04, min(1, progress))
iconView.tintColor = color
iconView.image = UIImage(systemName: status.isInProgress ? "sparkles" : status == .succeeded ? "checkmark" : "exclamationmark")
}
}
/// 相册与任务信息卡,匹配设计稿中的封面、账号与提交信息结构。
private final class AIJobDetailAlbumCard: UIView {
private let coverView = UIImageView()
private let albumLabel = UILabel()
private let phoneLabel = UILabel()
private let taskLabel = UILabel()
private let submitLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
AIJobDetailStyle.styleCard(self)
accessibilityIdentifier = "aiRetouchJob.detail.albumCard"
coverView.contentMode = .scaleAspectFill
coverView.clipsToBounds = true
coverView.layer.cornerRadius = 12
coverView.backgroundColor = AppColor.pageBackground
coverView.tintColor = AppColor.textTertiary
albumLabel.font = .systemFont(ofSize: 19, weight: .semibold)
albumLabel.textColor = AIJobDetailStyle.textPrimary
phoneLabel.font = .systemFont(ofSize: 14)
phoneLabel.textColor = AIJobDetailStyle.textSecondary
taskLabel.font = .systemFont(ofSize: 14)
taskLabel.textColor = AIJobDetailStyle.textSecondary
submitLabel.font = .systemFont(ofSize: 14)
submitLabel.textColor = AIJobDetailStyle.textSecondary
addSubview(coverView)
addSubview(albumLabel)
addSubview(phoneLabel)
addSubview(taskLabel)
addSubview(submitLabel)
coverView.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(16)
make.size.equalTo(84)
}
albumLabel.snp.makeConstraints { make in
make.leading.equalTo(coverView.snp.trailing).offset(14)
make.top.equalToSuperview().offset(19)
make.trailing.equalToSuperview().inset(16)
}
phoneLabel.snp.makeConstraints { make in
make.leading.equalTo(albumLabel)
make.top.equalTo(albumLabel.snp.bottom).offset(7)
}
taskLabel.snp.makeConstraints { make in
make.leading.equalTo(albumLabel)
make.bottom.equalToSuperview().inset(19)
}
submitLabel.snp.makeConstraints { make in
make.leading.greaterThanOrEqualTo(taskLabel.snp.trailing).offset(12)
make.trailing.equalToSuperview().inset(16)
make.centerY.equalTo(taskLabel)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(_ detail: TravelAlbumAIJobDetail) {
coverView.kf.setImage(with: URL(string: detail.album.coverURL), placeholder: UIImage(systemName: "photo"))
albumLabel.text = detail.album.name.isEmpty ? "未命名相册" : detail.album.name
let phone = TravelAlbumDisplayFormatter.maskPhone(detail.album.userPhone)
phoneLabel.text = phone.isEmpty ? "未提供手机号" : phone
taskLabel.text = "任务 #\(detail.aiRetouchBatchId)"
submitLabel.text = "\(TravelAlbumAIJobDateFormatter.display(detail.createdAt)) 提交"
accessibilityLabel = [albumLabel.text, phoneLabel.text, taskLabel.text, submitLabel.text]
.compactMap { $0 }.joined(separator: ",")
}
}
/// 任务内容卡,以浅蓝标签展示各输出目标数量并补充额度结算。
private final class AIJobDetailContentCard: UIView {
private let outputStack = UIStackView()
private let quotaLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
AIJobDetailStyle.styleCard(self)
accessibilityIdentifier = "aiRetouchJob.detail.contentCard"
let titleLabel = UILabel()
titleLabel.text = "任务内容"
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
titleLabel.textColor = AIJobDetailStyle.textPrimary
outputStack.axis = .horizontal
outputStack.spacing = 10
outputStack.distribution = .fillEqually
quotaLabel.font = .systemFont(ofSize: 12)
quotaLabel.textColor = AIJobDetailStyle.textSecondary
quotaLabel.numberOfLines = 0
let stack = UIStackView(arrangedSubviews: [titleLabel, outputStack, quotaLabel])
stack.axis = .vertical
stack.spacing = 14
addSubview(stack)
stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) }
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(_ detail: TravelAlbumAIJobDetail) {
outputStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
detail.outputs.forEach { outputStack.addArrangedSubview(makeChip($0)) }
outputStack.isHidden = detail.outputs.isEmpty
let quota = detail.quotaSettlement
quotaLabel.text = "额度:预占 \(quota.reservedUnits) · 消耗 \(quota.consumedUnits) · 释放 \(quota.releasedUnits)"
accessibilityLabel = ["任务内容", detail.outputs.map { "\($0.type.title) \($0.count)张" }.joined(separator: ","), quotaLabel.text]
.compactMap { $0 }.joined(separator: ",")
}
private func makeChip(_ output: TravelAlbumAIJobOutput) -> UIView {
let container = UIView()
container.backgroundColor = AppColor.primaryLight
container.layer.cornerRadius = 9
let iconView = UIImageView(image: UIImage(systemName: output.type.symbolName))
iconView.tintColor = AppColor.primary
iconView.contentMode = .scaleAspectFit
let label = UILabel()
label.text = "\(output.type.shortTitle) \(output.count) 张"
label.font = .systemFont(ofSize: 14, weight: .medium)
label.textColor = AIJobDetailStyle.textPrimary
label.adjustsFontSizeToFitWidth = true
label.minimumScaleFactor = 0.75
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
make.leading.equalTo(iconView.snp.trailing).offset(6)
make.trailing.equalToSuperview().inset(8)
make.centerY.equalToSuperview()
}
container.snp.makeConstraints { $0.height.equalTo(44) }
return container
}
}
/// 处理明细卡,将逐照片、逐输出状态及失败原因放在同一卡片中展示。
private final class AIJobDetailProcessingCard: UIView {
private let rowsStack = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
AIJobDetailStyle.styleCard(self)
accessibilityIdentifier = "aiRetouchJob.detail.processingCard"
let titleLabel = UILabel()
titleLabel.text = "处理明细"
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
titleLabel.textColor = AIJobDetailStyle.textPrimary
rowsStack.axis = .vertical
rowsStack.spacing = 0
let stack = UIStackView(arrangedSubviews: [titleLabel, rowsStack])
stack.axis = .vertical
stack.spacing = 12
addSubview(stack)
stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) }
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(
_ targets: [TravelAlbumAIJobTarget],
onViewResult: @escaping (TravelAlbumAIJobTarget) -> Void
) {
rowsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
if targets.isEmpty {
let label = UILabel()
label.text = "暂无处理明细"
label.font = .systemFont(ofSize: 14)
label.textColor = AIJobDetailStyle.textSecondary
label.textAlignment = .center
label.snp.makeConstraints { $0.height.equalTo(52) }
rowsStack.addArrangedSubview(label)
return
}
for (index, target) in targets.enumerated() {
let row = AIJobDetailTargetRow()
row.apply(target)
row.onViewResult = { onViewResult(target) }
rowsStack.addArrangedSubview(row)
if index < targets.count - 1 {
let separator = UIView()
separator.backgroundColor = AIJobDetailStyle.border
separator.snp.makeConstraints { $0.height.equalTo(1) }
rowsStack.addArrangedSubview(separator)
}
}
}
}
/// 单个输出明细行,内联展示结果操作或后端返回的失败原因。
private final class AIJobDetailTargetRow: UIView {
private let thumbnailView = UIImageView()
private let titleLabel = UILabel()
private let templateLabel = UILabel()
private let statusIconView = UIImageView()
private let statusLabel = UILabel()
private let resultButton = UIButton(type: .system)
private let errorContainer = UIView()
private let errorIconView = UIImageView(image: UIImage(systemName: "exclamationmark.circle.fill"))
private let errorLabel = UILabel()
var onViewResult: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
thumbnailView.contentMode = .scaleAspectFill
thumbnailView.clipsToBounds = true
thumbnailView.layer.cornerRadius = 10
thumbnailView.backgroundColor = AppColor.pageBackground
thumbnailView.tintColor = AppColor.textTertiary
titleLabel.font = .systemFont(ofSize: 15, weight: .medium)
titleLabel.textColor = AIJobDetailStyle.textPrimary
titleLabel.lineBreakMode = .byTruncatingMiddle
templateLabel.font = .systemFont(ofSize: 13)
templateLabel.textColor = AIJobDetailStyle.textSecondary
statusIconView.contentMode = .scaleAspectFit
statusLabel.font = .systemFont(ofSize: 14, weight: .medium)
resultButton.setTitle("查看结果", for: .normal)
resultButton.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
resultButton.addTarget(self, action: #selector(viewResultTapped), for: .touchUpInside)
errorContainer.backgroundColor = AppColor.dangerBackground
errorContainer.layer.cornerRadius = 8
errorIconView.tintColor = AppColor.danger
errorLabel.font = .systemFont(ofSize: 13)
errorLabel.textColor = AppColor.danger
errorLabel.numberOfLines = 0
let statusStack = UIStackView(arrangedSubviews: [statusIconView, statusLabel])
statusStack.axis = .horizontal
statusStack.spacing = 6
statusStack.alignment = .center
statusIconView.snp.makeConstraints { $0.size.equalTo(18) }
addSubview(thumbnailView)
addSubview(titleLabel)
addSubview(templateLabel)
addSubview(statusStack)
addSubview(resultButton)
addSubview(errorContainer)
errorContainer.addSubview(errorIconView)
errorContainer.addSubview(errorLabel)
thumbnailView.snp.makeConstraints { make in
make.leading.top.equalToSuperview().offset(4)
make.size.equalTo(80)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(thumbnailView.snp.trailing).offset(14)
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(thumbnailView.snp.bottom).offset(10)
make.leading.equalTo(titleLabel)
make.trailing.equalToSuperview().inset(4)
make.bottom.equalToSuperview().inset(12)
make.height.greaterThanOrEqualTo(38)
}
errorIconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(10)
make.top.equalToSuperview().offset(11)
make.size.equalTo(16)
}
errorLabel.snp.makeConstraints { make in
make.leading.equalTo(errorIconView.snp.trailing).offset(7)
make.top.bottom.equalToSuperview().inset(9)
make.trailing.equalToSuperview().inset(10)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(_ target: TravelAlbumAIJobTarget) {
let thumbnail = target.sourceMaterial?.thumbnailURL ?? target.resultAsset?.thumbnailURL ?? ""
thumbnailView.kf.setImage(with: URL(string: thumbnail), placeholder: UIImage(systemName: "photo"))
titleLabel.text = target.outputType == .cover
? "相册封面"
: (target.sourceMaterial?.fileName.isEmpty == false ? target.sourceMaterial?.fileName : "照片 \(target.sourceMaterial?.id ?? 0)")
templateLabel.text = target.template?.name ?? target.outputType.title
let color = target.status.semanticColor
statusIconView.tintColor = color
statusLabel.textColor = color
statusIconView.image = UIImage(systemName: target.status.symbolName)
switch target.status {
case .succeeded:
statusLabel.text = "\(target.outputType.shortTitle)完成"
case .processing:
statusLabel.text = "正在生成"
case .queued:
statusLabel.text = "等待处理"
case .failed:
statusLabel.text = "生成失败"
default:
statusLabel.text = target.status.title
}
let hasResult = target.status == .succeeded && target.resultAsset?.url.isEmpty == false
resultButton.isHidden = !hasResult
errorLabel.text = target.displayFailureMessage.map { "失败原因:\($0)" }
let showsError = target.displayFailureMessage != nil
errorContainer.isHidden = !showsError
errorContainer.snp.remakeConstraints { make in
make.top.equalTo(thumbnailView.snp.bottom).offset(showsError ? 10 : 0)
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]
.compactMap { $0 }.joined(separator: ",")
}
@objc private func viewResultTapped() { onViewResult?() }
}
/// 页面底部的查看相册主操作按钮。
private final class AIJobDetailAlbumButton: UIButton {
override init(frame: CGRect) {
super.init(frame: frame)
var configuration = UIButton.Configuration.plain()
configuration.title = "查看相册"
configuration.image = UIImage(systemName: "photo")
configuration.imagePadding = 10
configuration.baseForegroundColor = AppColor.primary
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
var outgoing = incoming
outgoing.font = .systemFont(ofSize: 18, weight: .medium)
return outgoing
}
self.configuration = configuration
layer.cornerRadius = 10
layer.borderWidth = 1.5
layer.borderColor = AppColor.primary.cgColor
backgroundColor = .white
accessibilityIdentifier = "aiRetouchJob.detail.albumButton"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}
/// 任务详情加载失败时的空态。
private final class AIJobDetailEmptyView: UIView {
private let titleLabel = UILabel()
private let messageLabel = UILabel()
private let button = UIButton(type: .system)
var onRetry: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
titleLabel.textAlignment = .center
messageLabel.font = .systemFont(ofSize: 14)
messageLabel.textColor = AppColor.textSecondary
messageLabel.textAlignment = .center
messageLabel.numberOfLines = 0
button.setTitle("重新加载", for: .normal)
button.addTarget(self, action: #selector(retry), for: .touchUpInside)
let stack = UIStackView(arrangedSubviews: [titleLabel, messageLabel, button])
stack.axis = .vertical
stack.spacing = 12
stack.alignment = .center
addSubview(stack)
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(40)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(title: String, message: String) { titleLabel.text = title; messageLabel.text = message }
@objc private func retry() { onRetry?() }
}
private extension TravelAlbumAIJobStatus {
var semanticColor: UIColor {
switch self {
case .queued, .processing: AppColor.primary
case .succeeded: AppColor.success
case .partiallySucceeded: AppColor.warning
case .failed: AppColor.danger
case .canceled, .unknown: AppColor.textSecondary
}
}
var symbolName: String {
switch self {
case .queued: "clock"
case .processing: "arrow.triangle.2.circlepath"
case .succeeded: "checkmark.circle.fill"
case .partiallySucceeded: "exclamationmark.circle.fill"
case .failed: "exclamationmark.circle"
case .canceled, .unknown: "minus.circle"
}
}
}
private extension TravelAlbumAIJobOutputType {
var shortTitle: String {
switch self {
case .refined: "精修"
case .atmosphere: "氛围感"
case .cover: "封面"
case .unknown: "其他"
}
}
var symbolName: String {
switch self {
case .refined: "wand.and.stars"
case .atmosphere: "sun.max"
case .cover: "bookmark"
case .unknown: "sparkles"
}
}
}
@@ -0,0 +1,675 @@
import Kingfisher
import SnapKit
import UIKit
/// 当前账号的 AI 修图任务中心,提供筛选、游标分页、刷新与状态轮询。
final class TravelAlbumAIJobListViewController: BaseViewController {
private enum Section { case main }
private let viewModel = TravelAlbumAIJobListViewModel()
private let api: any TravelAlbumServing
private let filterContainer = UIView()
private let filterStack = UIStackView()
private var filterButtons: [TravelAlbumAIJobFilter: UIButton] = [:]
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
private let refreshControl = UIRefreshControl()
private let emptyView = AIJobEmptyView()
private var dataSource: UICollectionViewDiffableDataSource<Section, TravelAlbumAIJobSummary>!
private var pollingTask: Task<Void, Never>?
private var isVisible = false
private var isPresentingLoading = false
/// 创建任务中心,可注入 API 以支持测试。
init(api: (any TravelAlbumServing)? = nil) {
self.api = api ?? NetworkServices.shared.travelAlbumAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func setupNavigationBar() {
let titleLabel = UILabel()
titleLabel.text = "AI修图任务"
titleLabel.textColor = UIColor(hex: 0x0F1F3D)
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
navigationItem.titleView = titleLabel
navigationItem.backButtonDisplayMode = .minimal
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = .white
appearance.shadowColor = .clear
navigationItem.standardAppearance = appearance
navigationItem.scrollEdgeAppearance = appearance
navigationItem.compactAppearance = appearance
navigationController?.setNavigationBarHidden(false, animated: false)
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackgroundSoft
configureFilters()
collectionView.backgroundColor = .clear
collectionView.alwaysBounceVertical = true
collectionView.delegate = self
collectionView.refreshControl = refreshControl
collectionView.register(AIJobSummaryCell.self, forCellWithReuseIdentifier: AIJobSummaryCell.reuseIdentifier)
dataSource = UICollectionViewDiffableDataSource<Section, TravelAlbumAIJobSummary>(collectionView: collectionView) {
collectionView, indexPath, item in
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: AIJobSummaryCell.reuseIdentifier,
for: indexPath
) as! AIJobSummaryCell
cell.apply(item)
return cell
}
emptyView.onRetry = { [weak self] in self?.reload() }
view.addSubview(filterContainer)
filterContainer.addSubview(filterStack)
view.addSubview(collectionView)
view.addSubview(emptyView)
}
override func setupConstraints() {
filterContainer.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
make.leading.trailing.equalToSuperview().inset(20)
make.height.equalTo(46)
}
filterStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(2)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(filterContainer.snp.bottom).offset(14)
make.leading.trailing.bottom.equalToSuperview()
}
emptyView.snp.makeConstraints { make in
make.top.equalTo(filterContainer.snp.bottom)
make.leading.trailing.bottom.equalToSuperview()
}
}
override func bindActions() {
refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } }
viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } }
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationBecameActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationEnteredBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.loadFirstPage(api: api) }
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
isVisible = true
updateLoadingPresentation()
updatePolling()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
isVisible = false
setLoadingPresented(false)
stopPolling()
}
deinit {
pollingTask?.cancel()
NotificationCenter.default.removeObserver(self)
}
private func configureFilters() {
filterContainer.accessibilityIdentifier = "aiRetouchJob.filterContainer"
filterContainer.backgroundColor = .white
filterContainer.layer.cornerRadius = 12
filterContainer.layer.borderWidth = 1
filterContainer.layer.borderColor = UIColor(hex: 0xE8EEF7).cgColor
filterContainer.layer.shadowColor = UIColor(hex: 0x315B94).withAlphaComponent(0.12).cgColor
filterContainer.layer.shadowOpacity = 1
filterContainer.layer.shadowRadius = 7
filterContainer.layer.shadowOffset = CGSize(width: 0, height: 3)
filterStack.axis = .horizontal
filterStack.spacing = 0
filterStack.distribution = .fillEqually
TravelAlbumAIJobFilter.allCases.forEach { filter in
var configuration = UIButton.Configuration.plain()
configuration.title = filter.title
configuration.contentInsets = .zero
let button = UIButton(configuration: configuration)
button.tag = TravelAlbumAIJobFilter.allCases.firstIndex(of: filter) ?? 0
button.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
button.layer.cornerRadius = 10
button.addTarget(self, action: #selector(filterTapped(_:)), for: .touchUpInside)
button.accessibilityLabel = "筛选:\(filter.title)"
filterButtons[filter] = button
filterStack.addArrangedSubview(button)
}
}
private func makeLayout() -> UICollectionViewLayout {
UICollectionViewCompositionalLayout { _, environment in
let item = NSCollectionLayoutItem(layoutSize: .init(
widthDimension: .fractionalWidth(1),
heightDimension: .estimated(250)
))
let group = NSCollectionLayoutGroup.vertical(
layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .estimated(250)),
subitems: [item]
)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 14
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 20, bottom: 24, trailing: 20)
return section
}
}
@MainActor
private func applyState() {
filterButtons.forEach { filter, button in
let selected = filter == viewModel.selectedFilter
button.configuration?.baseForegroundColor = selected ? .white : AppColor.textSecondary
button.configuration?.background.backgroundColor = .clear
button.backgroundColor = selected ? AppColor.primary : .clear
button.accessibilityTraits = selected ? [.button, .selected] : .button
}
refreshControl.endRefreshing()
var snapshot = NSDiffableDataSourceSnapshot<Section, TravelAlbumAIJobSummary>()
snapshot.appendSections([.main])
snapshot.appendItems(viewModel.items)
dataSource.apply(snapshot, animatingDifferences: true)
let empty = viewModel.items.isEmpty && !viewModel.isLoading
emptyView.isHidden = !empty
emptyView.apply(
title: viewModel.errorMessage == nil ? "暂无AI修图任务" : "任务加载失败",
message: viewModel.errorMessage ?? "提交AI修图后,可以在这里查看进度和结果。",
showsRetry: viewModel.errorMessage != nil
)
updateLoadingPresentation()
updatePolling()
}
@MainActor
private func updateLoadingPresentation() {
setLoadingPresented(isVisible && viewModel.isLoading && viewModel.items.isEmpty)
}
@MainActor
private func setLoadingPresented(_ presented: Bool) {
guard isPresentingLoading != presented else { return }
isPresentingLoading = presented
if presented {
showLoading()
} else {
hideLoading()
}
}
private func reload() { Task { await viewModel.loadFirstPage(api: api) } }
@objc private func refreshTriggered() {
Task { await viewModel.loadFirstPage(api: api, refreshing: true) }
}
@objc private func filterTapped(_ sender: UIButton) {
guard TravelAlbumAIJobFilter.allCases.indices.contains(sender.tag) else { return }
Task { await viewModel.selectFilter(TravelAlbumAIJobFilter.allCases[sender.tag], api: api) }
}
@objc private func applicationBecameActive() { updatePolling() }
@objc private func applicationEnteredBackground() { stopPolling() }
private func updatePolling() {
guard isVisible,
UIApplication.shared.applicationState == .active,
viewModel.containsInProgressJobs,
pollingTask == nil
else {
if !viewModel.containsInProgressJobs { stopPolling() }
return
}
pollingTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(15))
guard !Task.isCancelled, let self else { break }
await self.viewModel.loadFirstPage(api: self.api, silent: true)
}
}
}
private func stopPolling() {
pollingTask?.cancel()
pollingTask = nil
}
}
extension TravelAlbumAIJobListViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
navigationController?.pushViewController(
TravelAlbumAIJobDetailViewController(batchId: item.aiRetouchBatchId, api: api),
animated: true
)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView.contentSize.height > 0,
scrollView.contentOffset.y + scrollView.bounds.height > scrollView.contentSize.height - 240
else { return }
Task { await viewModel.loadMore(api: api) }
}
}
/// AI 修图任务列表卡片。
private final class AIJobSummaryCell: UICollectionViewCell {
static let reuseIdentifier = "AIJobSummaryCell"
private let topInfoView = UIView()
private let albumCoverImageView = UIImageView()
private let albumLabel = UILabel()
private let phoneLabel = UILabel()
private let taskLabel = UILabel()
private let timeIconView = UIImageView(image: UIImage(systemName: "clock"))
private let timeLabel = UILabel()
private let timeStack = UIStackView()
private let statusBadge = AIJobStatusBadgeView()
private let previewStack = UIStackView()
private var previewImageViews: [UIImageView] = []
private let progressRow = UIStackView()
private let completedLabel = UILabel()
private let outputLabel = UILabel()
private let progressView = UIProgressView(progressViewStyle: .default)
private let percentLabel = UILabel()
private let etaLabel = UILabel()
private let bottomRow = UIView()
private let outputPill = UIView()
private let outputIconView = UIImageView(image: UIImage(systemName: "wand.and.stars"))
private let detailsLabel = UILabel()
private let chevronView = UIImageView(image: UIImage(systemName: "chevron.right"))
private let contentStack = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .white
contentView.layer.cornerRadius = 16
contentView.layer.borderWidth = 1
contentView.layer.borderColor = UIColor(hex: 0xE6EDF8).cgColor
contentView.layer.shadowColor = UIColor(hex: 0x315B94).withAlphaComponent(0.1).cgColor
contentView.layer.shadowOpacity = 1
contentView.layer.shadowRadius = 9
contentView.layer.shadowOffset = CGSize(width: 0, height: 4)
configureTopInfo()
configurePreviewGrid()
completedLabel.font = .systemFont(ofSize: 14, weight: .medium)
completedLabel.textColor = UIColor(hex: 0x263754)
progressView.tintColor = AppColor.primary
progressView.trackTintColor = UIColor(hex: 0xE5EAF2)
progressView.layer.cornerRadius = 3
progressView.clipsToBounds = true
percentLabel.font = .systemFont(ofSize: 14, weight: .medium)
percentLabel.textColor = UIColor(hex: 0x263754)
percentLabel.textAlignment = .right
progressRow.axis = .horizontal
progressRow.alignment = .center
progressRow.spacing = 10
progressRow.addArrangedSubview(completedLabel)
progressRow.addArrangedSubview(progressView)
progressRow.addArrangedSubview(percentLabel)
progressView.snp.makeConstraints { $0.height.equalTo(6) }
completedLabel.setContentHuggingPriority(.required, for: .horizontal)
percentLabel.setContentHuggingPriority(.required, for: .horizontal)
etaLabel.font = .systemFont(ofSize: 13)
etaLabel.textColor = UIColor(hex: 0x7F8CA3)
configureBottomRow()
contentStack.axis = .vertical
contentStack.spacing = 12
[topInfoView, previewStack, progressRow, etaLabel, bottomRow].forEach {
contentStack.addArrangedSubview($0)
}
contentView.addSubview(contentStack)
contentStack.snp.makeConstraints { $0.edges.equalToSuperview().inset(14) }
topInfoView.snp.makeConstraints { $0.height.equalTo(72) }
previewStack.snp.makeConstraints { $0.height.equalTo(104) }
bottomRow.snp.makeConstraints { $0.height.equalTo(34) }
accessibilityIdentifier = "aiRetouchJob.summaryCell"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func prepareForReuse() {
super.prepareForReuse()
albumCoverImageView.kf.cancelDownloadTask()
albumCoverImageView.image = nil
previewImageViews.forEach {
$0.kf.cancelDownloadTask()
$0.image = nil
$0.isHidden = true
}
}
func apply(_ item: TravelAlbumAIJobSummary) {
albumLabel.text = item.album.name.isEmpty ? "未命名相册" : item.album.name
let phone = TravelAlbumDisplayFormatter.maskPhone(item.album.userPhone)
phoneLabel.text = phone.isEmpty ? "未提供手机号" : phone
taskLabel.text = "任务 #\(item.aiRetouchBatchId)"
timeLabel.text = relativeTime(item.createdAt)
statusBadge.apply(item.status)
albumCoverImageView.kf.setImage(
with: URL(string: item.album.coverURL),
placeholder: UIImage(systemName: "photo")
)
previewImageViews.enumerated().forEach { index, imageView in
guard item.previewImages.indices.contains(index) else {
imageView.isHidden = true
return
}
imageView.isHidden = false
imageView.kf.setImage(
with: URL(string: item.previewImages[index].thumbnailURL),
placeholder: UIImage(systemName: "photo")
)
}
previewStack.isHidden = item.previewImages.isEmpty
progressView.progress = Float(item.progress.fraction)
if item.status.isInProgress {
completedLabel.text = "已完成 \(item.progress.completed) / \(item.progress.total)"
percentLabel.text = "\(Int((item.progress.fraction * 100).rounded()))%"
etaLabel.text = item.estimatedFinishAt.map {
"预计 \(TravelAlbumAIJobDateFormatter.time($0)) 前完成"
} ?? "完成后将通过消息通知"
progressRow.isHidden = false
etaLabel.isHidden = false
outputIconView.image = UIImage(systemName: "wand.and.stars")
outputIconView.tintColor = AppColor.primary
outputPill.backgroundColor = AppColor.primaryLight
outputLabel.textColor = AppColor.primary
outputLabel.text = item.outputs.map { "\(shortTitle($0.type)) \($0.count)张" }.joined(separator: " · ")
} else if item.status == .partiallySucceeded {
progressRow.isHidden = true
etaLabel.isHidden = true
outputIconView.image = UIImage(systemName: "exclamationmark.circle.fill")
outputIconView.tintColor = AppColor.warning
outputPill.backgroundColor = .clear
outputLabel.textColor = UIColor(hex: 0xB56A00)
outputLabel.text = "\(item.progress.succeeded)张成功 · \(item.progress.failed)张失败"
} else {
progressRow.isHidden = true
etaLabel.isHidden = true
let succeeded = item.status == .succeeded
outputIconView.image = UIImage(systemName: succeeded ? "checkmark.circle.fill" : "xmark.circle.fill")
outputIconView.tintColor = succeeded ? AppColor.success : AppColor.danger
outputPill.backgroundColor = .clear
outputLabel.textColor = succeeded ? UIColor(hex: 0x178A55) : AppColor.danger
outputLabel.text = succeeded ? "\(item.progress.succeeded)张结果已生成" : item.status.title
}
accessibilityLabel = [albumLabel.text, statusBadge.accessibilityLabel, timeLabel.text, phoneLabel.text, taskLabel.text,
outputLabel.text, completedLabel.text, etaLabel.text]
.compactMap { $0 }.joined(separator: ",")
accessibilityTraits = .button
}
private func configureTopInfo() {
albumCoverImageView.accessibilityIdentifier = "aiRetouchJob.albumCover"
albumCoverImageView.contentMode = .scaleAspectFill
albumCoverImageView.clipsToBounds = true
albumCoverImageView.layer.cornerRadius = 10
albumCoverImageView.backgroundColor = AppColor.pageBackground
albumCoverImageView.tintColor = AppColor.textTertiary
albumLabel.font = .systemFont(ofSize: 17, weight: .semibold)
albumLabel.textColor = UIColor(hex: 0x0F1F3D)
albumLabel.lineBreakMode = .byTruncatingTail
phoneLabel.font = .systemFont(ofSize: 14)
phoneLabel.textColor = UIColor(hex: 0x72809A)
taskLabel.font = .systemFont(ofSize: 14)
taskLabel.textColor = UIColor(hex: 0x72809A)
timeIconView.tintColor = UIColor(hex: 0x7F8CA3)
timeIconView.contentMode = .scaleAspectFit
timeLabel.font = .systemFont(ofSize: 13)
timeLabel.textColor = UIColor(hex: 0x7F8CA3)
timeStack.axis = .horizontal
timeStack.spacing = 5
timeStack.alignment = .center
timeStack.addArrangedSubview(timeIconView)
timeStack.addArrangedSubview(timeLabel)
[albumCoverImageView, albumLabel, phoneLabel, taskLabel, timeStack, statusBadge].forEach {
topInfoView.addSubview($0)
}
albumCoverImageView.snp.makeConstraints { make in
make.leading.top.equalToSuperview()
make.size.equalTo(68)
}
timeIconView.snp.makeConstraints { $0.size.equalTo(15) }
timeStack.snp.makeConstraints { make in
make.top.trailing.equalToSuperview()
}
statusBadge.snp.makeConstraints { make in
make.trailing.bottom.equalToSuperview()
make.height.equalTo(30)
}
albumLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(1)
make.leading.equalTo(albumCoverImageView.snp.trailing).offset(12)
make.trailing.lessThanOrEqualTo(timeStack.snp.leading).offset(-8)
}
phoneLabel.snp.makeConstraints { make in
make.leading.equalTo(albumLabel)
make.top.equalTo(albumLabel.snp.bottom).offset(6)
make.trailing.lessThanOrEqualTo(statusBadge.snp.leading).offset(-8)
}
taskLabel.snp.makeConstraints { make in
make.leading.equalTo(albumLabel)
make.top.equalTo(phoneLabel.snp.bottom).offset(5)
make.trailing.lessThanOrEqualTo(statusBadge.snp.leading).offset(-8)
}
}
private func configurePreviewGrid() {
previewStack.axis = .horizontal
previewStack.spacing = 8
previewStack.distribution = .fillEqually
for index in 0..<3 {
let slot = UIView()
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 10
imageView.backgroundColor = AppColor.pageBackground
imageView.tintColor = AppColor.textTertiary
imageView.accessibilityIdentifier = "aiRetouchJob.preview.\(index)"
slot.addSubview(imageView)
imageView.snp.makeConstraints { $0.edges.equalToSuperview() }
previewStack.addArrangedSubview(slot)
previewImageViews.append(imageView)
}
}
private func configureBottomRow() {
outputPill.layer.cornerRadius = 9
outputPill.clipsToBounds = true
outputIconView.contentMode = .scaleAspectFit
outputLabel.font = .systemFont(ofSize: 13, weight: .medium)
outputLabel.numberOfLines = 1
outputLabel.adjustsFontSizeToFitWidth = true
outputLabel.minimumScaleFactor = 0.8
outputPill.addSubview(outputIconView)
outputPill.addSubview(outputLabel)
outputIconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(10)
make.centerY.equalToSuperview()
make.size.equalTo(17)
}
outputLabel.snp.makeConstraints { make in
make.leading.equalTo(outputIconView.snp.trailing).offset(6)
make.trailing.equalToSuperview().inset(10)
make.centerY.equalToSuperview()
}
detailsLabel.text = "查看详情"
detailsLabel.font = .systemFont(ofSize: 13)
detailsLabel.textColor = UIColor(hex: 0x7F8CA3)
chevronView.tintColor = UIColor(hex: 0x7F8CA3)
chevronView.contentMode = .scaleAspectFit
[outputPill, detailsLabel, chevronView].forEach { bottomRow.addSubview($0) }
outputPill.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview()
make.trailing.lessThanOrEqualTo(detailsLabel.snp.leading).offset(-8)
}
chevronView.snp.makeConstraints { make in
make.trailing.centerY.equalToSuperview()
make.size.equalTo(14)
}
detailsLabel.snp.makeConstraints { make in
make.trailing.equalTo(chevronView.snp.leading).offset(-6)
make.centerY.equalToSuperview()
}
}
private func shortTitle(_ type: TravelAlbumAIJobOutputType) -> String {
switch type {
case .refined: "精修"
case .atmosphere: "氛围感"
case .cover: "封面"
case .unknown: "其他"
}
}
private func relativeTime(_ value: String) -> String {
guard let date = TravelAlbumAIJobDateFormatter.date(value) else { return "--" }
let calendar = Calendar.current
let prefix: String
if calendar.isDateInToday(date) {
prefix = "今天"
} else if calendar.isDateInYesterday(date) {
prefix = "昨天"
} else {
prefix = date.formatted(.dateTime.month().day())
}
return "\(prefix) \(date.formatted(.dateTime.hour().minute()))"
}
}
/// 带图标与底色的任务状态徽标,保证状态不只依赖颜色表达。
private final class AIJobStatusBadgeView: UIView {
private let iconView = UIImageView()
private let titleLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 9
iconView.contentMode = .scaleAspectFit
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
addSubview(iconView)
addSubview(titleLabel)
iconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(9)
make.centerY.equalToSuperview()
make.size.equalTo(17)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(5)
make.trailing.equalToSuperview().inset(10)
make.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(_ status: TravelAlbumAIJobStatus) {
titleLabel.text = status.title
let color: UIColor
let background: UIColor
let symbol: String
switch status {
case .queued:
color = AppColor.primary
background = AppColor.infoBackground
symbol = "clock.arrow.circlepath"
case .processing:
color = AppColor.primary
background = AppColor.infoBackground
symbol = "arrow.triangle.2.circlepath"
case .succeeded:
color = AppColor.success
background = AppColor.successBackground
symbol = "checkmark.circle.fill"
case .partiallySucceeded:
color = AppColor.warning
background = AppColor.warningBackground
symbol = "exclamationmark.circle.fill"
case .failed:
color = AppColor.danger
background = AppColor.dangerBackground
symbol = "xmark.circle.fill"
case .canceled, .unknown:
color = AppColor.textSecondary
background = AppColor.pageBackground
symbol = "minus.circle.fill"
}
titleLabel.textColor = color
iconView.tintColor = color
iconView.image = UIImage(systemName: symbol)
backgroundColor = background
accessibilityLabel = status.title
}
}
/// 任务列表空态和错误态。
private final class AIJobEmptyView: UIView {
private let imageView = UIImageView(image: UIImage(systemName: "photo.on.rectangle.angled"))
private let titleLabel = UILabel()
private let messageLabel = UILabel()
private let retryButton = UIButton(type: .system)
var onRetry: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
imageView.tintColor = AppColor.primary
imageView.contentMode = .scaleAspectFit
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
titleLabel.textAlignment = .center
messageLabel.font = .systemFont(ofSize: 14)
messageLabel.textColor = AppColor.textSecondary
messageLabel.textAlignment = .center
messageLabel.numberOfLines = 0
retryButton.setTitle("重新加载", for: .normal)
retryButton.addTarget(self, action: #selector(retry), for: .touchUpInside)
let stack = UIStackView(arrangedSubviews: [imageView, titleLabel, messageLabel, retryButton])
stack.axis = .vertical
stack.alignment = .center
stack.spacing = 12
addSubview(stack)
stack.snp.makeConstraints { make in
make.centerY.equalToSuperview().offset(-40)
make.leading.trailing.equalToSuperview().inset(40)
}
imageView.snp.makeConstraints { $0.size.equalTo(52) }
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(title: String, message: String, showsRetry: Bool) {
titleLabel.text = title
messageLabel.text = message
retryButton.isHidden = !showsRetry
}
@objc private func retry() { onRetry?() }
}
@@ -25,7 +25,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
private let viewModel: TravelAlbumAIRetouchTemplateViewModel
private let api: any TravelAlbumServing
private let onSubmitted: () -> Void
private let onSubmitted: (TravelAlbumAIJobSubmission) -> Void
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
@@ -45,7 +45,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
init(
viewModel: TravelAlbumAIRetouchTemplateViewModel,
api: any TravelAlbumServing,
onSubmitted: @escaping () -> Void
onSubmitted: @escaping (TravelAlbumAIJobSubmission) -> Void
) {
self.viewModel = viewModel
self.api = api
@@ -182,10 +182,10 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onSubmitted = { [weak self] in
viewModel.onSubmitted = { [weak self] submission in
Task { @MainActor in
guard let self else { return }
self.dismiss(animated: true, completion: self.onSubmitted)
self.dismiss(animated: true) { self.onSubmitted(submission) }
}
}
}
@@ -65,7 +65,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
navigationItem.scrollEdgeAppearance = appearance
navigationItem.compactAppearance = appearance
navigationItem.rightBarButtonItem = UIBarButtonItem(
let moreItem = UIBarButtonItem(
image: UIImage(systemName: "ellipsis"),
menu: UIMenu(children: [
UIAction(title: "删除相册", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
@@ -73,7 +73,19 @@ final class TravelAlbumDetailViewController: BaseViewController {
},
])
)
navigationItem.rightBarButtonItem?.accessibilityLabel = "更多操作"
moreItem.accessibilityLabel = "更多操作"
let taskItem = UIBarButtonItem(
title: "修图任务",
style: .plain,
target: self,
action: #selector(openAIJobList)
)
taskItem.accessibilityLabel = "查看AI修图任务"
navigationItem.rightBarButtonItems = [moreItem, taskItem]
}
@objc private func openAIJobList() {
navigationController?.pushViewController(TravelAlbumAIJobListViewController(api: api), animated: true)
}
override func setupUI() {
@@ -446,15 +458,31 @@ final class TravelAlbumDetailViewController: BaseViewController {
materialIds: materialIds
),
api: api,
onSubmitted: { [weak self] in
onSubmitted: { [weak self] submission in
guard let self else { return }
self.viewModel.completeAIRetouchSubmission()
self.showToast("AI修图任务已提交")
self.presentAIRetouchSubmitted(submission)
}
)
present(controller, animated: true)
}
private func presentAIRetouchSubmitted(_ submission: TravelAlbumAIJobSubmission) {
let alert = UIAlertController(
title: "AI修图任务已提交",
message: "完成后将通过消息通知,你也可以随时查看处理进度。",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "稍后查看", style: .cancel))
alert.addAction(UIAlertAction(title: "查看任务", style: .default) { [weak self] _ in
self?.navigationController?.pushViewController(
TravelAlbumAIJobDetailViewController(batchId: submission.aiRetouchBatchId),
animated: true
)
})
present(alert, animated: true)
}
@objc private func deleteSelectedTapped() {
let count = viewModel.selectedMaterialIds.count
guard count > 0 else { return }
@@ -25,6 +25,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
private let loadMore: TravelAlbumPreviewLoadMore?
private let reload: TravelAlbumPreviewReload?
private let onProjectDeleted: ((Int) -> Void)?
private let allowsActions: Bool
private var nodes: [TravelAlbumPreviewNode] = []
private var currentNodeIndex = 0
private var dragStartIndex = 0
@@ -63,6 +64,8 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
projects: [TravelAlbumPreviewProject],
totalCount: Int,
startProjectIndex: Int,
startKind: TravelAlbumPreviewAssetKind = .original,
allowsActions: Bool = true,
configuration: TravelAlbumPreviewConfiguration = .init(),
actionHandler: any TravelAlbumPreviewActionHandling = PlaceholderTravelAlbumPreviewActionHandler(),
albumId: Int = 0,
@@ -82,9 +85,10 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
self.loadMore = loadMore
self.reload = reload
self.onProjectDeleted = onProjectDeleted
self.allowsActions = allowsActions
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
rebuildNodes(keepingProjectIndex: max(0, min(startProjectIndex, projects.count - 1)), kind: .original)
rebuildNodes(keepingProjectIndex: max(0, min(startProjectIndex, projects.count - 1)), kind: startKind)
}
@available(*, unavailable)
@@ -196,6 +200,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
configureHighResolutionButton()
configureComparisonButton()
configureActions()
actionStack.isHidden = !allowsActions
let titleStack = UIStackView(arrangedSubviews: [titleLabel, sizeLabel])
titleStack.axis = .vertical
@@ -721,15 +726,34 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
workflow: workflow
),
api: aiRetouchAPI,
onSubmitted: { [weak self] in
onSubmitted: { [weak self] submission in
guard let self else { return }
self.showPreviewToast("AI修图任务已提交")
self.reloadProjects(showGlobalLoading: false, forceRefreshImage: false)
self.presentAIRetouchSubmitted(submission)
}
)
present(controller, animated: true)
}
private func presentAIRetouchSubmitted(_ submission: TravelAlbumAIJobSubmission) {
let alert = UIAlertController(
title: "AI修图任务已提交",
message: "完成后将通过消息通知。",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "知道了", style: .cancel))
alert.addAction(UIAlertAction(title: "查看任务", style: .default) { [weak self] _ in
guard let self, let navigationController = presentingViewController?.navigationController else { return }
dismiss(animated: true) {
navigationController.pushViewController(
TravelAlbumAIJobDetailViewController(batchId: submission.aiRetouchBatchId),
animated: true
)
}
})
present(alert, animated: true)
}
@objc private func deleteTapped() {
guard currentProject != nil, !isDeletingProject else { return }
let alert = UIAlertController(