diff --git a/docs/design/ai-retouch-task-center/01-task-list.png b/docs/design/ai-retouch-task-center/01-task-list.png new file mode 100644 index 0000000..62b80d7 Binary files /dev/null and b/docs/design/ai-retouch-task-center/01-task-list.png differ diff --git a/docs/design/ai-retouch-task-center/02-task-detail-v2.png b/docs/design/ai-retouch-task-center/02-task-detail-v2.png new file mode 100644 index 0000000..634ea05 Binary files /dev/null and b/docs/design/ai-retouch-task-center/02-task-detail-v2.png differ diff --git a/docs/design/ai-retouch-task-center/02-task-detail.png b/docs/design/ai-retouch-task-center/02-task-detail.png new file mode 100644 index 0000000..8922e69 Binary files /dev/null and b/docs/design/ai-retouch-task-center/02-task-detail.png differ diff --git a/suixinkan/Core/Push/PushModels.swift b/suixinkan/Core/Push/PushModels.swift index a8a2fb2..a0dbd0f 100644 --- a/suixinkan/Core/Push/PushModels.swift +++ b/suixinkan/Core/Push/PushModels.swift @@ -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", ] diff --git a/suixinkan/Features/MessageCenter/Models/MessageCenterModels.swift b/suixinkan/Features/MessageCenter/Models/MessageCenterModels.swift index 207f1af..f852bbb 100644 --- a/suixinkan/Features/MessageCenter/Models/MessageCenterModels.swift +++ b/suixinkan/Features/MessageCenter/Models/MessageCenterModels.swift @@ -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( diff --git a/suixinkan/Features/MessageCenter/ViewModels/MessageCenterViewModels.swift b/suixinkan/Features/MessageCenter/ViewModels/MessageCenterViewModels.swift index 8fd798c..9857b81 100644 --- a/suixinkan/Features/MessageCenter/ViewModels/MessageCenterViewModels.swift +++ b/suixinkan/Features/MessageCenter/ViewModels/MessageCenterViewModels.swift @@ -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 { diff --git a/suixinkan/Features/TravelAlbum/AI修图任务中心需求与接口设计.md b/suixinkan/Features/TravelAlbum/AI修图任务中心需求与接口设计.md new file mode 100644 index 0000000..6aabb9c --- /dev/null +++ b/suixinkan/Features/TravelAlbum/AI修图任务中心需求与接口设计.md @@ -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= +Authorization: Bearer +``` + +| 参数 | 必填 | 类型 | 默认值 | 说明 | +|---|---|---|---|---| +| `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 +``` + +| 参数 | 必填 | 类型 | 说明 | +|---|---|---|---| +| `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,不把设计稿作为页面背景切图。 diff --git a/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift b/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift index b7bda96..0564e04 100644 --- a/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift +++ b/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift @@ -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))] + ) ) } } diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumAIJobModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumAIJobModels.swift new file mode 100644 index 0000000..efbdd53 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumAIJobModels.swift @@ -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()) + } +} diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIJobViewModels.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIJobViewModels.swift new file mode 100644 index 0000000..b8fe4cf --- /dev/null +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIJobViewModels.swift @@ -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?() } +} diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift index 0e76259..ca3e7c4 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift @@ -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 { diff --git a/suixinkan/UI/MainTab/MainTabBarController.swift b/suixinkan/UI/MainTab/MainTabBarController.swift index 8bfa7a4..07d0aff 100644 --- a/suixinkan/UI/MainTab/MainTabBarController.swift +++ b/suixinkan/UI/MainTab/MainTabBarController.swift @@ -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 { diff --git a/suixinkan/UI/MessageCenter/MessageDetailViewController.swift b/suixinkan/UI/MessageCenter/MessageDetailViewController.swift index 8b066b2..41de871 100644 --- a/suixinkan/UI/MessageCenter/MessageDetailViewController.swift +++ b/suixinkan/UI/MessageCenter/MessageDetailViewController.swift @@ -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)) diff --git a/suixinkan/UI/TravelAlbum/BeforeAfterComparisonViewController.swift b/suixinkan/UI/TravelAlbum/BeforeAfterComparisonViewController.swift index 321f770..f0a31c0 100644 --- a/suixinkan/UI/TravelAlbum/BeforeAfterComparisonViewController.swift +++ b/suixinkan/UI/TravelAlbum/BeforeAfterComparisonViewController.swift @@ -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) { diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumAIJobDetailViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumAIJobDetailViewController.swift new file mode 100644 index 0000000..ff033b1 --- /dev/null +++ b/suixinkan/UI/TravelAlbum/TravelAlbumAIJobDetailViewController.swift @@ -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? + 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" + } + } +} diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumAIJobListViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumAIJobListViewController.swift new file mode 100644 index 0000000..bba80b3 --- /dev/null +++ b/suixinkan/UI/TravelAlbum/TravelAlbumAIJobListViewController.swift @@ -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! + private var pollingTask: Task? + 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(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() + 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?() } +} diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift index 4196bef..6486e40 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumAIRetouchTemplateViewController.swift @@ -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! @@ -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) } } } } diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift index d22570c..be89bf3 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift @@ -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 } diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift index 51658fb..4d8e444 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift @@ -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( diff --git a/suixinkanTests/MessageCenterAPITests.swift b/suixinkanTests/MessageCenterAPITests.swift index 9783949..1551eb8 100644 --- a/suixinkanTests/MessageCenterAPITests.swift +++ b/suixinkanTests/MessageCenterAPITests.swift @@ -19,8 +19,8 @@ final class MessageCenterAPITests: XCTestCase { "id": 21, "receiver_id": 9, "receiver_type": "staff", - "type": 2, - "type_name": "退款成功通知", + "type": 14, + "type_name": "AI修图任务通知", "title": "unused", "content": "退款已完成", "push_at": "2026-07-09 10:00:00", @@ -28,7 +28,7 @@ final class MessageCenterAPITests: XCTestCase { "read_at": "", "push_channel": 1, "push_channel_name": "站内信", - "extra_data": {"order_no":"NO123","amount":12.5}, + "extra_data": {"ai_retouch_batch_id":"59","user_equity_travel_id":17}, "created_at": "2026-07-09 10:00:00", "updated_at": "2026-07-09 10:01:00" }] @@ -50,7 +50,7 @@ final class MessageCenterAPITests: XCTestCase { XCTAssertEqual(response.lastId, 18) XCTAssertEqual(response.items.first?.id, 21) XCTAssertEqual(response.items.first?.isRead, false) - XCTAssertEqual(response.items.first?.extraData?["order_no"], .string("NO123")) + XCTAssertEqual(response.items.first?.aiRetouchBatchId, 59) } func testUnreadCountReadAllReadAndDeleteUseExpectedContracts() async throws { diff --git a/suixinkanTests/MessageCenterViewModelTests.swift b/suixinkanTests/MessageCenterViewModelTests.swift index 449ad43..390e800 100644 --- a/suixinkanTests/MessageCenterViewModelTests.swift +++ b/suixinkanTests/MessageCenterViewModelTests.swift @@ -178,6 +178,44 @@ final class MessageCenterViewModelTests: XCTestCase { XCTAssertEqual(failureMessage, "删除失败: server down") } + + func testAIRetouchMessageExposesTaskActionAndParsesDirectBatchId() { + let message = makeMessage( + id: 14, + type: 14, + extraData: ["ai_retouch_batch_id": .number(59)] + ) + let viewModel = MessageDetailViewModel(message: message) + + XCTAssertTrue(viewModel.showsAIRetouchTaskAction) + XCTAssertEqual(viewModel.aiRetouchBatchId, 59) + } + + func testAIRetouchMessageParsesNestedStringBatchIdAndFallsBackWhenInvalid() { + let nested = makeMessage( + id: 15, + type: 14, + extraData: ["data": .object(["ai_retouch_batch_id": .string(" 61 ")])] + ) + let wrapped = makeMessage( + id: 17, + type: 14, + extraData: ["data": .string(#"{"ai_retouch_batch_id":63}"#)] + ) + let missing = makeMessage(id: 16, type: 14, extraData: [:]) + let manualRetouch = makeMessage( + id: 10, + type: 10, + extraData: ["ai_retouch_batch_id": .number(59)] + ) + + XCTAssertEqual(MessageDetailViewModel(message: nested).aiRetouchBatchId, 61) + XCTAssertEqual(MessageDetailViewModel(message: wrapped).aiRetouchBatchId, 63) + XCTAssertTrue(MessageDetailViewModel(message: missing).showsAIRetouchTaskAction) + XCTAssertNil(MessageDetailViewModel(message: missing).aiRetouchBatchId) + XCTAssertFalse(MessageDetailViewModel(message: manualRetouch).showsAIRetouchTaskAction) + XCTAssertNil(MessageDetailViewModel(message: manualRetouch).aiRetouchBatchId) + } } @MainActor @@ -226,12 +264,17 @@ private struct TestError: LocalizedError { var errorDescription: String? { message } } -private func makeMessage(id: Int, isRead: Bool = false) -> MessageItem { +private func makeMessage( + id: Int, + isRead: Bool = false, + type: Int = 1, + extraData: [String: MessageJSONValue]? = nil +) -> MessageItem { MessageItem( id: id, receiverId: 1, receiverType: "staff", - type: 1, + type: type, typeName: "系统通知", title: "系统通知", content: "消息内容", @@ -240,6 +283,7 @@ private func makeMessage(id: Int, isRead: Bool = false) -> MessageItem { readAt: "", pushChannel: 1, pushChannelName: "站内信", + extraData: extraData, createdAt: "2026-07-09 10:00:00", updatedAt: "2026-07-09 10:00:00" ) diff --git a/suixinkanTests/PushNotificationTests.swift b/suixinkanTests/PushNotificationTests.swift index 7f1317f..aaef0cc 100644 --- a/suixinkanTests/PushNotificationTests.swift +++ b/suixinkanTests/PushNotificationTests.swift @@ -63,6 +63,34 @@ final class PushNotificationTests: XCTestCase { XCTAssertEqual(nested.destination, .paymentDetails) } + func testAIRetouchPushRoutesToDetailAndFallsBackToList() { + XCTAssertEqual( + PushPayload(userInfo: [ + "type": 14, + "data": ["ai_retouch_batch_id": 91, "status": "succeeded"], + ]).destination, + .aiRetouchTaskDetail(batchId: 91) + ) + XCTAssertEqual( + PushPayload(userInfo: ["type": 14, "data": [:]]).destination, + .aiRetouchTaskList + ) + XCTAssertEqual( + PushPayload(userInfo: ["type": 14, "data": ["ai_retouch_batch_id": "0"]]).destination, + .aiRetouchTaskList + ) + XCTAssertEqual(PushPayload(userInfo: ["type": 10]).destination, .messageCenter) + } + + func testAIRetouchPushReadsEncodedDataAndVendorWrapper() { + let payload = PushPayload(userInfo: [ + "n_extras": #"{"type":14,"data":"{\"ai_retouch_batch_id\":92}"}"#, + ]) + + XCTAssertEqual(payload.destination, .aiRetouchTaskDetail(batchId: 92)) + XCTAssertEqual(payload.normalizedValues["ai_retouch_batch_id"], "92") + } + func testPushAPIUsesAndroidCompatibleEndpointAndQuery() async throws { let session = MockURLSession(responses: [try TestJSON.envelope(data: EmptyPayload())]) let api = PushAPI(client: APIClient(environment: .testing, session: session)) diff --git a/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift b/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift index 6f86ee4..656fa68 100644 --- a/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift +++ b/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift @@ -216,7 +216,7 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase { var message: String? var submitted = false viewModel.onShowMessage = { message = $0 } - viewModel.onSubmitted = { submitted = true } + viewModel.onSubmitted = { _ in submitted = true } await viewModel.loadTemplates(api: api) await viewModel.submit(api: api) diff --git a/suixinkanTests/TravelAlbumAPITests.swift b/suixinkanTests/TravelAlbumAPITests.swift index c6a85c4..d91733d 100644 --- a/suixinkanTests/TravelAlbumAPITests.swift +++ b/suixinkanTests/TravelAlbumAPITests.swift @@ -183,7 +183,7 @@ final class TravelAlbumAPITests: XCTestCase { } func testSubmitAIRetouchEncodesRequiredAndSelectedOptionalTemplates() async throws { - let session = MockURLSession(responses: [envelopeJSON(#"{"task_id":9}"#)]) + let session = MockURLSession(responses: [jobSubmissionJSON(batchId: 9, albumId: 6)]) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) try await api.submitAIRetouch( @@ -215,7 +215,7 @@ final class TravelAlbumAPITests: XCTestCase { } func testSubmitAIRetouchOmitsAllOptionalTemplatesWhenAbsent() async throws { - let session = MockURLSession(responses: [envelopeJSON(#"{"accepted":true}"#)]) + let session = MockURLSession(responses: [jobSubmissionJSON(batchId: 10, albumId: 6)]) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) try await api.submitAIRetouch( @@ -240,9 +240,9 @@ final class TravelAlbumAPITests: XCTestCase { func testSubmitAIReretouchEncodesOnlyFieldsRequiredByEachType() async throws { let session = MockURLSession(responses: [ - envelopeJSON(#"{"accepted":true}"#), - envelopeJSON(#"{"accepted":true}"#), - envelopeJSON(#"{"accepted":true}"#), + jobSubmissionJSON(batchId: 51, albumId: 6), + jobSubmissionJSON(batchId: 52, albumId: 6), + jobSubmissionJSON(batchId: 53, albumId: 6), ]) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) @@ -295,9 +295,56 @@ final class TravelAlbumAPITests: XCTestCase { XCTAssertEqual(bodies[2]?["type"] as? Int, 3) } + func testAIJobListBuildsCursorQueryAndDecodesUnknownStatusSafely() async throws { + let data = envelopeJSON( + #"{"items":[{"ai_retouch_batch_id":91,"user_equity_travel_id":6,"scope":"album","status":"future_status","album":{"id":6,"name":"九寨沟旅拍","user_phone":"138****0000","cover_url":""},"source_count":1,"outputs":[{"type":"refined","count":1}],"preview_images":[],"progress":{"total":1,"queued":1,"processing":0,"succeeded":0,"failed":0,"canceled":0},"estimated_finish_at":null,"failure_summary":null,"created_at":"2026-08-14T06:00:00Z","started_at":null,"finished_at":null}],"next_cursor":"cursor-2","has_more":true}"# + ) + let session = MockURLSession(responses: [data]) + let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) + + let response = try await api.aiRetouchJobList( + statusGroup: .inProgress, + limit: 20, + cursor: "cursor-1" + ) + + XCTAssertEqual(response.items.first?.status, .unknown("future_status")) + XCTAssertEqual(response.nextCursor, "cursor-2") + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch-job-list") + let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(query?.first { $0.name == "status_group" }?.value, "in_progress") + XCTAssertEqual(query?.first { $0.name == "limit" }?.value, "20") + XCTAssertEqual(query?.first { $0.name == "cursor" }?.value, "cursor-1") + } + + func testAIJobInfoDecodesPerOutputFailureReason() async throws { + let data = envelopeJSON( + #"{"ai_retouch_batch_id":91,"user_equity_travel_id":6,"scope":"album","status":"partially_succeeded","album":{"id":6,"name":"九寨沟旅拍","user_phone":"138****0000","cover_url":""},"source_count":1,"outputs":[{"type":"refined","count":1}],"progress":{"total":1,"queued":0,"processing":0,"succeeded":0,"failed":1,"canceled":0},"quota_settlement":{"status":"settled","reserved_units":1,"consumed_units":0,"released_units":1,"cover_units":0},"targets":[{"target_id":1,"source_material":{"id":11,"file_name":"A.JPG","thumbnail_url":""},"input_material_ids":[11],"output_type":"refined","template":{"id":21,"name":"清透"},"status":"failed","result_asset":null,"error":{"code":"FACE_NOT_FOUND","message":"未识别到清晰人脸,请更换照片","retryable":true},"created_at":"2026-08-14T06:00:00Z","started_at":null,"finished_at":"2026-08-14T06:01:00Z"}],"estimated_finish_at":null,"created_at":"2026-08-14T06:00:00Z","started_at":null,"finished_at":"2026-08-14T06:01:00Z","duration_seconds":60}"# + ) + let session = MockURLSession(responses: [data]) + let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) + + let detail = try await api.aiRetouchJobInfo(batchId: 91) + + XCTAssertEqual(detail.status, .partiallySucceeded) + XCTAssertEqual(detail.targets.first?.displayFailureMessage, "未识别到清晰人脸,请更换照片") + XCTAssertEqual(detail.targets.first?.error?.retryable, true) + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch-job-info") + let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(query?.first { $0.name == "ai_retouch_batch_id" }?.value, "91") + } + private func envelopeJSON(_ dataJSON: String) -> Data { """ {"code":100000,"msg":"success","data":\(dataJSON)} """.data(using: .utf8)! } + + private func jobSubmissionJSON(batchId: Int, albumId: Int) -> Data { + envelopeJSON( + #"{"ai_retouch_batch_id":\#(batchId),"user_equity_travel_id":\#(albumId),"status":"queued","progress":{"total":3,"queued":3,"processing":0,"succeeded":0,"failed":0,"canceled":0},"created_at":"2026-08-14T06:00:00Z"}"# + ) + } } diff --git a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift index d7f9b1f..ffb707f 100644 --- a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift +++ b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift @@ -10,6 +10,231 @@ import XCTest /// 相册管理页刷新、预览与选择态交互测试。 @MainActor final class TravelAlbumDetailViewControllerTests: XCTestCase { + func testAIJobDetailMatchesDesignCardHierarchyAndShowsInlineFailureReason() async throws { + let api = TravelAlbumMockAPI() + let source = TravelAlbumAIJobSourceMaterial( + id: 691, + fileName: "IMG_8293.JPG", + thumbnailURL: "" + ) + let failedTarget = TravelAlbumAIJobTarget( + targetId: 3, + sourceMaterial: source, + inputMaterialIds: [691], + outputType: .refined, + template: TravelAlbumAIJobTemplate(id: 7, name: "自然通透"), + status: .failed, + resultAsset: nil, + error: TravelAlbumAIJobError( + code: "VENDOR_TIMEOUT", + message: "AI服务处理超时,请重新修图", + retryable: true + ), + createdAt: "2026-08-14T03:20:43.000Z", + startedAt: nil, + finishedAt: "2026-08-14T03:21:43.000Z" + ) + api.aiJobDetailResponse = TravelAlbumAIJobDetail( + aiRetouchBatchId: 59, + userEquityTravelId: 17, + scope: "batch", + status: .processing, + album: TravelAlbumAIJobAlbum( + id: 17, + name: "旅拍相册", + userPhone: "13222319413", + coverURL: "" + ), + sourceCount: 1, + outputs: [ + TravelAlbumAIJobOutput(type: .refined, count: 4), + TravelAlbumAIJobOutput(type: .atmosphere, count: 4), + TravelAlbumAIJobOutput(type: .cover, count: 1), + ], + progress: TravelAlbumAIJobProgress( + total: 9, + queued: 2, + processing: 1, + succeeded: 5, + failed: 1, + canceled: 0 + ), + quotaSettlement: TravelAlbumAIJobQuotaSettlement( + status: "reserved", + reservedUnits: 9, + consumedUnits: 6, + releasedUnits: 0, + coverUnits: 1 + ), + targets: [failedTarget], + estimatedFinishAt: "2026-08-14T03:32:00.000Z", + createdAt: "2026-08-14T03:20:43.000Z", + startedAt: "2026-08-14T03:20:44.000Z", + finishedAt: nil, + durationSeconds: nil + ) + let controller = TravelAlbumAIJobDetailViewController(batchId: 59, api: api) + let navigationController = UINavigationController(rootViewController: controller) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = navigationController + window.makeKeyAndVisible() + defer { window.isHidden = true } + + controller.loadViewIfNeeded() + await waitUntil { + controller.view.findSubview { + $0.accessibilityIdentifier == "aiRetouchJob.detail.statusCard" + } != nil + } + controller.view.layoutIfNeeded() + + for identifier in [ + "aiRetouchJob.detail.statusCard", + "aiRetouchJob.detail.albumCard", + "aiRetouchJob.detail.contentCard", + "aiRetouchJob.detail.processingCard", + "aiRetouchJob.detail.albumButton", + ] { + XCTAssertNotNil(controller.view.findSubview { $0.accessibilityIdentifier == identifier }) + } + let labels = controller.view.allLabels().compactMap(\.text) + XCTAssertTrue(labels.contains("修图中")) + XCTAssertTrue(labels.contains("已完成 6 / 9")) + XCTAssertTrue(labels.contains("67%")) + XCTAssertTrue(labels.contains("失败原因:AI服务处理超时,请重新修图")) + XCTAssertFalse(labels.contains("去相册处理")) + XCTAssertFalse(labels.contains { $0.contains("VENDOR_TIMEOUT") }) + let detailScreenshot = UIGraphicsImageRenderer(bounds: window.bounds).image { context in + window.layer.render(in: context.cgContext) + } + let detailAttachment = XCTAttachment(image: detailScreenshot) + detailAttachment.name = "AI修图任务详情设计还原" + detailAttachment.lifetime = .keepAlways + add(detailAttachment) + } + + func testAIJobListCardMatchesDesignStructureAndKeepsSinglePreviewInGrid() async throws { + let api = TravelAlbumMockAPI() + let job = TravelAlbumAIJobSummary( + aiRetouchBatchId: 59, + userEquityTravelId: 17, + scope: "batch", + status: .queued, + album: TravelAlbumAIJobAlbum( + id: 17, + name: "2026-06-17-005", + userPhone: "13222319413", + coverURL: "" + ), + sourceCount: 1, + outputs: [ + TravelAlbumAIJobOutput(type: .refined, count: 1), + TravelAlbumAIJobOutput(type: .atmosphere, count: 1), + ], + previewImages: [TravelAlbumAIJobPreviewImage(materialId: 691, thumbnailURL: "")], + progress: TravelAlbumAIJobProgress( + total: 2, + queued: 2, + processing: 0, + succeeded: 0, + failed: 0, + canceled: 0 + ), + estimatedFinishAt: "2026-08-14T03:23:34.147Z", + failureSummary: nil, + createdAt: "2026-08-14T03:20:43.000Z", + startedAt: nil, + finishedAt: nil + ) + api.aiJobListResponses = [ + TravelAlbumAIJobListResponse(items: [job], nextCursor: nil, hasMore: false), + ] + let controller = TravelAlbumAIJobListViewController(api: api) + let navigationController = UINavigationController(rootViewController: controller) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = navigationController + window.makeKeyAndVisible() + defer { window.isHidden = true } + + controller.loadViewIfNeeded() + await waitUntil { api.aiJobListRequests.count == 1 } + try await Task.sleep(for: .milliseconds(150)) + controller.view.layoutIfNeeded() + let collectionView = try XCTUnwrap( + controller.view.findSubview { $0 is UICollectionView } as? UICollectionView + ) + collectionView.layoutIfNeeded() + let cell = try XCTUnwrap(collectionView.cellForItem(at: IndexPath(item: 0, section: 0))) + let albumCover = try XCTUnwrap( + cell.findSubview { $0.accessibilityIdentifier == "aiRetouchJob.albumCover" } + ) + let firstPreview = try XCTUnwrap( + cell.findSubview { $0.accessibilityIdentifier == "aiRetouchJob.preview.0" } + ) + let filterContainer = try XCTUnwrap( + controller.view.findSubview { $0.accessibilityIdentifier == "aiRetouchJob.filterContainer" } + ) + + XCTAssertEqual(filterContainer.bounds.height, 46, accuracy: 0.5) + XCTAssertEqual(albumCover.bounds.width, 68, accuracy: 2) + XCTAssertEqual(albumCover.bounds.height, 68, accuracy: 2) + XCTAssertLessThan(firstPreview.bounds.width, cell.bounds.width * 0.4) + XCTAssertTrue(cell.allLabels().contains { $0.text == "排队中" }) + XCTAssertTrue(cell.allLabels().contains { $0.text == "已完成 0 / 2" }) + XCTAssertTrue(cell.allLabels().contains { $0.text == "0%" }) + XCTAssertTrue(cell.allLabels().contains { $0.text == "精修 1张 · 氛围感 1张" }) + XCTAssertTrue(cell.allLabels().contains { $0.text == "查看详情" }) + let listScreenshot = UIGraphicsImageRenderer(bounds: window.bounds).image { context in + window.layer.render(in: context.cgContext) + } + let listAttachment = XCTAttachment(image: listScreenshot) + listAttachment.name = "AI修图任务列表设计还原" + listAttachment.lifetime = .keepAlways + add(listAttachment) + } + + func testAIJobEmptyInProgressFilterDismissesGlobalLoading() async throws { + GlobalLoadingManager.shared.hideAll() + defer { GlobalLoadingManager.shared.hideAll() } + let api = TravelAlbumMockAPI() + api.aiJobListDelayNanoseconds = 30_000_000 + api.aiJobListResponses = [ + TravelAlbumAIJobListResponse(items: [], nextCursor: nil, hasMore: false), + TravelAlbumAIJobListResponse(items: [], nextCursor: nil, hasMore: false), + ] + let controller = TravelAlbumAIJobListViewController(api: api) + controller.loadViewIfNeeded() + controller.viewDidAppear(false) + defer { controller.viewWillDisappear(false) } + + await waitUntil { api.aiJobListRequests.count == 1 && !GlobalLoadingManager.shared.isShowing } + let inProgressButton = try XCTUnwrap( + controller.view.findSubview { + ($0 as? UIButton)?.accessibilityLabel == "筛选:进行中" + } as? UIButton + ) + + inProgressButton.sendActions(for: .touchUpInside) + + await waitUntil { + api.aiJobListRequests.count == 2 && + api.aiJobListRequests.last?.statusGroup == .inProgress && + !GlobalLoadingManager.shared.isShowing + } + XCTAssertFalse(GlobalLoadingManager.shared.isShowing) + } + + func testAIJobEntryOnlyAppearsOnAlbumManagementNavigationBar() { + let detail = TravelAlbumDetailViewController(albumId: 2, api: TravelAlbumMockAPI()) + detail.setupNavigationBar() + let entry = TravelAlbumEntryViewController(api: TravelAlbumMockAPI()) + entry.setupNavigationBar() + + XCTAssertTrue(detail.navigationItem.rightBarButtonItems?.contains { $0.title == "修图任务" } == true) + XCTAssertNil(entry.navigationItem.rightBarButtonItem) + XCTAssertTrue(entry.navigationItem.rightBarButtonItems?.isEmpty ?? true) + } + func testPullToRefreshReloadsGridAndEndsRefreshing() async throws { let api = TravelAlbumMockAPI() api.infoResponse = TravelAlbum(id: 2, name: "测试相册") @@ -483,7 +708,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { let controller = TravelAlbumAIRetouchTemplateViewController( viewModel: viewModel, api: api, - onSubmitted: {} + onSubmitted: { _ in } ) controller.loadViewIfNeeded() controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) @@ -538,7 +763,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { XCTAssertEqual(selectionCountLabel.text, "已选择 4 张照片") XCTAssertEqual( tipsLabel.text, - "每张照片生成精修结果,氛围感可选;本次可免费生成 1 张封面" + "Tips:氛围感修图为选填,可横向选择一种样式;选中后每张照片会额外生成1个独立结果,第一张照片仍另生成封面。" ) XCTAssertFalse(tipsContainer.isHidden) XCTAssertEqual(tipsContainer.backgroundColor?.travelAlbumTestHexRGB, 0xF4F8FF) @@ -568,7 +793,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { let controller = TravelAlbumAIRetouchTemplateViewController( viewModel: viewModel, api: api, - onSubmitted: {} + onSubmitted: { _ in } ) controller.loadViewIfNeeded() controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) @@ -729,7 +954,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { XCTAssertEqual(comparisonButton.bounds.size, CGSize(width: 48, height: 48)) } - func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndStaysPresented() async throws { + func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndShowsTaskAction() async throws { UIView.setAnimationsEnabled(false) defer { UIView.setAnimationsEnabled(true) } let api = TravelAlbumMockAPI() @@ -823,10 +1048,12 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { XCTAssertTrue(tipsLabel.isHidden) confirmButton.sendActions(for: .touchUpInside) await waitUntil { api.aiReretouchRequests.count == 1 } - await waitUntil { controller.presentedViewController == nil } + await waitUntil { controller.presentedViewController is UIAlertController } XCTAssertEqual(api.aiReretouchRequests.first?.type, .refined) - XCTAssertNil(controller.presentedViewController) + let successAlert = try XCTUnwrap(controller.presentedViewController as? UIAlertController) + XCTAssertEqual(successAlert.title, "AI修图任务已提交") + XCTAssertEqual(successAlert.actions.map(\.title), ["知道了", "查看任务"]) XCTAssertTrue(window.rootViewController === controller) } diff --git a/suixinkanTests/TravelAlbumViewModelTests.swift b/suixinkanTests/TravelAlbumViewModelTests.swift index 65b9227..60a2b51 100644 --- a/suixinkanTests/TravelAlbumViewModelTests.swift +++ b/suixinkanTests/TravelAlbumViewModelTests.swift @@ -6,6 +6,96 @@ import XCTest @testable import suixinkan +/// AI 修图任务中心 ViewModel 测试。 +@MainActor +final class TravelAlbumAIJobViewModelTests: XCTestCase { + func testListFiltersPaginatesAndDeduplicatesJobs() async { + let api = TravelAlbumMockAPI() + api.aiJobListResponses = [ + TravelAlbumAIJobListResponse( + items: [makeSummary(id: 1, status: .processing)], + nextCursor: "page-2", + hasMore: true + ), + TravelAlbumAIJobListResponse( + items: [makeSummary(id: 1, status: .processing), makeSummary(id: 2, status: .succeeded)], + nextCursor: nil, + hasMore: false + ), + ] + let viewModel = TravelAlbumAIJobListViewModel() + + await viewModel.selectFilter(.inProgress, api: api) + await viewModel.loadMore(api: api) + + XCTAssertEqual(viewModel.selectedFilter, .inProgress) + XCTAssertEqual(viewModel.items.map(\.id), [1, 2]) + XCTAssertTrue(viewModel.containsInProgressJobs) + XCTAssertEqual(api.aiJobListRequests.map(\.cursor), [nil, "page-2"]) + XCTAssertEqual(api.aiJobListRequests.map(\.statusGroup), [.inProgress, .inProgress]) + } + + func testDetailStopsPollingAtTerminalStateAndRecognizes404() async { + let api = TravelAlbumMockAPI() + api.aiJobDetailResponse = makeDetail(status: .succeeded) + let viewModel = TravelAlbumAIJobDetailViewModel(batchId: 91) + + await viewModel.load(api: api) + + XCTAssertFalse(viewModel.shouldPoll) + XCTAssertEqual(viewModel.detail?.targets.first?.displayFailureMessage, "处理失败,请前往相册重新修图") + + let missing = TravelAlbumAIJobDetailViewModel(batchId: 92) + api.aiJobDetailResponse = nil + var notFoundCount = 0 + missing.onNotFound = { notFoundCount += 1 } + await missing.load(api: api) + await missing.load(api: api) + + XCTAssertTrue(missing.isNotFound) + XCTAssertEqual(notFoundCount, 1) + } + + private func makeSummary(id: Int, status: TravelAlbumAIJobStatus) -> TravelAlbumAIJobSummary { + TravelAlbumAIJobSummary( + aiRetouchBatchId: id, + userEquityTravelId: 6, + scope: "album", + status: status, + album: TravelAlbumAIJobAlbum(id: 6, name: "九寨沟", userPhone: "138****0000", coverURL: ""), + sourceCount: 1, + outputs: [TravelAlbumAIJobOutput(type: .refined, count: 1)], + previewImages: [], + progress: TravelAlbumAIJobProgress(total: 1, queued: 0, processing: status.isInProgress ? 1 : 0, succeeded: status == .succeeded ? 1 : 0, failed: 0, canceled: 0), + estimatedFinishAt: nil, + failureSummary: nil, + createdAt: "2026-08-14T06:00:00Z", + startedAt: nil, + finishedAt: nil + ) + } + + private func makeDetail(status: TravelAlbumAIJobStatus) -> TravelAlbumAIJobDetail { + TravelAlbumAIJobDetail( + aiRetouchBatchId: 91, + userEquityTravelId: 6, + scope: "album", + status: status, + album: TravelAlbumAIJobAlbum(id: 6, name: "九寨沟", userPhone: "138****0000", coverURL: ""), + sourceCount: 1, + outputs: [TravelAlbumAIJobOutput(type: .refined, count: 1)], + progress: TravelAlbumAIJobProgress(total: 1, queued: 0, processing: 0, succeeded: 0, failed: 1, canceled: 0), + quotaSettlement: TravelAlbumAIJobQuotaSettlement(status: "settled", reservedUnits: 1, consumedUnits: 0, releasedUnits: 1, coverUnits: 0), + targets: [TravelAlbumAIJobTarget(targetId: 1, sourceMaterial: nil, inputMaterialIds: [11], outputType: .refined, template: nil, status: .failed, resultAsset: nil, error: nil, createdAt: "", startedAt: nil, finishedAt: nil)], + estimatedFinishAt: nil, + createdAt: "2026-08-14T06:00:00Z", + startedAt: nil, + finishedAt: "2026-08-14T06:01:00Z", + durationSeconds: 60 + ) + } +} + @MainActor /// 旅拍相册 ViewModel 测试。 final class TravelAlbumEntryViewModelTests: XCTestCase { @@ -1076,6 +1166,11 @@ private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading { /// 旅拍相册 API 测试替身。 @MainActor final class TravelAlbumMockAPI: TravelAlbumServing { + struct AIJobListRequest: Equatable { + let statusGroup: TravelAlbumAIJobFilter + let limit: Int + let cursor: String? + } struct MaterialRequest: Equatable { let userEquityTravelId: Int let page: Int @@ -1107,6 +1202,17 @@ final class TravelAlbumMockAPI: TravelAlbumServing { var submitAIRetouchDelayNanoseconds: UInt64 = 0 var submitAIReretouchError: Error? var submitAIReretouchDelayNanoseconds: UInt64 = 0 + var aiJobSubmission = TravelAlbumAIJobSubmission( + aiRetouchBatchId: 1, + userEquityTravelId: 1, + status: .queued, + progress: TravelAlbumAIJobProgress(total: 1, queued: 1, processing: 0, succeeded: 0, failed: 0, canceled: 0), + createdAt: "2026-08-14T06:26:12.123Z" + ) + var aiJobListResponse = TravelAlbumAIJobListResponse(items: [], nextCursor: nil, hasMore: false) + var aiJobListResponses: [TravelAlbumAIJobListResponse] = [] + var aiJobListDelayNanoseconds: UInt64 = 0 + var aiJobDetailResponse: TravelAlbumAIJobDetail? var deleteMaterialError: Error? var deleteMaterialDelayNanoseconds: UInt64 = 0 @@ -1121,6 +1227,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing { private(set) var aiRetouchTemplateScenicIds: [Int] = [] private(set) var aiRetouchRequests: [TravelAlbumAIRetouchRequest] = [] private(set) var aiReretouchRequests: [TravelAlbumAIReretouchRequest] = [] + private(set) var aiJobListRequests: [AIJobListRequest] = [] func availableOrders() async throws -> [TravelAlbumAvailableOrder] { availableOrdersCallCount += 1 @@ -1205,19 +1312,39 @@ final class TravelAlbumMockAPI: TravelAlbumServing { return aiRetouchTemplatesResponse } - func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws { + func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission { aiRetouchRequests.append(request) if submitAIRetouchDelayNanoseconds > 0 { try await Task.sleep(nanoseconds: submitAIRetouchDelayNanoseconds) } if let submitAIRetouchError { throw submitAIRetouchError } + return aiJobSubmission } - func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws { + func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws -> TravelAlbumAIJobSubmission { aiReretouchRequests.append(request) if submitAIReretouchDelayNanoseconds > 0 { try await Task.sleep(nanoseconds: submitAIReretouchDelayNanoseconds) } if let submitAIReretouchError { throw submitAIReretouchError } + return aiJobSubmission + } + + func aiRetouchJobList( + statusGroup: TravelAlbumAIJobFilter, + limit: Int, + cursor: String? + ) async throws -> TravelAlbumAIJobListResponse { + aiJobListRequests.append(AIJobListRequest(statusGroup: statusGroup, limit: limit, cursor: cursor)) + if aiJobListDelayNanoseconds > 0 { + try await Task.sleep(nanoseconds: aiJobListDelayNanoseconds) + } + if !aiJobListResponses.isEmpty { return aiJobListResponses.removeFirst() } + return aiJobListResponse + } + + func aiRetouchJobInfo(batchId: Int) async throws -> TravelAlbumAIJobDetail { + guard let aiJobDetailResponse else { throw APIError.httpStatus(404, "not found") } + return aiJobDetailResponse } }