26 Commits
Author SHA1 Message Date
lujiuyin 30bfe0313f feat: add AI retouch task center 2026-08-14 15:06:57 +08:00
lujiuyin 0f3b26991e feat: add AI retouch before-after comparison 2026-08-13 17:46:06 +08:00
lujiuyin e190b5958b 调整图片预览关闭按钮图标 2026-08-13 16:25:27 +08:00
lujiuyin 6e2899dbfd 优化图片预览分段控件样式 2026-08-13 16:11:36 +08:00
lujiuyin 48b6b9be22 fix: 优化图片预览索引胶囊 2026-08-13 15:36:06 +08:00
lujiuyin 202b0f5e41 fix: 优化AI修图标签显示 2026-08-13 15:27:13 +08:00
lujiuyin 7b9e630838 feat: 优化 AI 修图模板选择页面 2026-08-13 15:16:03 +08:00
lujiuyin 109ec3dad0 feat: 美化图片预览工具栏 2026-08-13 14:23:10 +08:00
lujiuyin f2a005e008 feat: 优化 AI 修图模板与全屏预览 2026-08-13 13:48:11 +08:00
lujiuyin 60502784db docs: 补充 AI 修图需求与接口说明 2026-08-13 09:16:39 +08:00
lujiuyin 05cfdc3662 feat: 优化相册图片预览交互 2026-08-12 10:58:53 +08:00
lujiuyin 010add828b feat: 优化AI修图模板选择界面 2026-08-11 17:04:51 +08:00
lujiuyin aca19a53fb feat: 使用旅拍相册批量删除接口 2026-08-11 15:57:19 +08:00
lujiuyin e64dcec094 feat: 完善相册 AI 修图模板选择流程 2026-08-11 15:22:08 +08:00
lujiuyin 43c75c8e36 feat: 接通图片预览 AI 修图流程 2026-08-11 09:52:15 +08:00
lujiuyin 5704531f3a fix(album): rename AI retouch action label 2026-08-10 17:56:29 +08:00
lujiuyin 87819780c9 feat(album): add pull-to-refresh to management grid 2026-08-10 17:46:25 +08:00
lujiuyin 3b88b9cde9 fix(album): prevent black screen when switching preview variants 2026-08-10 16:28:00 +08:00
lujiuyin 439edf827c feat: add AI retouch and album preview actions 2026-08-10 16:00:32 +08:00
lujiuyin 24fa66281c docs: add travel album design assets 2026-08-10 10:08:45 +08:00
lujiuyin 99fbad3e97 feat: add iOS travel album photo preview 2026-08-10 10:07:19 +08:00
lujiuyin dcdab3cff7 feat: redesign travel album management 2026-08-03 16:20:31 +08:00
lujiuyin 1cdce616e4 chore: 更新 iOS 版本号至 1.2.1 2026-07-31 15:25:59 +08:00
lujiuyin 212607fc82 fix: 增加登录失效确认弹窗 2026-07-31 15:25:12 +08:00
lujiuyin 0cf0a41a60 fix: 修正账号姓名展示与资料缓存 2026-07-31 15:19:22 +08:00
lujiuyinandCursor f863004c8d fix: 优化收款码加载态,避免首屏错误占位闪现
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 14:07:05 +08:00
89 changed files with 11153 additions and 284 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+85
View File
@@ -0,0 +1,85 @@
# AI 修图完整链路设计稿 V1
## 交付内容
| 文件 | 页面 / 状态 |
|---|---|
| `00-flow-overview.png` | 六张关键屏稿总览 |
| `01-album-multi-selection.png` | 相册管理 · 已选择 4 张照片 |
| `02-template-selection.png` | AI 修图模板选择 Sheet |
| `03-template-preview.png` | 模板全屏预览 |
| `04-result-success.png` | 精修结果 · 成功 |
| `05-result-processing.png` | 精修结果 · 生成新版本中 |
| `06-result-failed.png` | 精修结果 · 新版本生成失败 |
独立屏稿均为 853 × 1844 像素(约 9:19.5),面向 iOS 16+、iPhone 竖屏、浅色模式。
## 设计系统
- 主色:`#1677FF`;辅助渐变:`#5CA8FF`、`#A7D8FF`。
- 页面底色:`#F7FAFF`;主文字:`#172033`;次文字:`#667085`。
- 失败色:`#EF4444`;赠送/免费标签使用橙色,避免与主要操作竞争。
- 字体:SF Pro / 苹方风格;圆角 12–16 pt;以 8 pt 为基础间距。
- 正文置于高不透明度表面;极光光晕只用于背景与重点操作,不承载关键信息。
- 结果页只通过“原图 / 精修后 / 氛围感”标签切换,不使用对比滑杆。
- 重新修图期间及失败后保留上一版成功图片;状态反馈只占局部,不遮挡照片。
## 可复用 ImageGen 提示词
以下提示词使用内置 ImageGen。首屏以 `../travel-album-management-redesign-v2.png` 为风格参考;后续页面以上一张已确认屏稿作为参考,确保视觉连续。
### 通用前缀
```text
Use case: ui-mockup
Asset type: shippable high-fidelity iOS app screen, full portrait canvas, no device frame
Style/medium: realistic production UIKit UI, light Aurora blue, crisp SF Pro / PingFang-like typography, SF Symbols-style icons, 12–16 pt corners, 8 pt spacing grid, subtle borders and restrained shadows.
Color palette: primary #1677FF, gradient accents #5CA8FF and #A7D8FF, background #F7FAFF, text #172033 and #667085, danger #EF4444.
Constraints: iPhone portrait 9:19.5; iOS status bar and home indicator; practical UIKit-buildable hierarchy; accessible contrast; minimum 44 pt touch targets; no dark mode; no watermark; no decorative device frame; no annotations.
Text constraints: render specified Chinese text exactly once; no gibberish, duplicate labels or unrelated copy.
```
### 01 · 相册多选
```text
Create the album management screen in multi-selection mode. Top navigation: “相册管理” and “完成”. Album summary: “旅拍相册”, “138****8000”. Filter row: “全部 128”, “已购 36”, “时间 ↓”. Use a precise three-column grid of nine travel photos. Four photos have blue checkmarks, blue outlines and subtle selected overlays; other photos show empty selectors. Fixed bottom actions: red outline “删除 4 张” and blue gradient “AI修图 4 张”.
```
### 02 · 模板选择
```text
Create a large native page sheet titled “AI修图” with subtitle “已选择 4 张照片”. Show the note “每张照片生成精修结果,氛围感可选;本次可免费生成 1 张封面”. Include three horizontal sections: “原图精修” + “必选”, “氛围感修图” + “选填”, “封面风格” + “赠送 · 不占额度”. Each section has three photographic cards, a clear single selected state and “预览” buttons. Cost card: “AI精修 · 按张收费”, “预计消耗 8 张”, “剩余 12 张”. Fixed actions: “取消” and “确定”.
```
### 03 · 模板预览
```text
Create an immersive full-screen preview for the selected “自然通透” template. Use a dark navy photographic canvas, a circular back button, title “自然通透” and subtitle “双指缩放查看细节”. The large photo shows a woman in a straw hat overlooking Santorini. Add “模板效果预览”. Bottom information panel: “自然通透”, “提亮肤色与画面通透感,保留自然质感”, “当前已选择”. Primary action: “使用此模板”. Do not add a comparison slider.
```
### 04 · 正常结果
```text
Create “照片预览” with counter “1 / 9”. Add exactly three tabs: “原图”, “精修后”, “氛围感”; select “精修后”. Show the same Santorini photo with badge “AI已修”, filename “IMG_8291.JPG” and “自然通透 · 2026/08/13”. Information card: “精修结果”, “自然通透模板 · 已生成最新版本”. Fixed actions: “AI修图”, “删除”, “刷新”. Use tabs only; no slider or press-and-hold comparison.
```
### 05 · 处理中状态编辑
```text
Use case: precise-object-edit. Keep the successful result screen, photo, tabs and layout unchanged. Replace “AI已修” with spinner + “修图中”. Replace the result card with “正在生成新版本” and “当前仍展示上一版结果,完成后自动更新”, plus an indeterminate shimmer line with no percentage. Keep the previous photo unobscured. Disable only the “AI修图” button; leave “删除” and “刷新” available.
```
### 06 · 失败状态编辑
```text
Use case: precise-object-edit. Keep the successful result screen, photo, tabs and layout unchanged. Replace “AI已修” with a local red badge “生成失败”. Replace the result card with pale-red recovery content: “新版本生成失败”, “上一版结果已保留,可重新修图”, and an outline action “重新修图”. Keep the previous photo unobscured and restore the bottom “AI修图” button to its primary blue state. Do not show a modal, stack trace or error code.
```
## QA 记录
- 六张独立屏稿尺寸一致,使用同一旅拍照片体系与组件语言。
- 精修必选、氛围感选填、四张照片触发封面、封面不占额度均已表达。
- 成功、处理中、失败三种状态均保留同一照片与结果标签,便于直接比较。
- 处理中不展示虚构百分比;重复提交按钮禁用,刷新仍可用。
- 失败状态提供“重新修图”恢复路径,且上一版结果保持可见。
- 本目录只包含设计稿与说明,未修改 UIKit、业务逻辑或 Asset Catalog。
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+76
View File
@@ -0,0 +1,76 @@
# AI 修图完整链路设计稿 V2
## 本轮调整
- 第 1、2 张沿用 V1 的浅色“轻盈极光蓝”风格。
- 第 3–6 张改为统一的黑色专业影像工作区,突出照片本身。
- 暗色页面不做简单反色:背景、浮层、边框、文字和按钮均使用独立暗色语义层级。
- V1 完整保留在 `../ai-retouch-v1/`,便于对比和回退。
## 交付内容
| 文件 | 页面 / 状态 | 风格 |
|---|---|---|
| `00-flow-overview-v2.png` | 六张关键屏稿总览 | 混合流程 |
| `01-album-multi-selection.png` | 相册管理 · 已选择 4 张 | 浅色 |
| `02-template-selection.png` | AI 修图模板选择 Sheet | 浅色 |
| `03-template-preview-dark.png` | 模板全屏预览 | 暗色 |
| `04-result-success-dark.png` | 精修结果 · 成功 | 暗色 |
| `05-result-processing-dark.png` | 精修结果 · 生成中 | 暗色 |
| `06-result-failed-dark.png` | 精修结果 · 生成失败 | 暗色 |
## 暗色设计 Token
- 页面背景:`#05070D`,允许向深海军蓝 `#07111F` 做非常轻微的层次过渡。
- 抬升表面:`#111827`;边框:`#263244`。
- 主文字:`#F8FAFC`;次文字:`#9CA3AF`;未选中标签:`#C7CED9`。
- 品牌主色:`#1677FF`;蓝色高光:`#5CA8FF`。
- 失败色:`#EF4444`,仅用于失败徽标、恢复入口和局部边框。
- 图片保持明亮自然,不叠加全屏蒙层;信息区和操作区不出现白色表面。
## 可复用 ImageGen 暗色编辑提示词
以下提示词使用内置 ImageGen,以对应 V1 页面作为 edit target;第 3 张暗色稿确定后,也作为第 4–6 张的风格参考。
### 通用暗色编辑约束
```text
Use case: precise-object-edit
Restyle the target into a cohesive dark professional photography workspace.
Preserve the exact iPhone portrait dimensions, photo content and crop, person identity and pose, all Chinese copy, hierarchy, controls and safe-area spacing.
Use background #05070D, elevated surface #111827, border #263244, primary #1677FF, text #F8FAFC and secondary #9CA3AF.
Keep the bright photo unchanged as the focal point. Use white text and SF Symbols-style white icons. Use brand blue only for selected state and primary actions.
No white page areas, no comparison slider, no device frame, no annotations, no watermark and no additional copy.
```
### 模板预览
```text
Keep “自然通透”, “双指缩放查看细节”, the Santorini photo and “模板效果预览”. Change the lower information panel to charcoal with white heading, muted description and blue “当前已选择”. Change the complete bottom safe area to black and retain the blue “使用此模板” action.
```
### 修图结果
```text
Keep “照片预览”, “1 / 9” and the three tabs “原图 / 精修后 / 氛围感”. Use a charcoal segmented control with “精修后” selected in blue. Keep “AI已修”, the filename metadata, the photo and the result information. Change the result card and secondary actions “删除 / 刷新” to dark surfaces; keep “AI修图” blue.
```
### 处理中状态
```text
Based on the approved dark result screen, change only the badge to spinner + “修图中”. Keep the previous photo visible. Change the card to “正在生成新版本” and “当前仍展示上一版结果,完成后自动更新”, with an indeterminate shimmer line and no percentage. Disable only “AI修图”.
```
### 失败状态
```text
Based on the approved dark result screen, change only the badge to “生成失败”. Keep the previous photo visible. Use a charcoal recovery card with restrained red border, “新版本生成失败”, “上一版结果已保留,可重新修图” and “重新修图”. Keep the main “AI修图” button blue. Do not use a white or pale-red card.
```
## QA 记录
- 第 1、2 张与 V1 像素级复用,没有重新生成。
- 第 3–6 张均为 853 × 1844,暗色背景、表面、文字和操作层级一致。
- 成功、处理中、失败三态保留同一结果标签、照片和底部操作布局。
- 处理中无虚构百分比;失败状态有明确恢复动作;旧结果始终可见。
- 本目录只新增设计稿与说明,未修改 UIKit、业务逻辑或 Asset Catalog。
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+6 -6
View File
@@ -436,7 +436,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1010501; CURRENT_PROJECT_VERSION = 1020101;
DEVELOPMENT_TEAM = 56GVN5RNVN; DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
@@ -460,7 +460,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.5; MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@@ -502,7 +502,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1010501; CURRENT_PROJECT_VERSION = 1020101;
DEVELOPMENT_TEAM = 56GVN5RNVN; DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
@@ -526,7 +526,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.5; MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@@ -741,7 +741,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1010501; CURRENT_PROJECT_VERSION = 1020101;
DEVELOPMENT_TEAM = 56GVN5RNVN; DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -761,7 +761,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.5; MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "ai_retouch_template_placeholder.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -0,0 +1,8 @@
{
"images" : [
{ "filename" : "travel_album_cover_photo_icon.png", "idiom" : "universal", "scale" : "1x" },
{ "filename" : "travel_album_cover_photo_icon@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "travel_album_cover_photo_icon@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 642 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,8 @@
{
"images" : [
{ "filename" : "travel_album_header_background@1x.png", "idiom" : "universal", "scale" : "1x" },
{ "filename" : "travel_album_header_background@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "travel_album_header_background@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

@@ -0,0 +1,8 @@
{
"images" : [
{ "filename" : "travel_album_sort_icon.png", "idiom" : "universal", "scale" : "1x" },
{ "filename" : "travel_album_sort_icon@2x.png", "idiom" : "universal", "scale" : "2x" },
{ "filename" : "travel_album_sort_icon@3x.png", "idiom" : "universal", "scale" : "3x" }
],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 B

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "travel_album_before_after.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M12 4H20V20H12Z" fill="#000000" fill-opacity="0.32"/>
<rect x="4" y="4" width="16" height="16" stroke="#000000" stroke-width="2" stroke-linejoin="round"/>
<path d="M12 4V9L15 12L12 15V20" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 389 B

+57 -1
View File
@@ -5,25 +5,32 @@ enum PushDestination: Sendable, Equatable {
case paymentRecord case paymentRecord
case paymentDetails case paymentDetails
case messageCenter case messageCenter
case aiRetouchTaskList
case aiRetouchTaskDetail(batchId: Int)
} }
/// 极光/APNs payload 解析结果,仅提取业务消息类型用于点击路由。 /// 极光/APNs payload 解析结果,仅提取业务消息类型用于点击路由。
struct PushPayload: Sendable, Equatable { struct PushPayload: Sendable, Equatable {
private let type: String private let type: String
private let aiRetouchBatchId: Int?
/// 从系统通知 userInfo 提取顶层或推送包装层中的业务消息类型。 /// 从系统通知 userInfo 提取顶层或推送包装层中的业务消息类型。
nonisolated init(userInfo: [AnyHashable: Any]) { nonisolated init(userInfo: [AnyHashable: Any]) {
type = Self.extractType(from: userInfo) type = Self.extractType(from: userInfo)
aiRetouchBatchId = Self.extractBatchId(from: userInfo)
} }
/// 从可发送的字符串字典创建 payload,仅保留业务消息类型。 /// 从可发送的字符串字典创建 payload,仅保留业务消息类型。
nonisolated init(values: [String: String]) { nonisolated init(values: [String: String]) {
type = Self.normalizedType(values["type"]) type = Self.normalizedType(values["type"])
aiRetouchBatchId = Int(values["ai_retouch_batch_id"] ?? "").flatMap { $0 > 0 ? $0 : nil }
} }
/// 可发送的规范化字段快照。 /// 可发送的规范化字段快照。
nonisolated var normalizedValues: [String: String] { nonisolated var normalizedValues: [String: String] {
type.isEmpty ? [:] : ["type": type] var values = type.isEmpty ? [:] : ["type": type]
if let aiRetouchBatchId { values["ai_retouch_batch_id"] = String(aiRetouchBatchId) }
return values
} }
/// 仅根据后端业务消息类型解析目标页面。 /// 仅根据后端业务消息类型解析目标页面。
@@ -33,6 +40,11 @@ struct PushPayload: Sendable, Equatable {
return .paymentRecord return .paymentRecord
case "6": case "6":
return .paymentDetails return .paymentDetails
case "14":
if let aiRetouchBatchId, aiRetouchBatchId > 0 {
return .aiRetouchTaskDetail(batchId: aiRetouchBatchId)
}
return .aiRetouchTaskList
default: default:
return .messageCenter return .messageCenter
} }
@@ -80,6 +92,50 @@ struct PushPayload: Sendable, Equatable {
} }
} }
private nonisolated static func extractBatchId(
from dictionary: [AnyHashable: Any],
depth: Int = 0
) -> Int? {
guard depth <= 5 else { return nil }
if let value = normalizedPositiveInt(dictionary["ai_retouch_batch_id"]) { return value }
if let data = dictionary["data"] {
if let nested = data as? [AnyHashable: Any],
let value = extractBatchId(from: nested, depth: depth + 1) { return value }
if let text = data as? String,
let nested = jsonDictionary(text),
let value = extractBatchId(from: nested, depth: depth + 1) { return value }
}
for key in nestedTypeContainerKeys {
guard let value = dictionary[key] else { continue }
if let nested = value as? [AnyHashable: Any],
let batchId = extractBatchId(from: nested, depth: depth + 1) { return batchId }
if let text = value as? String,
let nested = jsonDictionary(text),
let batchId = extractBatchId(from: nested, depth: depth + 1) { return batchId }
}
return nil
}
private nonisolated static func normalizedPositiveInt(_ value: Any?) -> Int? {
let parsed: Int?
switch value {
case let number as NSNumber: parsed = number.intValue
case let string as String: parsed = Int(string.trimmingCharacters(in: .whitespacesAndNewlines))
default: parsed = nil
}
return parsed.flatMap { $0 > 0 ? $0 : nil }
}
private nonisolated static func jsonDictionary(_ text: String) -> [AnyHashable: Any]? {
guard let data = text.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data),
let dictionary = object as? [String: Any]
else { return nil }
return Dictionary(uniqueKeysWithValues: dictionary.map { (AnyHashable($0.key), $0.value) })
}
private nonisolated static let nestedTypeContainerKeys = [ private nonisolated static let nestedTypeContainerKeys = [
"extras", "extra", "JMessageExtra", "n_extras", "extras", "extra", "JMessageExtra", "n_extras",
] ]
@@ -70,10 +70,10 @@ enum AuthSessionHelper {
AppStore.shared.session.userId = String(user.businessUserId) AppStore.shared.session.userId = String(user.businessUserId)
AppStore.shared.session.accountType = .scenicUser AppStore.shared.session.accountType = .scenicUser
AppStore.shared.session.accountDisplayName = firstNonEmpty( AppStore.shared.session.accountDisplayName = firstNonEmpty(
user.scenicName, user.nickname, user.realName, user.username user.scenicName, user.nickname, user.realName
) )
AppStore.shared.session.userName = firstNonEmpty(user.nickname, user.username, user.realName) AppStore.shared.session.userName = firstNonEmpty(user.nickname, user.realName)
AppStore.shared.session.realName = firstNonEmpty(user.realName, user.nickname, user.username) AppStore.shared.session.realName = user.realName
AppStore.shared.session.phone = user.phone AppStore.shared.session.phone = user.phone
AppStore.shared.session.currentScenicId = user.scenicId AppStore.shared.session.currentScenicId = user.scenicId
AppStore.shared.session.currentScenicName = user.scenicName AppStore.shared.session.currentScenicName = user.scenicName
@@ -90,10 +90,10 @@ enum AuthSessionHelper {
AppStore.shared.session.userId = String(user.businessUserId) AppStore.shared.session.userId = String(user.businessUserId)
AppStore.shared.session.accountType = .storeUser AppStore.shared.session.accountType = .storeUser
AppStore.shared.session.accountDisplayName = firstNonEmpty( AppStore.shared.session.accountDisplayName = firstNonEmpty(
user.storeName, user.scenicName, user.realName, user.userName, user.username user.storeName, user.scenicName, user.realName
) )
AppStore.shared.session.userName = firstNonEmpty(user.userName, user.username, user.realName) AppStore.shared.session.userName = user.realName
AppStore.shared.session.realName = firstNonEmpty(user.realName, user.userName, user.username) AppStore.shared.session.realName = user.realName
AppStore.shared.session.phone = user.phone AppStore.shared.session.phone = user.phone
AppStore.shared.session.avatar = user.avatar AppStore.shared.session.avatar = user.avatar
AppStore.shared.session.currentScenicId = user.scenicId AppStore.shared.session.currentScenicId = user.scenicId
@@ -196,7 +196,6 @@ struct V9ScenicUser: Decodable, Equatable {
let userId: Int let userId: Int
let scenicUserId: Int let scenicUserId: Int
let ssUserId: Int let ssUserId: Int
let username: String
let realName: String let realName: String
let nickname: String let nickname: String
let phone: String let phone: String
@@ -212,7 +211,7 @@ struct V9ScenicUser: Decodable, Equatable {
} }
var displayName: String { var displayName: String {
scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? username scenicName.nonEmpty ?? nickname.nonEmpty ?? realName
} }
func toAccountSwitchAccount() -> AccountSwitchAccount { func toAccountSwitchAccount() -> AccountSwitchAccount {
@@ -238,8 +237,6 @@ struct V9ScenicUser: Decodable, Equatable {
case userId = "user_id" case userId = "user_id"
case scenicUserId = "scenic_user_id" case scenicUserId = "scenic_user_id"
case ssUserId = "ss_user_id" case ssUserId = "ss_user_id"
case username = "user_name"
case legacyUsername = "username"
case realName = "real_name" case realName = "real_name"
case nickname case nickname
case phone case phone
@@ -258,9 +255,6 @@ struct V9ScenicUser: Decodable, Equatable {
userId = try container.decodeLossyInt(forKey: .userId) ?? 0 userId = try container.decodeLossyInt(forKey: .userId) ?? 0
scenicUserId = try container.decodeLossyInt(forKey: .scenicUserId) ?? 0 scenicUserId = try container.decodeLossyInt(forKey: .scenicUserId) ?? 0
ssUserId = try container.decodeLossyInt(forKey: .ssUserId) ?? 0 ssUserId = try container.decodeLossyInt(forKey: .ssUserId) ?? 0
let preferredUsername = try container.decodeLossyString(forKey: .username)
let legacyUsername = try container.decodeLossyString(forKey: .legacyUsername)
username = preferredUsername.nonEmpty ?? legacyUsername
realName = try container.decodeLossyString(forKey: .realName) realName = try container.decodeLossyString(forKey: .realName)
nickname = try container.decodeLossyString(forKey: .nickname) nickname = try container.decodeLossyString(forKey: .nickname)
phone = try container.decodeLossyString(forKey: .phone) phone = try container.decodeLossyString(forKey: .phone)
@@ -281,8 +275,6 @@ struct V9StoreUser: Decodable, Equatable {
let id: Int let id: Int
let userId: Int let userId: Int
let storeUserId: Int let storeUserId: Int
let username: String
let userName: String
let realName: String let realName: String
let phone: String let phone: String
let avatar: String let avatar: String
@@ -300,14 +292,14 @@ struct V9StoreUser: Decodable, Equatable {
} }
var displayName: String { var displayName: String {
storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? username storeName.nonEmpty ?? scenicName.nonEmpty ?? realName
} }
func toAccountSwitchAccount() -> AccountSwitchAccount { func toAccountSwitchAccount() -> AccountSwitchAccount {
AccountSwitchAccount( AccountSwitchAccount(
accountType: accountType.nonEmpty ?? Self.accountTypeValue, accountType: accountType.nonEmpty ?? Self.accountTypeValue,
businessUserId: businessUserId, businessUserId: businessUserId,
title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? "门店账号", title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? "门店账号",
subtitle: joinAccountSubtitle(scenicName, roleName), subtitle: joinAccountSubtitle(scenicName, roleName),
phone: phone, phone: phone,
realName: realName, realName: realName,
@@ -325,8 +317,6 @@ struct V9StoreUser: Decodable, Equatable {
case id case id
case userId = "user_id" case userId = "user_id"
case storeUserId = "store_user_id" case storeUserId = "store_user_id"
case username
case userName = "user_name"
case realName = "real_name" case realName = "real_name"
case phone case phone
case avatar case avatar
@@ -346,8 +336,6 @@ struct V9StoreUser: Decodable, Equatable {
id = try container.decodeLossyInt(forKey: .id) ?? 0 id = try container.decodeLossyInt(forKey: .id) ?? 0
userId = try container.decodeLossyInt(forKey: .userId) ?? 0 userId = try container.decodeLossyInt(forKey: .userId) ?? 0
storeUserId = try container.decodeLossyInt(forKey: .storeUserId) ?? 0 storeUserId = try container.decodeLossyInt(forKey: .storeUserId) ?? 0
username = try container.decodeLossyString(forKey: .username)
userName = try container.decodeLossyString(forKey: .userName)
realName = try container.decodeLossyString(forKey: .realName) realName = try container.decodeLossyString(forKey: .realName)
phone = try container.decodeLossyString(forKey: .phone) phone = try container.decodeLossyString(forKey: .phone)
avatar = try container.decodeLossyString(forKey: .avatar) avatar = try container.decodeLossyString(forKey: .avatar)
@@ -30,6 +30,37 @@ enum MessageJSONValue: Decodable, Hashable, Sendable {
self = .object(try container.decode([String: MessageJSONValue].self)) self = .object(try container.decode([String: MessageJSONValue].self))
} }
} }
/// 将消息字段转换为合法的正整数标识;兼容后端返回数字或数字字符串。
var positiveInt: Int? {
let value: Int?
switch self {
case let .number(number):
guard number.isFinite, number.rounded() == number else { return nil }
value = Int(exactly: number)
case let .string(string):
value = Int(string.trimmingCharacters(in: .whitespacesAndNewlines))
default:
value = nil
}
guard let value, value > 0 else { return nil }
return value
}
/// 返回对象类型的原始键值。
var objectValue: [String: MessageJSONValue]? {
switch self {
case let .object(value):
return value
case let .string(value):
guard let data = value.data(using: .utf8),
let decoded = try? JSONDecoder().decode(MessageJSONValue.self, from: data)
else { return nil }
return decoded.objectValue
default:
return nil
}
}
} }
/// 消息未读数量响应,用于同步首页红点和桌面图标角标。 /// 消息未读数量响应,用于同步首页红点和桌面图标角标。
@@ -203,6 +234,20 @@ struct MessageItem: Decodable, Hashable, Sendable {
return MessageDateFormatter.formatDateTime(createdAt) ?? createdAt return MessageDateFormatter.formatDateTime(createdAt) ?? createdAt
} }
/// 是否为 AI 修图任务通知;人工修图的 `type = 10` 不属于该类型。
var isAIRetouchTaskNotification: Bool {
type == 14
}
/// AI 修图批次 ID,兼容 `extra_data` 直接字段及 `extra_data.data` 包装结构。
var aiRetouchBatchId: Int? {
guard isAIRetouchTaskNotification, let extraData else { return nil }
if let batchId = extraData["ai_retouch_batch_id"]?.positiveInt {
return batchId
}
return extraData["data"]?.objectValue?["ai_retouch_batch_id"]?.positiveInt
}
/// 返回已读状态的消息副本。 /// 返回已读状态的消息副本。
func markedRead() -> MessageItem { func markedRead() -> MessageItem {
MessageItem( MessageItem(
@@ -163,6 +163,16 @@ final class MessageDetailViewModel {
self.message = message self.message = message
} }
/// AI 修图任务通知在详情页展示任务入口。
var showsAIRetouchTaskAction: Bool {
message.isAIRetouchTaskNotification
}
/// 当前消息携带的 AI 修图批次 ID;缺失时由页面降级进入任务列表。
var aiRetouchBatchId: Int? {
message.aiRetouchBatchId
}
/// 删除当前消息。 /// 删除当前消息。
func delete(api: any MessageCenterServing) async { func delete(api: any MessageCenterServing) async {
guard message.id > 0 else { guard message.id > 0 else {
@@ -69,9 +69,9 @@ enum PayPageBrandingPolicy {
/// 收款页当前账号信息的展示文本格式化规则。 /// 收款页当前账号信息的展示文本格式化规则。
enum PaymentAccountDisplayFormatter { enum PaymentAccountDisplayFormatter {
/// 解析当前登录摄影师名称,昵称为空时使用实名,仍为空时显示占位符。 /// 解析当前登录摄影师的真实姓名,空值显示占位符。
static func photographerName(userName: String, realName: String) -> String { static func photographerName(realName: String) -> String {
userName.trimmedNonEmpty ?? realName.trimmedNonEmpty ?? "-" realName.trimmedNonEmpty ?? "-"
} }
/// 去除空值与重复项后,合并当前账号的全部店铺名称。 /// 去除空值与重复项后,合并当前账号的全部店铺名称。
@@ -28,6 +28,7 @@ final class PaymentCollectionDetailsViewModel {
private var staticPayURL = "" private var staticPayURL = ""
private var dynamicPayURL = "" private var dynamicPayURL = ""
private var hasCompletedPayCodeRequest = false
private let appStore: AppStore private let appStore: AppStore
private let payPageConfigCache: PayPageConfigCaching private let payPageConfigCache: PayPageConfigCaching
@@ -50,6 +51,11 @@ final class PaymentCollectionDetailsViewModel {
qrImage != nil && !staticPayURL.isEmpty qrImage != nil && !staticPayURL.isEmpty
} }
/// 是否应在二维码区域展示加载状态,首次请求开始前也视为加载中,避免短暂闪现错误占位。
var isPayCodeLoading: Bool {
loading || !hasCompletedPayCodeRequest
}
var displayScenicName: String { var displayScenicName: String {
scenicName.isEmpty ? "暂无景区" : scenicName scenicName.isEmpty ? "暂无景区" : scenicName
} }
@@ -60,7 +66,6 @@ final class PaymentCollectionDetailsViewModel {
var displayPhotographerName: String { var displayPhotographerName: String {
PaymentAccountDisplayFormatter.photographerName( PaymentAccountDisplayFormatter.photographerName(
userName: appStore.session.userName,
realName: appStore.session.realName realName: appStore.session.realName
) )
} }
@@ -85,11 +90,11 @@ final class PaymentCollectionDetailsViewModel {
func loadPayCode(api: PaymentAPI) async { func loadPayCode(api: PaymentAPI) async {
guard !loading else { return } guard !loading else { return }
refreshLocalState()
loading = true loading = true
notifyStateChange() refreshLocalState()
defer { defer {
loading = false loading = false
hasCompletedPayCodeRequest = true
notifyStateChange() notifyStateChange()
} }
@@ -17,11 +17,11 @@ final class ProfileViewModel {
var onStateChange: (() -> Void)? var onStateChange: (() -> Void)?
var displayNickname: String { var displayNickname: String {
nonEmpty(userInfo?.nickname) ?? "未设置昵称" nonEmpty(userInfo?.nickname) ?? AppStore.shared.session.userName.nonEmpty ?? "未设置昵称"
} }
var displayRealName: String { var displayRealName: String {
nonEmpty(userInfo?.realName) ?? "--" nonEmpty(userInfo?.realName) ?? AppStore.shared.session.realName.nonEmpty ?? "--"
} }
var displayPhone: String { var displayPhone: String {
@@ -37,6 +37,17 @@ final class ProfileViewModel {
return uid.isEmpty ? "--" : uid return uid.isEmpty ? "--" : uid
} }
/// 本地是否已有可供冷启动首屏展示的基本资料。
var hasCachedBasicInfo: Bool {
[
AppStore.shared.session.userName,
AppStore.shared.session.realName,
AppStore.shared.session.phone,
AppStore.shared.session.avatar,
AppStore.shared.session.userId,
].contains { nonEmpty($0) != nil }
}
var accountDisplayName: String { var accountDisplayName: String {
let name = AppStore.shared.session.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines) let name = AppStore.shared.session.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? "--" : name return name.isEmpty ? "--" : name
@@ -112,6 +123,7 @@ final class ProfileViewModel {
if !info.roleName.isEmpty { if !info.roleName.isEmpty {
AppStore.shared.session.roleName = info.roleName AppStore.shared.session.roleName = info.roleName
} }
notifyStateChange()
if showPhotographerFields { if showPhotographerFields {
async let realName = api.realNameInfo() async let realName = api.realNameInfo()
@@ -0,0 +1,814 @@
# AI 修图任务中心需求与接口设计
> 文档状态:待产品、后端、Android、iOS 联审
> 更新日期:2026-08-14
> 适用范围:随心瞰商家版 AI 修图任务,不包含人工修图任务
## 1. 背景与目标
AI 修图属于异步长耗时任务。当前用户提交后只能等待或主动返回相册刷新,无法明确知道任务是否仍在排队、预计何时完成、哪些照片成功或失败。
本需求增加:
1. 当前账号全部相册的 AI 修图任务列表。
2. 单次 AI 修图任务详情。
3. AI 修图终态消息推送。
4. 从推送、任务列表和提交成功提示进入对应任务的完整导航链路。
本期解决“看得到进度、完成会通知、结果可直达”的问题,不增加任务取消、批量重试、历史版本管理或后台供应商诊断能力。
## 2. 核心产品决策
### 2.1 推送跳转结论
AI 修图推送优先跳转到对应任务详情页。
原因:
- 用户点击完成通知时,核心意图是确认这一次任务的结果,而不是重新查找任务。
- 详情页可以直接表达成功、部分成功和失败,减少一次列表定位操作。
- `ai_retouch_batch_id` 是稳定任务标识,可以支撑前台、后台和冷启动直达。
以下情况降级进入任务列表:
- 推送缺少 `ai_retouch_batch_id`。
- ID 类型异常、为 0 或负数。
- 详情接口返回任务不存在、已失效或当前账号无权访问。
- 未来客户端收到无法识别的 AI 修图终态数据。
### 2.2 推送类型
| `type` | 业务含义 | 本需求处理 |
|---:|---|---|
| `10` | 人工修图完成通知 | 保持原业务语义和原点击行为,不作复用 |
| `14` | AI 修图任务通知 | 新增,按 `ai_retouch_batch_id` 进入 AI 修图任务详情 |
后端需在 `PushMsg` 类型定义及类型名称映射中增加:
```text
14 => AI修图任务通知
```
### 2.3 任务唯一标识
列表、详情、推送和提交响应统一使用已有的 `ai_retouch_batch_id`,类型固定为正整数。不得再引入另一套 `job_id`,避免与素材接口和重新修图接口中的批次标识无法对应。
### 2.4 列表范围
任务列表展示当前登录账号有权限查看的全部相册任务,不要求用户先进入某个相册。任务卡必须显示所属相册信息。
## 3. 用户流程与入口
### 3.1 主流程
```text
相册管理选择照片
→ 提交 AI 修图
→ 后端返回 ai_retouch_batch_id
→ 客户端提示“AI修图任务已提交”并提供“查看任务”
→ 用户可离开页面
→ 任务进入终态后收到 type = 14 推送
→ 点击推送进入任务详情
→ 查看成功结果或进入相册处理失败项
```
### 3.2 页面入口
- 相册管理页导航栏右侧增加“修图任务”,进入当前账号的全局任务列表;新增相册页不展示该入口。
- AI 修图提交成功提示提供“查看任务”,直接进入本次任务详情。
- 任务列表点击卡片进入对应任务详情。
- `type = 14` 推送携带有效任务 ID 时直达详情,否则进入任务列表。
## 4. 任务状态定义
后端状态值必须稳定,客户端根据枚举映射中文,不依赖后端返回的中文状态名。
| 状态 | 是否终态 | 列表分组 | 中文展示 | 说明 |
|---|---|---|---|---|
| `queued` | 否 | 进行中 | 排队中 | 已受理,尚无子任务开始 |
| `processing` | 否 | 进行中 | 修图中 | 至少一个子任务开始,尚未全部结束 |
| `succeeded` | 是 | 已完成 | 已完成 | 所有子任务成功 |
| `partially_succeeded` | 是 | 已完成 | 部分完成 | 子任务全部结束,既有成功也有失败或取消 |
| `failed` | 是 | 失败 | 处理失败 | 子任务全部结束,没有成功结果 |
| `canceled` | 是 | 失败 | 已取消 | 任务因素材删除或后台操作被取消 |
进度必须满足:
```text
total = queued + processing + succeeded + failed + canceled
completed = succeeded + failed + canceled
```
客户端进度百分比只能通过 `completed / total` 计算;`total <= 0` 时隐藏进度百分比,不显示虚构进度。
## 5. 任务列表页
### 5.1 设计稿
![AI 修图任务列表设计稿](../../../docs/design/ai-retouch-task-center/01-task-list.png)
### 5.2 页面结构
1. 导航栏
- 返回按钮。
- 标题“AI修图任务”。
2. 状态筛选
- 全部。
- 进行中。
- 已完成。
- 失败。
3. 任务卡列表
- 所属相册名称。
- 用户脱敏手机号。
- 提交时间。
- 任务编号,例如“任务 #9521”。
- 1 至 3 张原图缩略图;多余图片通过数量表达,不继续横向堆叠。
- 输出摘要,例如“精修 4 张 · 氛围感 4 张 · 封面 1 张”。
- 状态图标与文字标签。
- 进度或终态结果摘要。
- 详情指示。
### 5.3 状态展示
| 状态 | 卡片主信息 | 辅助信息 |
|---|---|---|
| `queued` | 排队中 | 有 ETA 时显示预计完成时间,否则显示“完成后将通过消息通知” |
| `processing` | 已完成 N / M | 显示进度条与预计完成时间 |
| `succeeded` | N 张结果已生成 | 显示完成时间 |
| `partially_succeeded` | N 张成功 · M 张失败 | 使用橙色警示,不按整单失败展示 |
| `failed` | 处理失败 | 引导点击查看详情 |
| `canceled` | 已取消 | 显示取消时间;不展示供应商或内部日志 |
### 5.4 失败信息展示
- 列表只承担任务级概览,不逐张展开失败原因。
- `partially_succeeded` 使用“N 张成功 · M 张失败”作为唯一结果摘要;`failed` 使用“失败”,不再增加独立的失败摘要行。
- 列表卡整体可点击并保留“查看详情”指示,具体失败原因统一进入详情查看,避免与结果数量重复。
- 后端返回的 `failure_summary` 继续解析并保留,供通知、无逐项错误数据等降级场景使用;列表首版不直接展示。
- 详情里的逐照片 `error.message` 仍是用户查看失败原因的主要信息来源,不展示原始错误码。
### 5.5 刷新与分页
- 首次进入、下拉刷新和 App 回到前台时拉取第一页。
- 页面可见且存在 `queued` 或 `processing` 任务时,每 15 秒刷新第一页。
- 页面离开、App 进入后台或页面内所有任务终态后停止定时刷新。
- 加载更多使用不透明游标;客户端不得解析或拼接游标。
- 服务端排序固定为 `created_at DESC, ai_retouch_batch_id DESC`,避免同一时间创建的任务分页不稳定。
- 刷新第一页时按 `ai_retouch_batch_id` 合并,不能产生重复卡片。
### 5.6 空态和异常
| 场景 | 展示 |
|---|---|
| 账号从未提交任务 | “暂无AI修图任务”与“提交修图后可在这里查看进度” |
| 当前筛选无数据 | “暂无该状态的任务” |
| 首屏加载失败 | 错误说明和“重新加载” |
| 加载更多失败 | 保留现有列表,底部提供重试 |
## 6. 任务详情页
### 6.1 设计稿
![AI 修图任务详情设计稿 V2](../../../docs/design/ai-retouch-task-center/02-task-detail-v2.png)
### 6.2 页面结构
1. 导航栏
- 返回按钮。
- 标题“任务详情”。
- 手动刷新按钮。
2. 状态摘要卡
- 任务状态图标和文字。
- 完成数量与进度条。
- 预计完成时间或实际完成时间。
- 非终态展示“完成后将通过消息通知你”。
3. 相册与任务信息
- 相册封面、相册名称、脱敏手机号。
- 任务编号、提交时间、开始时间、完成时间或处理耗时。
4. 任务内容
- 精修、氛围感、封面的目标数量。
- 额度预占、实际消耗和释放数量。
5. 处理明细
- 按原图组织精修和氛围感子任务。
- 封面作为独立明细。
- 每项展示照片缩略图、文件名、模板名称和状态。
- 某个输出失败时,在该照片、该输出项内部紧邻状态展示后端失败原因。
6. 页面操作
- 成功结果:“查看结果”,进入照片预览对应 Tab。
- 失败结果:仅展示后端返回的用户可理解失败原因,不在失败原因后追加操作按钮。
- 页面底部始终可提供“查看相册”。
### 6.3 失败原因展示
失败原因必须和失败照片、失败输出类型绑定展示,不使用全局弹窗、Toast 或脱离上下文的页面顶部提示代替。
| 场景 | 展示规则 |
|---|---|
| 单个精修/氛围感失败 | 在对应输出项状态“生成失败”下方显示“失败原因:{error.message}” |
| 同一照片两种输出均失败 | 精修、氛围感分别显示各自原因,不合并为一个模糊原因 |
| 封面失败 | 在封面独立明细下方显示原因 |
| 任务仍在处理中但已有失败项 | 立即展示已确定的照片失败原因,不等整批任务终态 |
| 后端原因为空 | 展示客户端兜底“处理失败,请稍后重试” |
| 原因超过两行 | 默认显示两行并提供“查看完整原因”;辅助功能朗读完整文本 |
视觉规则:
- 使用红色错误图标、红色“生成失败”和淡红色原因容器;颜色不是唯一状态提示。
- `error.message` 使用正文级字号和足够对比度,不使用脚注小字弱化重要信息。
- 不展示 `error.code`、供应商名称、堆栈、请求 ID 或服务器路径。
- 无论 `retryable` 取值如何,失败原因区域均只展示原因;用户仍可通过详情页底部“查看相册”进入相册管理页。
- 用户返回页面或手动刷新后,失败原因随接口最新值更新。
### 6.4 刷新规则
- 详情首次出现时立即请求最新数据。
- 非终态且页面可见时每 8 秒刷新。
- 进入终态、页面离开或 App 进入后台时停止刷新。
- 手动刷新与定时刷新不能并发发起重复请求。
- 推送不作为唯一状态来源;用户关闭通知权限后仍可通过页面刷新获得终态。
### 6.5 查看结果规则
- `output_type = refined`:打开来源原图的照片预览,默认选中“精修后”。
- `output_type = atmosphere`:打开来源原图的照片预览,默认选中“氛围感”。
- `output_type = cover`:打开所属相册,并定位到生成的封面素材;无法定位时进入相册第一页。
- 结果资源已删除或不可用时,隐藏“查看结果”并展示“结果已失效”。
## 7. 视觉与可访问性规范
### 7.1 视觉 Token
| 用途 | 建议值 |
|---|---|
| 页面背景 | `#F5F7FB` |
| 卡片背景 | `#FFFFFF` |
| 品牌主色 | `#1677FF` |
| 蓝色高光 | `#3A91FF` |
| 主文字 | `#111827` |
| 次文字 | `#64748B` |
| 成功 | `#22A06B` |
| 警示/部分完成 | `#F59E0B` |
| 失败 | `#EF4444` |
| 卡片圆角 | 12–16 pt |
### 7.2 交互要求
- 状态必须同时使用图标、文字和颜色,不得只用颜色区分。
- 正文文字与背景对比度至少 4.5:1。
- 卡片、筛选项和操作按钮的最小触控区域为 44 × 44 pt。
- 动态字体至少覆盖系统默认至辅助功能常用档位;文字放大时允许卡片增高。
- 进度动画尊重“减弱动态效果”;关闭动画后保留静态进度与文字。
- 网络图片使用缩略图地址,并提供占位图和失败占位状态。
## 8. 后端接口总览
基础路径:
```text
/api/yf-handset-app/photog/travel-album
```
| 方法 | 路径 | 类型 | 用途 |
|---|---|---|---|
| `POST` | `/ai-retouch` | 扩展现有响应 | 首次/批量提交后返回任务标识 |
| `POST` | `/ai-reretouch` | 扩展现有响应 | 重新修图提交后返回任务标识 |
| `GET` | `/ai-retouch-job-list` | 新增 | 获取当前账号全部 AI 修图任务 |
| `GET` | `/ai-retouch-job-info` | 实现并统一 | 获取指定任务详情 |
时间字段统一使用 ISO 8601 UTC,例如:
```text
2026-08-14T06:26:12.123Z
```
所有 ID 的 JSON 类型必须稳定;本需求中的 `ai_retouch_batch_id`、`user_equity_travel_id` 和素材 ID 均使用整数。
## 9. 扩展 AI 修图提交响应
现有 `/ai-retouch` 和 `/ai-reretouch` 请求体保持不变,成功后统一返回可供跳转和立即展示的任务摘要。
HTTP 状态码:`202 Accepted`
```json
{
"code": 100000,
"msg": "AI修图任务已提交",
"data": {
"ai_retouch_batch_id": 9521,
"user_equity_travel_id": 88,
"status": "queued",
"progress": {
"total": 9,
"queued": 9,
"processing": 0,
"succeeded": 0,
"failed": 0,
"canceled": 0
},
"created_at": "2026-08-14T06:26:12.123Z"
}
}
```
必有字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| `ai_retouch_batch_id` | int | 大于 0 的任务唯一标识 |
| `user_equity_travel_id` | int | 所属相册 ID |
| `status` | string | 提交成功时通常为 `queued` |
| `progress` | object | 提交时的真实子任务数量 |
| `created_at` | string | 服务端受理时间 |
客户端超时后使用原幂等键重试时,后端必须返回同一个 `ai_retouch_batch_id`,不得创建重复任务。
## 10. 获取 AI 修图任务列表
### 10.1 请求
```http
GET /api/yf-handset-app/photog/travel-album/ai-retouch-job-list?status_group=in_progress&limit=20&cursor=<opaque_cursor>
Authorization: Bearer <token>
```
| 参数 | 必填 | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
| `status_group` | 否 | string | `all` | `all`、`in_progress`、`completed`、`failed` |
| `limit` | 否 | int | `20` | 最小 1,最大 50 |
| `cursor` | 否 | string | — | 服务端返回的不透明游标,第一页不传 |
服务端分组映射:
| `status_group` | 包含状态 |
|---|---|
| `all` | 全部六种状态 |
| `in_progress` | `queued`、`processing` |
| `completed` | `succeeded`、`partially_succeeded` |
| `failed` | `failed`、`canceled` |
### 10.2 响应
```json
{
"code": 100000,
"msg": "success",
"data": {
"items": [
{
"ai_retouch_batch_id": 9521,
"user_equity_travel_id": 88,
"scope": "batch",
"status": "processing",
"album": {
"id": 88,
"name": "旅拍相册",
"user_phone": "13812348000",
"cover_url": "https://cdn.example.com/albums/88/cover.jpg"
},
"source_count": 4,
"outputs": [
{
"type": "refined",
"count": 4
},
{
"type": "atmosphere",
"count": 4
},
{
"type": "cover",
"count": 1
}
],
"preview_images": [
{
"material_id": 2031,
"thumbnail_url": "https://cdn.example.com/albums/88/2031_thumb.jpg"
},
{
"material_id": 2032,
"thumbnail_url": "https://cdn.example.com/albums/88/2032_thumb.jpg"
},
{
"material_id": 2033,
"thumbnail_url": "https://cdn.example.com/albums/88/2033_thumb.jpg"
}
],
"progress": {
"total": 9,
"queued": 3,
"processing": 1,
"succeeded": 5,
"failed": 0,
"canceled": 0
},
"estimated_finish_at": "2026-08-14T06:32:00.000Z",
"failure_summary": null,
"created_at": "2026-08-14T06:26:12.123Z",
"started_at": "2026-08-14T06:26:18.000Z",
"finished_at": null
}
],
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0xNFQwNjoyNjoxMi4xMjNaIiwiaWQiOjk1MjF9",
"has_more": true
}
}
```
### 10.3 列表字段约定
| 字段 | 必有 | 说明 |
|---|---|---|
| `album` | 是 | 服务端按登录态校验后返回,客户端不再逐任务查询相册 |
| `album.user_phone` | 否 | 返回原始手机号时客户端脱敏;没有时返回空字符串,不返回多种类型 |
| `outputs` | 是 | 各输出类型的计划生成数量 |
| `preview_images` | 是 | 最多返回 3 项,允许为空数组 |
| `progress` | 是 | 六种子任务数量必须满足进度恒等式 |
| `estimated_finish_at` | 否 | 无法估算或已终态时返回 `null` |
| `failure_summary` | 否 | 任务级可展示摘要;`failed` 时必有,其他状态存在失败子任务时建议返回 |
| `next_cursor` | 是 | 无下一页时为 `null` |
| `has_more` | 是 | 与 `next_cursor` 语义一致 |
`estimated_finish_at` 是动态估算而非 SLA。估算变化时允许更新;客户端只展示最新值,不做倒计时承诺。
`failure_summary` 不代替逐照片失败原因。存在多个不同失败原因时,列表建议返回“N 张照片处理失败,点击查看原因”;只有单一且简短的原因时才直接返回该原因。建议限制在 60 个中文字符以内。
## 11. 获取 AI 修图任务详情
### 11.1 请求
```http
GET /api/yf-handset-app/photog/travel-album/ai-retouch-job-info?ai_retouch_batch_id=9521
Authorization: Bearer <token>
```
| 参数 | 必填 | 类型 | 说明 |
|---|---|---|---|
| `ai_retouch_batch_id` | 是 | int | 大于 0 的 AI 修图批次 ID |
接口不要求客户端再传 `user_equity_travel_id`。后端应从登录态和任务归属完成权限校验,并在响应中返回相册信息。
### 11.2 响应
```json
{
"code": 100000,
"msg": "success",
"data": {
"ai_retouch_batch_id": 9521,
"user_equity_travel_id": 88,
"scope": "batch",
"status": "processing",
"album": {
"id": 88,
"name": "旅拍相册",
"user_phone": "13812348000",
"cover_url": "https://cdn.example.com/albums/88/cover.jpg"
},
"source_count": 4,
"outputs": [
{
"type": "refined",
"count": 4
},
{
"type": "atmosphere",
"count": 4
},
{
"type": "cover",
"count": 1
}
],
"progress": {
"total": 9,
"queued": 2,
"processing": 1,
"succeeded": 5,
"failed": 1,
"canceled": 0
},
"quota_settlement": {
"status": "partially_settled",
"reserved_units": 8,
"consumed_units": 5,
"released_units": 1,
"cover_units": 0
},
"targets": [
{
"target_id": 30101,
"source_material": {
"id": 2031,
"file_name": "IMG_8291.JPG",
"thumbnail_url": "https://cdn.example.com/albums/88/2031_thumb.jpg"
},
"input_material_ids": [2031],
"output_type": "refined",
"template": {
"id": 11,
"name": "自然通透"
},
"status": "succeeded",
"result_asset": {
"id": 9201,
"material_id": 2031,
"url": "https://cdn.example.com/albums/88/2031_refined_v2.jpg",
"thumbnail_url": "https://cdn.example.com/albums/88/2031_refined_v2_thumb.jpg"
},
"error": null,
"created_at": "2026-08-14T06:26:12.123Z",
"started_at": "2026-08-14T06:26:18.000Z",
"finished_at": "2026-08-14T06:28:40.000Z"
},
{
"target_id": 30102,
"source_material": {
"id": 2032,
"file_name": "IMG_8292.JPG",
"thumbnail_url": "https://cdn.example.com/albums/88/2032_thumb.jpg"
},
"input_material_ids": [2032],
"output_type": "atmosphere",
"template": {
"id": 21,
"name": "暖阳氛围"
},
"status": "processing",
"result_asset": null,
"error": null,
"created_at": "2026-08-14T06:26:12.123Z",
"started_at": "2026-08-14T06:29:02.000Z",
"finished_at": null
},
{
"target_id": 30103,
"source_material": {
"id": 2033,
"file_name": "IMG_8293.JPG",
"thumbnail_url": "https://cdn.example.com/albums/88/2033_thumb.jpg"
},
"input_material_ids": [2033],
"output_type": "refined",
"template": {
"id": 11,
"name": "自然通透"
},
"status": "failed",
"result_asset": null,
"error": {
"code": "AI_PROVIDER_TIMEOUT",
"message": "AI服务处理超时,请重新修图",
"retryable": true
},
"created_at": "2026-08-14T06:26:12.123Z",
"started_at": "2026-08-14T06:27:10.000Z",
"finished_at": "2026-08-14T06:29:30.000Z"
},
{
"target_id": 30109,
"source_material": null,
"input_material_ids": [2031, 2032, 2033, 2034],
"output_type": "cover",
"template": {
"id": 31,
"name": "旅拍拼贴"
},
"status": "queued",
"result_asset": null,
"error": null,
"created_at": "2026-08-14T06:26:12.123Z",
"started_at": null,
"finished_at": null
}
],
"estimated_finish_at": "2026-08-14T06:32:00.000Z",
"created_at": "2026-08-14T06:26:12.123Z",
"started_at": "2026-08-14T06:26:18.000Z",
"finished_at": null,
"duration_seconds": null
}
}
```
### 11.3 失败明细
失败子任务的 `error` 结构:
```json
{
"code": "AI_PROVIDER_TIMEOUT",
"message": "AI服务处理超时,请重新修图",
"retryable": true
}
```
约定:
- `status = failed` 时 `error` 和非空 `error.message` 必须返回,不能只返回错误码。
- `message` 必须是经过后端转换、可直接展示给用户的中文文案,建议不超过 120 个中文字符。
- `code` 用于客户端判断与联调排查,不直接展示。
- 不得返回供应商密钥、内部请求、堆栈、服务器路径或原始异常。
- `retryable` 只表达业务上是否允许重新提交,本期详情页不直接发起批量重试。
- `queued`、`processing`、`succeeded` 时 `error` 固定返回 `null`,不要返回空对象。
- 同一来源照片的精修和氛围感必须分别返回自己的 `error`,客户端不通过素材级公共错误猜测具体失败输出。
### 11.4 额度结算
| 状态 | 说明 |
|---|---|
| `reserved` | 已预占,尚无子任务完成 |
| `partially_settled` | 部分预占已转为消耗或释放 |
| `settled` | 所有额度完成结算 |
必须满足:
```text
reserved_units >= consumed_units + released_units
```
终态时应满足:
```text
reserved_units = consumed_units + released_units
```
封面不消耗额度,`cover_units` 固定为 0。
## 12. 接口错误码
| HTTP | 业务码 | 场景 | 客户端行为 |
|---:|---|---|---|
| 400 | `INVALID_RETOUCH_STATUS_GROUP` | 列表筛选参数错误 | 回退“全部”并记录联调日志 |
| 400 | `INVALID_CURSOR` | 游标无效或过期 | 清空游标并重新加载第一页 |
| 400 | `INVALID_RETOUCH_BATCH_ID` | 任务 ID 非法 | 进入任务列表 |
| 401 | `UNAUTHORIZED` | 登录态失效 | 走现有重新登录流程,登录后继续待处理路由 |
| 404 | `AI_RETOUCH_JOB_NOT_FOUND` | 不存在、已失效或无权访问 | 提示“任务不存在或已失效”,进入任务列表 |
| 429 | `TOO_MANY_REQUESTS` | 刷新过于频繁 | 停止本轮轮询,按服务端建议时间重试 |
| 500 | `AI_RETOUCH_JOB_QUERY_FAILED` | 服务端异常 | 保留已有数据并提供重试 |
权限不足建议统一返回 404,避免泄露其他账号任务是否存在。
## 13. AI 修图推送协议
### 13.1 发送条件
- 只在任务第一次从非终态进入 `succeeded`、`partially_succeeded` 或 `failed` 时发送。
- `canceled` 默认不发送通知。
- 同一 `ai_retouch_batch_id` 只发送一次终态推送。
- 后端应通过事务字段或唯一记录保证幂等,例如 `terminal_push_sent_at`。
- 设备没有有效极光 Registration ID 时不影响任务结算;用户仍可在任务中心查看结果。
### 13.2 统一业务结构
```json
{
"title": "AI修图已完成",
"content": "「旅拍相册」的9张结果已生成,点击查看。",
"msg_id": 2014,
"type": 14,
"data": {
"ai_retouch_batch_id": 9521,
"user_equity_travel_id": 88,
"status": "succeeded"
}
}
```
字段约定:
| 字段 | 必有 | 类型 | 说明 |
|---|---|---|---|
| `type` | 是 | int | 固定为 `14` |
| `msg_id` | 是 | int | 后端业务消息 ID,不是极光 `_j_msgid` |
| `data` | 是 | object | 不得发送为 JSON 字符串 |
| `ai_retouch_batch_id` | 是 | int | 大于 0;点击详情的主键 |
| `user_equity_travel_id` | 是 | int | 用于降级列表和联调排查 |
| `status` | 是 | string | 仅允许三个会推送的终态值 |
### 13.3 通知文案
| 状态 | 标题 | 正文模板 |
|---|---|---|
| `succeeded` | AI修图已完成 | `「{相册名}」的{成功数}张结果已生成,点击查看。` |
| `partially_succeeded` | AI修图部分完成 | `{成功数}张成功,{失败数}张失败,点击查看详情。` |
| `failed` | AI修图未完成 | `本次任务处理失败,点击查看原因。` |
相册名称为空时,正文使用“本次AI修图任务”,不得出现空书名号。
### 13.4 iOS 示例
```json
{
"aps": {
"alert": {
"title": "AI修图已完成",
"body": "「旅拍相册」的9张结果已生成,点击查看。"
},
"sound": "default"
},
"msg_id": 2014,
"type": 14,
"data": {
"ai_retouch_batch_id": 9521,
"user_equity_travel_id": 88,
"status": "succeeded"
}
}
```
Android 的 `extras` 中放入同一业务对象;字段名、字段类型和业务含义必须与 iOS 一致。
## 14. 推送点击路由
### 14.1 路由规则
```text
收到通知点击
→ 解析业务 type
→ type != 14:继续走现有类型路由
→ type = 14:解析 data.ai_retouch_batch_id
→ 合法:打开 AI 修图任务详情
→ 缺失/非法:打开 AI 修图任务列表
→ 详情请求 404:提示后返回任务列表
```
### 14.2 生命周期
- 前台、后台点击和冷启动使用同一套 `type + data` 解析规则。
- 未登录时暂存 `ai_retouch_batch_id`,登录成功且主 Tab 建立后继续路由。
- 账号切换或退出登录时清除上一账号尚未执行的待处理路由。
- 同一系统通知的 request identifier 只处理一次。
- 客户端不得从标题或正文解析任务 ID。
### 14.3 向后兼容
- 旧客户端无法识别 `type = 14` 时按既有默认逻辑进入消息中心,不应崩溃。
- 新客户端收到 `type = 10` 时仍按人工修图处理,不进入 AI 修图任务中心。
- 对 `data` 中新增的未知字段,客户端应忽略。
### 14.4 站内消息详情入口
- `type = 14` 的站内消息详情页在消息正文下方展示品牌蓝主按钮“查看任务详情”。
- 消息 `extra_data.ai_retouch_batch_id` 合法时进入对应任务详情;兼容 `extra_data.data.ai_retouch_batch_id` 包装结构。
- 任务 ID 缺失、无法解析或小于等于 0 时,按钮仍保留,点击后降级进入 AI 修图任务列表。
- `type = 10` 人工修图及其他类型消息不展示该入口,原有消息详情行为保持不变。
- 底部“删除并返回”继续作为独立的破坏性操作,不承担业务跳转职责。
## 15. 数据、安全与一致性
- 列表和详情只能返回当前登录账号有权限查看的任务。
- 后端从登录态确定账号范围,不接受客户端传入用户 ID 扩大查询范围。
- 手机号仅用于业务识别,客户端统一脱敏展示。
- 缩略图和结果 URL 应使用受控 CDN 地址;如需签名,过期时间应覆盖合理浏览时长。
- 任务状态和所有子任务状态必须在同一份一致性快照中返回。
- 推送发送失败不得回滚已完成的 AI 任务或额度结算。
- 已删除且不再允许客户端展示的素材,不应继续通过任务详情暴露原图或结果 URL。
- 如果任务已整体不可见,详情统一返回 `AI_RETOUCH_JOB_NOT_FOUND`。
## 16. 验收标准
### 16.1 页面
- 能从相册管理页进入当前账号全部 AI 修图任务列表,新增相册页不展示该入口。
- 四种筛选与六种状态映射正确。
- 进行中任务显示真实完成数量;有 ETA 才显示预计完成时间。
- 详情可展示任务摘要、相册信息、额度和逐输出明细。
- 单照片、单输出失败时,失败原因展示在对应明细内部,不需要用户从任务级摘要猜测失败对象。
- 后端原因超过两行时可查看完整内容;原因缺失时有稳定兜底文案。
- 成功结果可进入对应照片结果,失败项可进入所属相册。
- 页面离开或任务终态后停止轮询。
### 16.2 接口
- 两个提交接口返回稳定且可跳转的 `ai_retouch_batch_id`。
- 游标分页在任务新增和状态更新期间不重复、不漏项。
- 进度数量满足恒等式,任务终态与子任务状态一致。
- ETA 缺失时返回 `null`,不返回空字符串或 0 时间。
- `status = failed` 的子任务必有非空 `error.message`,且详情能够按照片和输出类型准确展示。
- 失败信息不包含供应商、堆栈、服务器路径等内部实现。
### 16.3 推送
- `type = 14` 未被其他业务占用,后端类型名称映射完整。
- `type = 10` 人工修图和 `type = 14` AI 修图互不影响。
- 成功、部分成功、失败各只发送一次终态通知。
- 前台、后台、冷启动、未登录和重复点击均符合路由规则。
- 缺少任务 ID、非法 ID、任务失效时均安全降级到任务列表。
## 17. 本期不做
- 从任务中心取消 AI 修图任务。
- 一键批量重试失败子任务。
- 展示 AI 供应商、内部错误堆栈或链路日志。
- 展示同一照片的历史修图版本。
- 为 ETA 提供 SLA 倒计时承诺。
- 修改 `type = 10` 人工修图协议。
## 18. 设计稿生成说明
两张设计稿使用内置 ImageGen 生成,参考现有 AI 修图 V2 的浅色相册管理页面、旅行摄影素材和状态视觉语言。
- 任务列表:853 × 1844,展示进行中、已完成、部分完成三种代表性卡片。
- 任务详情 V2:852 × 1846,展示处理中的任务、6/9 进度、ETA、消息通知说明,以及单照片“生成失败 + 失败原因”的内联状态。
- 设计稿用于产品和研发对齐;实际 UIKit 实现应使用项目内颜色、字体、SnapKit 和 SF Symbols,不把设计稿作为页面背景切图。
@@ -0,0 +1,531 @@
# AI 修图接口文档
## 接口列表
| # | 方法 | 路径 | 用途 |
|---|---|---|---|
| 1 | `GET` | `/api/yf-handset-app/photog/travel-album/material-list` | 获取相册网格素材和预览初始数据 |
| 2 | `GET` | `/api/yf-handset-app/photog/travel-album/material-info` | 获取/刷新当前原图及关联图 |
| 3 | `GET` | `/api/yf-handset-app/photog/travel-album/ai-retouch-options` | 获取 AI 模板、选择规则和剩余额度 |
| 4 | `POST` | `/api/yf-handset-app/photog/travel-album/ai-retouch` | 提交首次修图或重新修图任务 |
| 5 | `GET` | `/api/yf-handset-app/photog/travel-album/ai-retouch-job-info` | 查询 AI 修图任务进度和结果 |
| 6 | `POST` | `/api/yf-handset-app/photog/travel-album/delete-material` | 单张/批量删除素材及关联图 |
## 接口详情
### 获取相册素材列表
```http
GET /api/yf-handset-app/photog/travel-album/material-list?user_equity_travel_id=88&limit=30&cursor=opaque_cursor&sort=created_at_desc
```
| 参数 | 必填 | 说明 |
|---|---|---|
| `user_equity_travel_id` | 是 | 相册 ID |
| `limit` | 否 | 默认 30,最大 100 |
| `cursor` | 否 | 服务端返回的不透明游标,首页不传 |
| `sort` | 否 | `created_at_desc` 或 `created_at_asc` |
| `material_type` | 否 | `original`、`cover`;不传表示全部 |
响应:
```json
{
"code": 100000,
"msg": "success",
"data": {
"items": [
{
"id": "2031",
"user_equity_travel_id": "88",
"material_type": "original",
"original_asset": {
"id": "asset_8001",
"url": "https://cdn.example.com/albums/88/IMG_1024.jpg",
"thumbnail_url": "https://cdn.example.com/albums/88/IMG_1024_thumb.jpg",
"file_name": "IMG_1024.jpg",
"mime_type": "image/jpeg",
"file_size": 4821931,
"width": 4032,
"height": 3024,
"version": 1,
"created_at": "2026-08-09T01:00:00.000Z"
},
"display_status": "uploaded",
"variant_slots": [],
"revision": 7,
"created_at": "2026-08-09T01:00:00.000Z",
"updated_at": "2026-08-11T07:30:18.000Z"
}
],
"next_cursor": "next_opaque_cursor",
"has_more": true
}
}
```
- 只返回网格顶层素材:原图和独立封面。
- 原图如存在关联图,`variant_slots` 包含预览页 Tab 初始数据。
- 建议使用游标分页,因为 AI 任务完成时可能实时插入封面素材。
### 获取/刷新单个原图项目
```http
GET /api/yf-handset-app/photog/travel-album/material-info?user_equity_travel_id=88&material_id=2031
```
查询参数 `user_equity_travel_id` 和 `material_id` 均必填。响应的 `data` 直接返完整 `material_project`,字段与列表 Item 完全一致。该接口供预览页刷新和提交后定向更新使用。
如 `material_id` 已删除,返回 `404 MATERIAL_NOT_FOUND`。
### 获取 AI 修图选项
```http
GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_travel_id=88&scope=batch&source_count=4
```
| `scope` | 使用场景 |
|---|---|
| `batch` | 网格多选 AI 修图 |
| `all_variants` | 预览页无 Tab,或当前是原图 Tab |
| `refined_only` | 当前是精修后 Tab |
| `atmosphere_only` | 当前是氛围感 Tab |
`user_equity_travel_id`、`scope` 和 `source_count` 均必填。`source_count` 用于计算封面模板是否显示和必选。
响应:
```json
{
"code": 100000,
"msg": "success",
"data": {
"scope": "batch",
"source_count": 4,
"groups": [
{
"type": "refined",
"title": "原图精修",
"required": true,
"selection_mode": "single",
"quota_cost": {
"mode": "per_source",
"units": 1
},
"templates": [
{
"id": "tpl_refined_12",
"name": "清透精修",
"preview_url": "https://cdn.example.com/templates/refined_12.jpg",
"before_url": "https://cdn.example.com/templates/refined_12_before.jpg",
"after_url": "https://cdn.example.com/templates/refined_12_after.jpg",
"enabled": true
}
]
},
{
"type": "atmosphere",
"title": "氛围感修图",
"required": false,
"selection_mode": "single",
"quota_cost": {
"mode": "per_source",
"units": 1
},
"templates": [
{
"id": "tpl_atmosphere_06",
"name": "暖阳",
"preview_url": "https://cdn.example.com/templates/atmosphere_06.jpg",
"before_url": "https://cdn.example.com/templates/atmosphere_06_before.jpg",
"after_url": "https://cdn.example.com/templates/atmosphere_06_after.jpg",
"enabled": true
}
]
},
{
"type": "cover",
"title": "封面风格模板",
"required": true,
"selection_mode": "single",
"quota_cost": {
"mode": "per_job",
"units": 0
},
"templates": [
{
"id": "tpl_cover_03",
"name": "旅行画册",
"preview_url": "https://cdn.example.com/templates/cover_03.jpg",
"before_url": "https://cdn.example.com/templates/cover_03_before.jpg",
"after_url": "https://cdn.example.com/templates/cover_03_after.jpg",
"enabled": true
}
]
}
],
"quota": {
"unit": "retouch_time",
"remaining": 12,
"reserved": 3,
"display_text": "剩余 12 次",
"updated_at": "2026-08-11T07:29:58.000Z"
},
"limits": {
"minimum_source_count": 1,
"maximum_source_count": 50,
"cover_minimum_source_count": 4
}
}
}
```
设计要点:
- 不再让 App 传 `scenic_id`,后端从相册归属景区获取可用模板。
- 由后端返回 `required` 和可见分组,避免多端各写一套“4 张显示封面”规则。
- `preview_url` 用于模板卡片缩略图;`before_url` 与 `after_url` 必须是同尺寸、同构图的配对图片,供客户端滑动对比。
- 客户端可默认选中必选分组的第一个可用模板,可选分组默认不选。
- 此接口用于 UI 配置;提交时后端仍必须根据真实素材 ID 重新校验。
- 同一响应返回当前用户的剩余可用额度和每类输出的额度单价,弹窗无需再发起第二个额度请求。
- `quota.remaining` 是扣除其他未完成任务已预占数量后、当前立即可用的额度;弹窗底部使用此值显示“剩余 N 次”。
- 额度文案位于模板选择弹窗的固定底部区域,不随中间模板列表滚动。
- `quota.reserved` 是当前用户其他未完成任务的预占额度,仅用于解释账户状态。
- 客户端预估本次消耗时,对已选输出求和:`per_source` 类型为 `source_count * units`,`per_job` 类型为 `units`。封面始终为 0。
- 本文档默认精修和氛围感每生成一张各消耗 1 次。如实际商业规则变更,后端只需调整 `quota_cost`,客户端不需改接口。
### 创建 AI 修图任务
```http
POST /api/yf-handset-app/photog/travel-album/ai-retouch
Idempotency-Key: 9A7820CE-78AB-45C6-9E0F-5DF268995379
Content-Type: application/json
```
#### 5.4.1 网格批量修图
```json
{
"user_equity_travel_id": "88",
"scope": "batch",
"source_material_ids": ["2031", "2032", "2033", "2034"],
"outputs": [
{
"type": "refined",
"template_id": "tpl_refined_12"
},
{
"type": "atmosphere",
"template_id": "tpl_atmosphere_06"
},
{
"type": "cover",
"template_id": "tpl_cover_03"
}
]
}
```
#### 5.4.2 预览页原图 Tab
```json
{
"user_equity_travel_id": "88",
"scope": "all_variants",
"source_material_ids": ["2031"],
"outputs": [
{
"type": "refined",
"template_id": "tpl_refined_15"
},
{
"type": "atmosphere",
"template_id": "tpl_atmosphere_09"
}
]
}
```
如用户未选氛围感模板,`outputs` 中不传 `atmosphere`。缺失表示“本次不处理”,不表示删除现有氛围感图。
#### 5.4.3 只重新精修
```json
{
"user_equity_travel_id": "88",
"scope": "refined_only",
"source_material_ids": ["2031"],
"outputs": [
{
"type": "refined",
"template_id": "tpl_refined_18"
}
]
}
```
#### 5.4.4 只重新生成氛围感
```json
{
"user_equity_travel_id": "88",
"scope": "atmosphere_only",
"source_material_ids": ["2031"],
"outputs": [
{
"type": "atmosphere",
"template_id": "tpl_atmosphere_11"
}
]
}
```
#### 5.4.5 提交校验矩阵
| `scope` | 原图数 | 精修 | 氛围感 | 封面 |
|---|---:|---|---|---|
| `batch` | 1 至 50 | 必选 | 可选 | 少于 4 张禁止;4 张及以上必选 |
| `all_variants` | 必须为 1 | 必选 | 可选 | 禁止 |
| `refined_only` | 必须为 1 | 必选 | 禁止 | 禁止 |
| `atmosphere_only` | 必须为 1 | 禁止 | 必选 | 禁止 |
另外:
- `source_material_ids` 必须去重,且全部为 `original` 类型。
- 模板必须启用、类型匹配,且适用于相册所属景区。
- 后端必须使用提交时的最新额度计费规则重新计算消耗,不信任客户端的预估数值。
- 封面输出不论首次生成、重试或任务结果如何,额度消耗始终为 0。
- 每个输出槽位均采用 `replace_on_success`:成功后替换旧版本,失败保留旧版本。
- 批量选中已有 AI 结果的原图时,允许重新生成,只覆盖本次 `outputs` 列出的类型。
- 同一原图、同一槽位已有 `queued/processing` 任务时,整次请求返回 `409 RETOUCH_TARGET_BUSY`,并列出冲突项。
#### 5.4.6 提交响应
HTTP 状态码:`202 Accepted`
```json
{
"code": 100000,
"msg": "AI 修图任务已提交",
"data": {
"job": {
"id": "job_01K2E4",
"user_equity_travel_id": "88",
"scope": "all_variants",
"status": "queued",
"source_material_ids": ["2031"],
"progress": {
"total": 2,
"queued": 2,
"processing": 0,
"succeeded": 0,
"failed": 0
},
"created_at": "2026-08-11T07:30:15.123Z",
"started_at": null,
"finished_at": null
},
"quota_reservation": {
"before_remaining": 12,
"reserved_units": 2,
"remaining_after_reservation": 10,
"cover_units": 0
},
"affected_projects": [
{
"id": "2031",
"user_equity_travel_id": "88",
"material_type": "original",
"original_asset": {
"id": "asset_8001",
"url": "https://cdn.example.com/albums/88/IMG_1024.jpg",
"thumbnail_url": "https://cdn.example.com/albums/88/IMG_1024_thumb.jpg",
"file_name": "IMG_1024.jpg",
"mime_type": "image/jpeg",
"file_size": 4821931,
"width": 4032,
"height": 3024,
"version": 1,
"created_at": "2026-08-09T01:00:00.000Z"
},
"display_status": "pending",
"variant_slots": [
{
"type": "refined",
"asset": null,
"latest_generation": {
"job_id": "job_01K2E4",
"status": "queued",
"is_replacement": false,
"template": {
"id": "tpl_refined_15",
"name": "自然精修"
},
"requested_at": "2026-08-11T07:30:15.123Z",
"started_at": null,
"finished_at": null,
"error": null
}
},
{
"type": "atmosphere",
"asset": null,
"latest_generation": {
"job_id": "job_01K2E4",
"status": "queued",
"is_replacement": false,
"template": {
"id": "tpl_atmosphere_09",
"name": "暖阳"
},
"requested_at": "2026-08-11T07:30:15.123Z",
"started_at": null,
"finished_at": null,
"error": null
}
}
],
"revision": 8,
"created_at": "2026-08-09T01:00:00.000Z",
"updated_at": "2026-08-11T07:30:15.123Z"
}
]
}
}
```
必须返回 `job.id`、`quota_reservation` 和更新后的 `affected_projects`。客户端用 `remaining_after_reservation` 立即更新剩余额度,并更新 Cell 状态;已有 `asset` 的重修 Tab 可同步显示处理态,首次生成且 `asset = null` 的槽位不显示 Tab。
### 查询 AI 修图任务
```http
GET /api/yf-handset-app/photog/travel-album/ai-retouch-job-info?user_equity_travel_id=88&job_id=job_01K2E4
```
响应:
```json
{
"code": 100000,
"msg": "success",
"data": {
"id": "job_01K2E4",
"user_equity_travel_id": "88",
"scope": "batch",
"status": "partially_succeeded",
"source_material_ids": ["2031", "2032", "2033", "2034"],
"progress": {
"total": 9,
"queued": 0,
"processing": 0,
"succeeded": 8,
"failed": 1
},
"quota_settlement": {
"status": "settled",
"reserved_units": 8,
"consumed_units": 7,
"released_units": 1,
"cover_units": 0
},
"targets": [
{
"source_material_id": "2031",
"output_type": "refined",
"status": "succeeded",
"result_asset_id": "asset_9201",
"error": null
},
{
"source_material_id": "2032",
"output_type": "atmosphere",
"status": "failed",
"result_asset_id": null,
"error": {
"code": "AI_PROVIDER_TIMEOUT",
"message": "AI 服务处理超时,请重试",
"retryable": true
}
},
{
"source_material_id": null,
"output_type": "cover",
"status": "succeeded",
"result_material_id": "cover_301",
"error": null
}
],
"created_at": "2026-08-11T07:30:15.123Z",
"started_at": "2026-08-11T07:30:18.000Z",
"finished_at": "2026-08-11T07:33:00.000Z"
}
}
```
| 整体状态 | 含义 |
|---|---|
| `queued` | 已受理,尚无子任务开始 |
| `processing` | 至少一个子任务正在执行,且尚未全部完成 |
| `succeeded` | 所有子任务成功 |
| `partially_succeeded` | 子任务全部结束,且既有成功也有失败 |
| `failed` | 子任务全部结束,没有任何成功结果 |
| `canceled` | 任务因素材删除或管理操作被取消 |
额度结算规则:
- 任务提交成功时先预占所有非封面子任务的额度,封面不进入预占。
- 每个精修/氛围感子任务成功后,将对应预占转为实际消耗。
- 子任务失败或取消时释放对应预占。因此部分成功示例中,预占 8 次、成功消耗 7 次、失败退回 1 次。
- `quota_settlement.status` 取值为 `reserved | partially_settled | settled`。
客户端如只关心当前预览图,优先调用单素材接口;本接口主要用于批量进度、问题排查和任务详情。
### 批量删除素材
```http
POST /api/yf-handset-app/photog/travel-album/delete-material
Content-Type: application/json
```
请求:
```json
{
"user_equity_travel_id": "88",
"material_ids": ["2031", "2032", "cover_301"]
}
```
为兼容旧客户端,原单张请求体继续有效:
```json
{
"id": "2031"
}
```
新请求使用 `user_equity_travel_id + material_ids`,旧请求使用 `id`,两组参数不同时传入。
响应:
```json
{
"code": 100000,
"msg": "删除成功",
"data": {
"deleted_material_ids": ["2031", "2032", "cover_301"],
"deleted_variant_asset_ids": ["asset_9101", "asset_9102"],
"canceled_job_ids": ["job_01K2E4"],
"oss_cleanup_status": "scheduled"
}
}
```
语义:
- 传入原图 ID:级联软删除精修图、氛围感图,并取消该原图的未完成子任务。
- 传入独立封面 ID:只删除该封面素材。
- 删除原图不级联删除以它为输入之一的已生成封面。
- 数据库业务删除采用一个事务,默认全部成功或全部失败。
- 任一 ID 不属于当前相册或无权删除时,整次请求失败。已删除的 ID 可视为幂等成功。
- OSS 文件清理异步执行,不影响业务删除成功。
@@ -0,0 +1,168 @@
# AI 修图需求文档
> 本文档描述旅拍相册中 AI 修图的产品需求和业务规则。后端接口建议见同目录《AI 修图接口文档》。
## 1. 目标与范围
- 相册管理页面展示用户已在其他流程上传到 OSS、并已在后端登记的图片素材。
- 用户可从网格页批量发起 AI 修图,也可从单图预览页首次修图或重新修图。
- 精修图和氛围感图是某张原图的“关联图”;封面图是相册内的独立素材,不归属于任何一张原图。
- AI 生成为异步长流程,页面必须能分辨已提交、排队、处理、成功和失败。
## 2. 核心概念
### 2.1 原图项目
一张原图与它的精修图、氛围感图共同组成一个原图项目。原图的素材 ID 是项目的稳定标识,OSS URL 不作为业务标识。
### 2.2 关联图
- `refined`:精修图,一张原图最多保留一个当前有效版本。
- `atmosphere`:氛围感图,一张原图最多保留一个当前有效版本。
- 关联图不作为独立 Cell 出现在相册网格中,只在该原图的预览页 Tab 中展示。
### 2.3 封面图
- 一次选择不少于 4 张原图时,必须选择封面模板。
- 封面模板使用本次选中的多张原图生成封面图。
- 封面图不关联某张原图,生成成功后作为 `cover` 类型的独立素材出现在相册网格中。
- 删除参与生成封面的原图时,不级联删除已生成的独立封面图。
## 3. 相册管理页
### 3.1 网格列表
- 网格展示原图和独立封面图,不直接展示精修图、氛围感图。
- 列表需支持稳定分页。AI 任务执行期间可能插入新的封面素材,不应造成重复或漏项。
- 点击原图 Cell 进入图片预览页,可横向滑动切换其他原图项目。
### 3.2 Cell 修图状态
Cell 左上角展示稳定的修图状态:
| 状态 | 含义 |
|---|---|
| 已上传 | 原图已登记,从未提交 AI 修图,当前也没有任务 |
| 待处理 | AI 任务已提交,正在排队 |
| 修图中 | AI 任务已开始执行 |
| AI 已修 | 最新任务已成功,存在有效 AI 结果 |
| 失败 | 最新任务执行失败,可能仍保留上一版成功结果 |
状态以后端返回的枚举代码为准,客户端自行映射中文文案,不依赖可变的中文状态名。
### 3.3 批量 AI 修图
1. 用户进入多选模式并选择一张或多张原图。
2. 点击底部“AI 修图”,弹出模板选择页。
3. 精修模板必选且单选。
4. 氛围感模板可选且单选,已选时可取消。
5. 选中原图数量小于 4 时不展示封面模板。
6. 选中原图数量大于等于 4 时展示封面模板,且必须单选一个。
7. 提交后,每张原图分别生成精修图,选了氛围感模板时再分别生成氛围感图。
8. 选了封面模板时,整个批次额外生成独立封面图。
9. 提交成功指后端已受理任务,不代表图片已生成。
### 3.4 修图额度
- 模板选择弹窗的底部固定显示当前用户可用的 AI 修图剩余额度,文案例如“剩余 12 次”,不随模板列表滚动消失。
- 精修和氛围感生成会消耗修图额度;封面模板生成不消耗任何修图额度。
- 本需求默认按生成结果张数计费:每张原图的精修结果消耗 1 次,每张原图的氛围感结果消耗 1 次,封面结果消耗 0 次。最终单价由后端返回,客户端不硬编码。
- 重新修图与首次修图使用相同的额度规则。
- 后端受理任务时预占本次所需额度,避免用户并发提交造成超额。
- 只对最终生成成功的精修/氛围感结果扣减额度;失败或取消的子任务释放对应预占额度。
- 如剩余可用额度不足,提交按钮禁用并显示明确提示;后端提交接口仍必须做最终校验。
### 3.5 批量删除
- 用户多选原图后点击“删除”,删除选中原图及各自的精修图、氛围感图,并取消关联的未完成任务。已有任务记录保留用于审计和排查,但不再对客户端展示已删除素材。
- 已生成的独立封面图不在级联删除范围内,除非用户明确选中该封面素材本身。
- 批量删除应只调用一次后端接口,后端应返回整体或逐项结果,不由客户端循环调用单删接口。
## 4. 图片预览页
### 4.1 横向切换
- 从网格点击某张原图进入预览页。
- 横向滑动在相册内的不同原图项目之间切换。
- 切换到新的原图项目时,默认显示其“原图”。
### 4.2 底部操作
页面底部固定显示三个按钮:
- AI 修图
- 删除
- 刷新
### 4.3 关联图 Tab
- 如果当前原图没有任何已生成的关联图,不显示 Tab;首次生成任务排队或处理期间仍不显示。
- 存在至少一张已成功的关联图时显示 Tab。
- Tab 最多包含“原图”、“精修后”、“氛围感”;关联图 Tab 只在对应图片已生成时显示。
- 点击 Tab 切换当前原图项目内的展示图片,不切换原图项目。
- 某个已有结果的 Tab 重新提交修图后,仍可展示上一版图片,同时通过该结果槽位的任务状态展示“待处理/修图中/失败”。
- 首次生成尚无图片 URL 时,通过原图 Cell 状态和项目任务状态表达进度,不创建空 Tab。
### 4.4 预览页删除
- 删除的对象始终是当前原图项目,而不是当前 Tab 上的单张关联图。
- 删除原图及其所有关联图,并取消关联的未完成任务;历史任务记录保留用于审计。
- 删除成功后显示下一个原图项目;如果删除的是末项,则回退上一项;无剩余项时关闭预览页。
### 4.5 预览页 AI 修图
| 当前情况 | 模板要求 | 生成与覆盖规则 |
|---|---|---|
| 不显示 Tab | 精修必选,氛围感可选 | 首次生成关联的精修图,可选生成氛围感图 |
| 已显示 Tab,当前为“原图” | 精修必选,氛围感可选 | 重新生成精修图;如选氛围感则也重新生成。新结果成功后原子替换对应旧结果 |
| 已显示 Tab,当前为“精修后” | 精修必选 | 只重新生成精修图,成功后覆盖旧精修图,不影响氛围感图 |
| 已显示 Tab,当前为“氛围感” | 氛围感必选 | 只重新生成氛围感图,成功后覆盖旧氛围感图,不影响精修图 |
重新修图期间应保留上一版成功图片可见;只有新结果成功时才原子替换当前版本。失败时继续保留旧版本,并返回可展示的错误信息。
### 4.6 刷新
- 点击“刷新”只获取当前显示原图项目的最新数据,包括原图、关联图、各结果槽位的任务状态和整体修图状态。
- 不应为刷新一张图重新拉取整个分页列表。
- 服务端对已替换的图片应返回新的资源版本或新 URL,避免 CDN/客户端缓存继续显示旧图。
## 5. 异步任务规则
- 每次提交返回唯一 `job_id`、服务端接收时间和当前状态。
- 每次提交同时返回额度预占数、提交前剩余额度和预占后可用额度。
- 任务状态至少包含:`queued`、`processing`、`succeeded`、`partially_succeeded`、`failed`、`canceled`。
- 精修、氛围感和封面子任务可以独立成功或失败,服务端需保留逐原图、逐结果类型的状态。
- 客户端重试同一次提交时不得创建重复任务,由幂等键保证。
- 同一原图、同一结果类型存在未完成任务时,服务端拒绝再次提交,避免旧任务晚完成后覆盖新结果。
## 6. 删除与数据一致性
- 删除原图项目需在一个后端业务操作中完成:标记原图删除、标记关联图删除、取消未完成任务。
- 数据库状态成功后再异步清理 OSS 文件,不应因 OSS 删除失败导致客户端删除失败。
- AI 回调必须检查任务和素材是否已删除或已被新版本替代,过期回调不得恢复已删除素材或覆盖新结果。
## 7. 异常与提示
- 模板加载失败:保留页面并允许重试。
- 模板已下线:提交时返回明确错误码,客户端刷新模板列表。
- 素材不存在、不属于当前相册或不是原图:整次提交不受理,并返回问题素材 ID。
- 数量不满足封面规则:返回稳定错误码和最小数量。
- 任务部分成功:保留已成功结果,失败槽位提供错误码、错误信息和是否可重试。
- 网络超时:客户端使用原幂等键重试,不可产生两个任务。
## 8. 权限与校验
- 后端必须从登录态校验用户对相册、原图和模板的访问权,不信任客户端传入的用户 ID、景区 ID 或批次 ID。
- 模板是否适用于相册所属景区,由后端根据相册 ID 推导和校验。
- 后端负责校验精修、氛围感和封面模板的必选/可选规则,客户端校验只用于交互提示。
## 9. 默认产品决策
为使接口和异步覆盖行为可实现,本文档采用以下默认决策:
1. 重新修图不先删旧图;新图成功后才原子切换,失败仍保留旧图。
2. 在“原图” Tab 重新修图时,若用户未选氛围感模板,保留现有氛围感图,只替换精修图。
3. 封面图只在生成成功后插入网格;生成前由所选原图的修图状态和任务详情表达进度。
4. 每个关联图类型只对外暴露一个当前有效版本;历史版本是否长期保留属于后端存储策略,不在当前客户端功能中展示。
> 第 2 条是对“氛围感模板可选”的安全解释:未选不等于删除旧结果。如产品希望未选时删除旧氛围感图,应增加明确的“移除氛围感”操作,不建议让后端根据字段缺失隐式删除。
@@ -29,6 +29,9 @@ protocol TravelAlbumServing {
isPurchased: Int? isPurchased: Int?
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial> ) async throws -> TravelAlbumListResponse<TravelAlbumMaterial>
/// 拉取单个相册素材及其最新关联图片信息。
func materialInfo(userEquityTravelId: Int, materialId: Int) async throws -> TravelAlbumMaterial
/// 上传并登记旅拍相册素材。 /// 上传并登记旅拍相册素材。
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial
@@ -38,11 +41,30 @@ protocol TravelAlbumServing {
/// 删除旅拍相册。 /// 删除旅拍相册。
func deleteAlbum(id: Int) async throws func deleteAlbum(id: Int) async throws
/// 删除旅拍相册素材。 /// 批量删除旅拍相册素材。
func deleteMaterial(id: Int) async throws func batchDeleteMaterials(ids: [Int]) async throws
/// 拉取相册小程序码。 /// 拉取相册小程序码。
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse
/// 拉取当前景区可用的 AI 修图模板。
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse
/// 提交相册素材 AI 修图任务。
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission
/// 提交单张素材重新修图任务。
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws -> TravelAlbumAIJobSubmission
/// 拉取当前账号的 AI 修图任务列表。
func aiRetouchJobList(
statusGroup: TravelAlbumAIJobFilter,
limit: Int,
cursor: String?
) async throws -> TravelAlbumAIJobListResponse
/// 拉取指定 AI 修图任务详情。
func aiRetouchJobInfo(batchId: Int) async throws -> TravelAlbumAIJobDetail
} }
@MainActor @MainActor
@@ -113,6 +135,20 @@ final class TravelAlbumAPI: TravelAlbumServing {
) )
} }
/// 拉取单个相册素材及其最新关联图片信息。
func materialInfo(userEquityTravelId: Int, materialId: Int) async throws -> TravelAlbumMaterial {
try await client.send(
APIRequest(
method: .get,
path: "\(basePath)/material-info",
queryItems: [
URLQueryItem(name: "user_equity_travel_id", value: String(userEquityTravelId)),
URLQueryItem(name: "material_id", value: String(materialId)),
]
)
)
}
/// 上传并登记旅拍相册素材。 /// 上传并登记旅拍相册素材。
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial { func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
try await client.send(APIRequest(method: .post, path: "\(basePath)/upload-material", body: request)) try await client.send(APIRequest(method: .post, path: "\(basePath)/upload-material", body: request))
@@ -138,10 +174,14 @@ final class TravelAlbumAPI: TravelAlbumServing {
) )
} }
/// 删除旅拍相册素材。 /// 批量删除旅拍相册素材。
func deleteMaterial(id: Int) async throws { func batchDeleteMaterials(ids: [Int]) async throws {
let _: EmptyPayload = try await client.send( let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "\(basePath)/delete-material", body: TravelAlbumIDRequest(id: id)) APIRequest(
method: .post,
path: "\(basePath)/batch-delete-material",
body: TravelAlbumBatchDeleteMaterialRequest(ids: ids)
)
) )
} }
@@ -155,9 +195,64 @@ final class TravelAlbumAPI: TravelAlbumServing {
) )
) )
} }
/// 拉取当前景区可用的 AI 修图模板。
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse {
try await client.send(
APIRequest(
method: .get,
path: "\(basePath)/ai-retouch-templates",
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
)
)
}
/// 提交相册素材 AI 修图任务并返回任务摘要。
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission {
try await client.send(APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request))
}
/// 提交重新修图任务并返回任务摘要。
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws -> TravelAlbumAIJobSubmission {
try await client.send(APIRequest(method: .post, path: "\(basePath)/ai-reretouch", body: request))
}
/// 拉取当前账号的 AI 修图任务列表。
func aiRetouchJobList(
statusGroup: TravelAlbumAIJobFilter,
limit: Int = 20,
cursor: String? = nil
) async throws -> TravelAlbumAIJobListResponse {
var queryItems = [
URLQueryItem(name: "status_group", value: statusGroup.rawValue),
URLQueryItem(name: "limit", value: String(min(50, max(1, limit)))),
]
if let cursor = cursor?.trimmingCharacters(in: .whitespacesAndNewlines), !cursor.isEmpty {
queryItems.append(URLQueryItem(name: "cursor", value: cursor))
}
return try await client.send(
APIRequest(method: .get, path: "\(basePath)/ai-retouch-job-list", queryItems: queryItems)
)
}
/// 拉取指定 AI 修图任务详情。
func aiRetouchJobInfo(batchId: Int) async throws -> TravelAlbumAIJobDetail {
try await client.send(
APIRequest(
method: .get,
path: "\(basePath)/ai-retouch-job-info",
queryItems: [URLQueryItem(name: "ai_retouch_batch_id", value: String(batchId))]
)
)
}
} }
/// 旅拍相册 ID 请求体。 /// 旅拍相册 ID 请求体。
private struct TravelAlbumIDRequest: Encodable, Sendable { private struct TravelAlbumIDRequest: Encodable, Sendable {
let id: Int let id: Int
} }
/// 旅拍相册素材批量删除请求体。
private struct TravelAlbumBatchDeleteMaterialRequest: Encodable, Sendable {
let ids: [Int]
}
@@ -0,0 +1,380 @@
import Foundation
/// AI 修图任务筛选分组,对应任务列表接口的 `status_group`。
enum TravelAlbumAIJobFilter: String, CaseIterable, Sendable, Hashable {
case all
case inProgress = "in_progress"
case completed
case failed
var title: String {
switch self {
case .all: "全部"
case .inProgress: "进行中"
case .completed: "已完成"
case .failed: "失败"
}
}
}
/// AI 修图任务及子任务状态;未知值安全降级,避免新增后端状态导致整页解码失败。
enum TravelAlbumAIJobStatus: Sendable, Hashable {
case queued
case processing
case succeeded
case partiallySucceeded
case failed
case canceled
case unknown(String)
init(rawValue: String) {
switch rawValue {
case "queued": self = .queued
case "processing": self = .processing
case "succeeded": self = .succeeded
case "partially_succeeded": self = .partiallySucceeded
case "failed": self = .failed
case "canceled": self = .canceled
default: self = .unknown(rawValue)
}
}
var rawValue: String {
switch self {
case .queued: "queued"
case .processing: "processing"
case .succeeded: "succeeded"
case .partiallySucceeded: "partially_succeeded"
case .failed: "failed"
case .canceled: "canceled"
case .unknown(let value): value
}
}
var isInProgress: Bool { self == .queued || self == .processing }
var isTerminal: Bool { !isInProgress && !isUnknown }
private var isUnknown: Bool { if case .unknown = self { true } else { false } }
var title: String {
switch self {
case .queued: "排队中"
case .processing: "修图中"
case .succeeded: "已完成"
case .partiallySucceeded: "部分完成"
case .failed: "失败"
case .canceled: "已取消"
case .unknown: "状态更新中"
}
}
}
extension TravelAlbumAIJobStatus: Decodable {
init(from decoder: Decoder) throws {
self.init(rawValue: (try? decoder.singleValueContainer().decode(String.self)) ?? "")
}
}
/// AI 修图输出类型。
enum TravelAlbumAIJobOutputType: Sendable, Hashable {
case refined
case atmosphere
case cover
case unknown(String)
init(rawValue: String) {
switch rawValue {
case "refined": self = .refined
case "atmosphere": self = .atmosphere
case "cover": self = .cover
default: self = .unknown(rawValue)
}
}
var title: String {
switch self {
case .refined: "原图精修"
case .atmosphere: "氛围感"
case .cover: "封面"
case .unknown: "其他结果"
}
}
var previewKind: TravelAlbumPreviewAssetKind? {
switch self {
case .refined: .retouched
case .atmosphere: .atmosphere
case .cover: .cover
case .unknown: nil
}
}
}
extension TravelAlbumAIJobOutputType: Decodable {
init(from decoder: Decoder) throws {
self.init(rawValue: (try? decoder.singleValueContainer().decode(String.self)) ?? "")
}
}
/// AI 修图子任务数量进度。
struct TravelAlbumAIJobProgress: Decodable, Sendable, Equatable, Hashable {
let total: Int
let queued: Int
let processing: Int
let succeeded: Int
let failed: Int
let canceled: Int
var completed: Int { min(total, succeeded + failed + canceled) }
var fraction: Double { total > 0 ? min(1, Double(completed) / Double(total)) : 0 }
}
/// AI 修图提交成功后返回的任务摘要。
struct TravelAlbumAIJobSubmission: Decodable, Sendable, Equatable {
let aiRetouchBatchId: Int
let userEquityTravelId: Int
let status: TravelAlbumAIJobStatus
let progress: TravelAlbumAIJobProgress
let createdAt: String
enum CodingKeys: String, CodingKey {
case aiRetouchBatchId = "ai_retouch_batch_id"
case userEquityTravelId = "user_equity_travel_id"
case status, progress
case createdAt = "created_at"
}
}
/// 任务所属相册摘要。
struct TravelAlbumAIJobAlbum: Decodable, Sendable, Equatable, Hashable {
let id: Int
let name: String
let userPhone: String
let coverURL: String
enum CodingKeys: String, CodingKey {
case id, name
case userPhone = "user_phone"
case coverURL = "cover_url"
}
}
/// 任务计划输出数量。
struct TravelAlbumAIJobOutput: Decodable, Sendable, Equatable, Hashable {
let type: TravelAlbumAIJobOutputType
let count: Int
}
/// 任务列表缩略图。
struct TravelAlbumAIJobPreviewImage: Decodable, Sendable, Equatable, Hashable {
let materialId: Int
let thumbnailURL: String
enum CodingKeys: String, CodingKey {
case materialId = "material_id"
case thumbnailURL = "thumbnail_url"
}
}
/// AI 修图任务列表项。
struct TravelAlbumAIJobSummary: Decodable, Sendable, Equatable, Hashable, Identifiable {
var id: Int { aiRetouchBatchId }
let aiRetouchBatchId: Int
let userEquityTravelId: Int
let scope: String
let status: TravelAlbumAIJobStatus
let album: TravelAlbumAIJobAlbum
let sourceCount: Int
let outputs: [TravelAlbumAIJobOutput]
let previewImages: [TravelAlbumAIJobPreviewImage]
let progress: TravelAlbumAIJobProgress
let estimatedFinishAt: String?
let failureSummary: String?
let createdAt: String
let startedAt: String?
let finishedAt: String?
enum CodingKeys: String, CodingKey {
case aiRetouchBatchId = "ai_retouch_batch_id"
case userEquityTravelId = "user_equity_travel_id"
case scope, status, album, outputs, progress
case sourceCount = "source_count"
case previewImages = "preview_images"
case estimatedFinishAt = "estimated_finish_at"
case failureSummary = "failure_summary"
case createdAt = "created_at"
case startedAt = "started_at"
case finishedAt = "finished_at"
}
var displayFailureSummary: String? {
guard status == .failed || status == .partiallySucceeded || progress.failed > 0 else { return nil }
let value = failureSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return value.isEmpty ? "部分照片处理失败,点击查看原因" : value
}
}
/// AI 修图任务游标分页响应。
struct TravelAlbumAIJobListResponse: Decodable, Sendable, Equatable {
let items: [TravelAlbumAIJobSummary]
let nextCursor: String?
let hasMore: Bool
enum CodingKeys: String, CodingKey {
case items
case nextCursor = "next_cursor"
case hasMore = "has_more"
}
}
/// AI 修图额度结算信息。
struct TravelAlbumAIJobQuotaSettlement: Decodable, Sendable, Equatable {
let status: String
let reservedUnits: Int
let consumedUnits: Int
let releasedUnits: Int
let coverUnits: Int
enum CodingKeys: String, CodingKey {
case status
case reservedUnits = "reserved_units"
case consumedUnits = "consumed_units"
case releasedUnits = "released_units"
case coverUnits = "cover_units"
}
}
/// AI 修图目标的来源素材。
struct TravelAlbumAIJobSourceMaterial: Decodable, Sendable, Equatable, Hashable {
let id: Int
let fileName: String
let thumbnailURL: String
enum CodingKeys: String, CodingKey {
case id
case fileName = "file_name"
case thumbnailURL = "thumbnail_url"
}
}
/// AI 修图目标使用的模板摘要。
struct TravelAlbumAIJobTemplate: Decodable, Sendable, Equatable, Hashable {
let id: Int
let name: String
}
/// AI 修图成功结果资源。
struct TravelAlbumAIJobResultAsset: Decodable, Sendable, Equatable, Hashable {
let id: Int
let materialId: Int
let url: String
let thumbnailURL: String
enum CodingKeys: String, CodingKey {
case id
case materialId = "material_id"
case url
case thumbnailURL = "thumbnail_url"
}
}
/// 可直接展示给用户的 AI 修图失败信息。
struct TravelAlbumAIJobError: Decodable, Sendable, Equatable, Hashable {
let code: String
let message: String
let retryable: Bool
var displayMessage: String {
let value = message.trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? "处理失败,请前往相册重新修图" : value
}
}
/// 单个输出目标的处理明细。
struct TravelAlbumAIJobTarget: Decodable, Sendable, Equatable, Hashable, Identifiable {
var id: Int { targetId }
let targetId: Int
let sourceMaterial: TravelAlbumAIJobSourceMaterial?
let inputMaterialIds: [Int]
let outputType: TravelAlbumAIJobOutputType
let template: TravelAlbumAIJobTemplate?
let status: TravelAlbumAIJobStatus
let resultAsset: TravelAlbumAIJobResultAsset?
let error: TravelAlbumAIJobError?
let createdAt: String
let startedAt: String?
let finishedAt: String?
enum CodingKeys: String, CodingKey {
case targetId = "target_id"
case sourceMaterial = "source_material"
case inputMaterialIds = "input_material_ids"
case outputType = "output_type"
case template, status, error
case resultAsset = "result_asset"
case createdAt = "created_at"
case startedAt = "started_at"
case finishedAt = "finished_at"
}
var displayFailureMessage: String? {
guard status == .failed else { return nil }
return error?.displayMessage ?? "处理失败,请前往相册重新修图"
}
}
/// AI 修图任务完整详情。
struct TravelAlbumAIJobDetail: Decodable, Sendable, Equatable {
let aiRetouchBatchId: Int
let userEquityTravelId: Int
let scope: String
let status: TravelAlbumAIJobStatus
let album: TravelAlbumAIJobAlbum
let sourceCount: Int
let outputs: [TravelAlbumAIJobOutput]
let progress: TravelAlbumAIJobProgress
let quotaSettlement: TravelAlbumAIJobQuotaSettlement
let targets: [TravelAlbumAIJobTarget]
let estimatedFinishAt: String?
let createdAt: String
let startedAt: String?
let finishedAt: String?
let durationSeconds: Int?
enum CodingKeys: String, CodingKey {
case aiRetouchBatchId = "ai_retouch_batch_id"
case userEquityTravelId = "user_equity_travel_id"
case scope, status, album, outputs, progress, targets
case sourceCount = "source_count"
case quotaSettlement = "quota_settlement"
case estimatedFinishAt = "estimated_finish_at"
case createdAt = "created_at"
case startedAt = "started_at"
case finishedAt = "finished_at"
case durationSeconds = "duration_seconds"
}
}
/// AI 修图任务日期展示工具。
enum TravelAlbumAIJobDateFormatter {
private static let internetFormatter = ISO8601DateFormatter()
private static let preciseFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
static func date(_ value: String?) -> Date? {
guard let value, !value.isEmpty else { return nil }
return preciseFormatter.date(from: value) ?? internetFormatter.date(from: value)
}
static func display(_ value: String?) -> String {
guard let date = date(value) else { return "--" }
return date.formatted(.dateTime.month().day().hour().minute())
}
static func time(_ value: String?) -> String {
guard let date = date(value) else { return "--" }
return date.formatted(.dateTime.hour().minute())
}
}
@@ -127,6 +127,11 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
let fileSize: Int let fileSize: Int
let coverUrl: String let coverUrl: String
let isPurchased: Bool let isPurchased: Bool
let aiRetouchStatus: Int
let aiRetouchStatusName: String
let aiRetouchBatchId: Int
let aiRefinedURL: String
let aiAtmosphereURL: String
let createdAt: String let createdAt: String
let updatedAt: String let updatedAt: String
@@ -142,10 +147,42 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
case fileSize = "file_size" case fileSize = "file_size"
case coverUrl = "cover_url" case coverUrl = "cover_url"
case isPurchased = "is_purchased" case isPurchased = "is_purchased"
case aiRetouchStatus = "ai_retouch_status"
case aiRetouchStatusName = "ai_retouch_status_name"
case aiRetouchBatchId = "ai_retouch_batch_id"
case aiRefinedURL = "ai_refined_url"
case aiAtmosphereURL = "ai_atmosphere_url"
case createdAt = "created_at" case createdAt = "created_at"
case updatedAt = "updated_at" case updatedAt = "updated_at"
} }
/// 从素材接口解码;AI 修图扩展字段缺失、为空或类型异常时使用安全默认值。
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
userEquityTravelId = try container.decode(Int.self, forKey: .userEquityTravelId)
status = try container.decode(Int.self, forKey: .status)
orderNumber = try container.decode(String.self, forKey: .orderNumber)
userId = try container.decode(Int.self, forKey: .userId)
fileName = try container.decode(String.self, forKey: .fileName)
fileType = try container.decode(Int.self, forKey: .fileType)
fileUrl = try container.decode(String.self, forKey: .fileUrl)
fileSize = try container.decode(Int.self, forKey: .fileSize)
coverUrl = try container.decode(String.self, forKey: .coverUrl)
isPurchased = try container.decode(Bool.self, forKey: .isPurchased)
aiRetouchStatus = (try? container.decodeIfPresent(Int.self, forKey: .aiRetouchStatus)) ?? 0
aiRetouchStatusName = (
try? container.decodeIfPresent(String.self, forKey: .aiRetouchStatusName)
) ?? ""
aiRetouchBatchId = (try? container.decodeIfPresent(Int.self, forKey: .aiRetouchBatchId)) ?? 0
aiRefinedURL = (try? container.decodeIfPresent(String.self, forKey: .aiRefinedURL)) ?? ""
aiAtmosphereURL = (
try? container.decodeIfPresent(String.self, forKey: .aiAtmosphereURL)
) ?? ""
createdAt = try container.decode(String.self, forKey: .createdAt)
updatedAt = try container.decode(String.self, forKey: .updatedAt)
}
init( init(
id: Int = 0, id: Int = 0,
userEquityTravelId: Int = 0, userEquityTravelId: Int = 0,
@@ -158,6 +195,11 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
fileSize: Int = 0, fileSize: Int = 0,
coverUrl: String = "", coverUrl: String = "",
isPurchased: Bool = false, isPurchased: Bool = false,
aiRetouchStatus: Int = 0,
aiRetouchStatusName: String = "",
aiRetouchBatchId: Int = 0,
aiRefinedURL: String = "",
aiAtmosphereURL: String = "",
createdAt: String = "", createdAt: String = "",
updatedAt: String = "" updatedAt: String = ""
) { ) {
@@ -172,11 +214,69 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
self.fileSize = fileSize self.fileSize = fileSize
self.coverUrl = coverUrl self.coverUrl = coverUrl
self.isPurchased = isPurchased self.isPurchased = isPurchased
self.aiRetouchStatus = aiRetouchStatus
self.aiRetouchStatusName = aiRetouchStatusName
self.aiRetouchBatchId = aiRetouchBatchId
self.aiRefinedURL = aiRefinedURL
self.aiAtmosphereURL = aiAtmosphereURL
self.createdAt = createdAt self.createdAt = createdAt
self.updatedAt = updatedAt self.updatedAt = updatedAt
} }
} }
/// 相册素材网格角标类别,用于稳定映射文案优先级和语义颜色。
enum TravelAlbumMaterialBadgeKind: Sendable, Equatable {
case purchased
case pending
case processing
case retouched
case cover
case failed
}
/// 相册素材网格角标展示内容。
struct TravelAlbumMaterialBadgePresentation: Sendable, Equatable {
let kind: TravelAlbumMaterialBadgeKind
let text: String
}
extension TravelAlbumMaterial {
/// 按 AI 修图状态和购买状态生成网格角标;返回 nil 时隐藏角标。
var badgePresentation: TravelAlbumMaterialBadgePresentation? {
if aiRetouchStatus == 0 {
return isPurchased
? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
: nil
}
let statusName = aiRetouchStatusName.trimmingCharacters(in: .whitespacesAndNewlines)
switch aiRetouchStatus {
case 1:
return TravelAlbumMaterialBadgePresentation(
kind: .pending,
text: statusName.isEmpty ? "待处理" : statusName
)
case 2:
return TravelAlbumMaterialBadgePresentation(
kind: .processing,
text: statusName.isEmpty ? "修图中" : statusName
)
case 3:
return TravelAlbumMaterialBadgePresentation(
kind: statusName == "AI封面" ? .cover : .retouched,
text: statusName.isEmpty ? "AI已修" : statusName
)
case 4:
return TravelAlbumMaterialBadgePresentation(
kind: .failed,
text: statusName.isEmpty ? "失败" : statusName
)
default:
return nil
}
}
}
/// 旅拍相册创建请求体,对齐 Android `TravelAlbumCreateRequest`。 /// 旅拍相册创建请求体,对齐 Android `TravelAlbumCreateRequest`。
struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable { struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable {
let name: String let name: String
@@ -247,6 +347,182 @@ struct TravelAlbumMpCodeResponse: Decodable, Sendable, Equatable {
} }
} }
/// AI 修图模板类别,决定页面分组、选择规则和提交字段。
enum TravelAlbumAIRetouchTemplateCategory: Int, Sendable, Hashable {
case refined
case atmosphere
case cover
/// 模板分组展示标题。
var title: String {
switch self {
case .refined: "原图精修"
case .atmosphere: "氛围感修图"
case .cover: "封面风格"
}
}
}
/// AI 修图模板页工作流,明确区分首次批量修图与单张结果覆盖重修。
enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable {
/// 对一个相册内的原始素材发起首次 AI 修图。
case initial(albumId: Int, materialIds: [Int])
/// 对单张素材的既有 AI 结果发起覆盖重修。
case reretouch(materialId: Int, batchId: Int, type: TravelAlbumAIReretouchType)
/// 当前页面需要展示的模板分组。
var visibleCategories: [TravelAlbumAIRetouchTemplateCategory] {
switch self {
case .initial(_, let materialIds):
var categories: [TravelAlbumAIRetouchTemplateCategory] = [.refined, .atmosphere]
if materialIds.count >= 4 { categories.append(.cover) }
return categories
case .reretouch(_, _, .refined):
return [.refined]
case .reretouch(_, _, .atmosphere):
return [.atmosphere]
case .reretouch(_, _, .all):
return [.refined, .atmosphere]
}
}
/// 当前分组是否允许不选择;仅首次修图的氛围感模板选填。
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
if case .initial = self, category == .atmosphere { return true }
return false
}
/// 工作流目标是否满足接口的最小参数要求。
var isValid: Bool {
switch self {
case .initial(let albumId, let materialIds):
return albumId > 0 && !materialIds.isEmpty
case .reretouch(let materialId, let batchId, _):
return materialId > 0 && batchId > 0
}
}
}
/// AI 修图模板,包含业务 ID、展示名称、卡片预览图及前后对比图片地址。
struct TravelAlbumAIRetouchTemplate: Decodable, Sendable, Equatable, Hashable, Identifiable {
let id: Int
let name: String
let previewURL: String
let beforeURL: String
let afterURL: String
enum CodingKeys: String, CodingKey {
case id
case name
case previewURL = "preview_url"
case beforeURL = "before_url"
case afterURL = "after_url"
}
/// 创建 AI 修图模板。
init(
id: Int,
name: String,
previewURL: String,
beforeURL: String = "",
afterURL: String = ""
) {
self.id = id
self.name = name
self.previewURL = previewURL
self.beforeURL = beforeURL
self.afterURL = afterURL
}
/// 解码模板;前后对比字段缺失或为 null 时按空字符串兼容旧接口响应。
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
previewURL = try container.decode(String.self, forKey: .previewURL)
beforeURL = (try? container.decodeIfPresent(String.self, forKey: .beforeURL)) ?? ""
afterURL = (try? container.decodeIfPresent(String.self, forKey: .afterURL)) ?? ""
}
}
/// AI 修图模板接口响应,按原图、氛围感和封面风格分组。
struct TravelAlbumAIRetouchTemplatesResponse: Decodable, Sendable, Equatable {
let refinedTemplates: [TravelAlbumAIRetouchTemplate]
let atmosphereTemplates: [TravelAlbumAIRetouchTemplate]
let coverTemplates: [TravelAlbumAIRetouchTemplate]
let remainingQuota: Int
enum CodingKeys: String, CodingKey {
case refinedTemplates = "refined_templates"
case atmosphereTemplates = "atmosphere_templates"
case coverTemplates = "cover_templates"
case remainingQuota = "remaining_quota"
}
/// 创建分组模板响应,默认各组为空。
init(
refinedTemplates: [TravelAlbumAIRetouchTemplate] = [],
atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = [],
coverTemplates: [TravelAlbumAIRetouchTemplate] = [],
remainingQuota: Int = 0
) {
self.refinedTemplates = refinedTemplates
self.atmosphereTemplates = atmosphereTemplates
self.coverTemplates = coverTemplates
self.remainingQuota = max(0, remainingQuota)
}
/// 解码模板和剩余额度;旧响应缺少额度时按零处理,避免误提交付费任务。
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
refinedTemplates = try container.decode([TravelAlbumAIRetouchTemplate].self, forKey: .refinedTemplates)
atmosphereTemplates = try container.decode([TravelAlbumAIRetouchTemplate].self, forKey: .atmosphereTemplates)
coverTemplates = try container.decode([TravelAlbumAIRetouchTemplate].self, forKey: .coverTemplates)
remainingQuota = max(0, try container.decodeIfPresent(Int.self, forKey: .remainingQuota) ?? 0)
}
}
/// 提交 AI 修图任务的请求参数。
struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable {
let userEquityTravelId: Int
let materialIds: [Int]
let refinedTemplateId: Int
let atmosphereTemplateId: Int?
let coverTemplateId: Int?
enum CodingKeys: String, CodingKey {
case userEquityTravelId = "user_equity_travel_id"
case materialIds = "material_ids"
case refinedTemplateId = "refined_template_id"
case atmosphereTemplateId = "atmosphere_template_id"
case coverTemplateId = "cover_template_id"
}
}
/// 重新修图类型,决定后端覆盖的 AI 结果及必需模板字段。
enum TravelAlbumAIReretouchType: Int, Encodable, Sendable, Equatable {
case refined = 1
case atmosphere = 2
case all = 3
}
/// 提交单张素材重新修图的最小请求参数。
struct TravelAlbumAIReretouchRequest: Encodable, Sendable, Equatable {
let id: Int
let aiRetouchBatchId: Int
let type: TravelAlbumAIReretouchType
let refinedTemplateId: Int?
let atmosphereTemplateId: Int?
enum CodingKeys: String, CodingKey {
case id
case aiRetouchBatchId = "ai_retouch_batch_id"
case type
case refinedTemplateId = "refined_template_id"
case atmosphereTemplateId = "atmosphere_template_id"
}
}
/// 旅拍相册展示格式化工具。 /// 旅拍相册展示格式化工具。
enum TravelAlbumDisplayFormatter { enum TravelAlbumDisplayFormatter {
/// 脱敏手机号。 /// 脱敏手机号。
@@ -277,4 +553,22 @@ enum TravelAlbumDisplayFormatter {
} }
return text return text
} }
/// 将服务端创建时间格式化为摘要卡使用的 `yyyy/MM/dd HH:mm`。
static func creationTimeText(_ text: String) -> String {
guard !text.isEmpty else { return "--" }
let normalized = text.replacingOccurrences(of: "T", with: " ")
guard normalized.count >= 16 else { return normalized }
return String(normalized.prefix(16)).replacingOccurrences(of: "-", with: "/")
}
/// 计算相册摘要卡封面,按相册、素材缩略图、素材原图顺序回退。
static func albumCoverURL(album: TravelAlbum?, materials: [TravelAlbumMaterial]) -> String {
if let coverURL = album?.coverUrl.trimmingCharacters(in: .whitespacesAndNewlines), !coverURL.isEmpty {
return coverURL
}
guard let first = materials.first else { return "" }
let materialCover = first.coverUrl.trimmingCharacters(in: .whitespacesAndNewlines)
return materialCover.isEmpty ? first.fileUrl.trimmingCharacters(in: .whitespacesAndNewlines) : materialCover
}
} }
@@ -0,0 +1,270 @@
//
// TravelAlbumPreviewModels.swift
// suixinkan
//
import Foundation
/// 前后图片对比页所需的通用内容,与具体入口和 UI 框架解耦。
struct BeforeAfterComparisonContent: Sendable, Equatable {
let beforeURL: String
let afterURL: String
let beforeLabel: String
let afterLabel: String
/// 创建前后对比内容,默认使用 AI 修图模块的中文角标。
init(
beforeURL: String,
afterURL: String,
beforeLabel: String = "原图",
afterLabel: String = "效果图"
) {
self.beforeURL = beforeURL.trimmingCharacters(in: .whitespacesAndNewlines)
self.afterURL = afterURL.trimmingCharacters(in: .whitespacesAndNewlines)
self.beforeLabel = beforeLabel
self.afterLabel = afterLabel
}
/// 两张图片地址均存在时才允许进入对比页。
var isValid: Bool {
!beforeURL.isEmpty && !afterURL.isEmpty
}
}
/// 相册预览页横向滑动策略,由调用方通过配置注入。
enum TravelAlbumPreviewSwipeMode: Sendable, Equatable {
/// 横滑只切换原图项目,关联图通过 Tab 切换。
case projectsOnly
/// 横滑依次切换当前项目的关联图,并在边界进入相邻项目原图。
case includeVariants
}
/// 相册预览页配置。
struct TravelAlbumPreviewConfiguration: Sendable {
let swipeMode: TravelAlbumPreviewSwipeMode
/// 创建预览配置,默认只按原图项目分页。
init(swipeMode: TravelAlbumPreviewSwipeMode = .projectsOnly) {
self.swipeMode = swipeMode
}
}
/// 预览图片类型,顺序同时决定 Tab 与关联图浏览顺序。
enum TravelAlbumPreviewAssetKind: Int, CaseIterable, Sendable, Hashable {
case original
case retouched
case atmosphere
case cover
var title: String {
switch self {
case .original: "原图"
case .retouched: "精修后"
case .atmosphere: "氛围感"
case .cover: "封面"
}
}
}
/// 预览页使用的单张图片信息,与后端关联图字段解耦。
struct TravelAlbumPreviewAsset: Identifiable, Sendable, Hashable {
let id: String
let kind: TravelAlbumPreviewAssetKind
let fileURL: String
let coverURL: String
let fileName: String
let fileSize: Int
/// 实际用于展示的地址,优先使用原图地址。
var displayURL: String {
let trimmedFileURL = fileURL.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedFileURL.isEmpty ? coverURL.trimmingCharacters(in: .whitespacesAndNewlines) : trimmedFileURL
}
/// 横滑预览地址,优先使用体积更小的封面图以降低解码开销。
var previewURL: String {
let trimmedCoverURL = coverURL.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedCoverURL.isEmpty ? fileURL.trimmingCharacters(in: .whitespacesAndNewlines) : trimmedCoverURL
}
}
/// 一张原图及其所有关联图片组成的预览项目。
struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
let originalMaterialId: Int
let aiRetouchBatchId: Int
let assets: [TravelAlbumPreviewAsset]
var id: Int { originalMaterialId }
/// 按产品约定顺序返回实际存在的图片。
var orderedAssets: [TravelAlbumPreviewAsset] {
TravelAlbumPreviewAssetKind.allCases.compactMap(asset(for:))
}
/// 是否存在原图之外的关联图片。
var hasVariants: Bool {
orderedAssets.contains { $0.kind != .original }
}
/// 返回指定类型的图片。
func asset(for kind: TravelAlbumPreviewAssetKind) -> TravelAlbumPreviewAsset? {
assets.first { $0.kind == kind }
}
/// 为精修或氛围感结果构建与原图的对比内容;其他类型或无效地址返回 nil。
func comparisonContent(for kind: TravelAlbumPreviewAssetKind) -> BeforeAfterComparisonContent? {
guard kind == .retouched || kind == .atmosphere,
let original = asset(for: .original),
let result = asset(for: kind)
else { return nil }
let content = BeforeAfterComparisonContent(
beforeURL: original.displayURL,
afterURL: result.displayURL
)
return content.isValid ? content : nil
}
/// 将素材映射为原图及实际存在的 AI 精修、氛围感结果图。
init(material: TravelAlbumMaterial) {
originalMaterialId = material.id
aiRetouchBatchId = material.aiRetouchBatchId
var mappedAssets = [
TravelAlbumPreviewAsset(
id: "original-\(material.id)",
kind: .original,
fileURL: material.fileUrl,
coverURL: material.coverUrl,
fileName: material.fileName,
fileSize: material.fileSize
),
]
let refinedURL = material.aiRefinedURL.trimmingCharacters(in: .whitespacesAndNewlines)
if !refinedURL.isEmpty {
mappedAssets.append(
TravelAlbumPreviewAsset(
id: "retouched-\(material.id)",
kind: .retouched,
fileURL: refinedURL,
coverURL: refinedURL,
fileName: material.fileName,
fileSize: material.fileSize
)
)
}
let atmosphereURL = material.aiAtmosphereURL.trimmingCharacters(in: .whitespacesAndNewlines)
if !atmosphereURL.isEmpty {
mappedAssets.append(
TravelAlbumPreviewAsset(
id: "atmosphere-\(material.id)",
kind: .atmosphere,
fileURL: atmosphereURL,
coverURL: atmosphereURL,
fileName: material.fileName,
fileSize: material.fileSize
)
)
}
assets = mappedAssets
}
/// 创建包含关联图片的项目,主要供适配器和测试使用。
init(originalMaterialId: Int, aiRetouchBatchId: Int = 0, assets: [TravelAlbumPreviewAsset]) {
self.originalMaterialId = originalMaterialId
self.aiRetouchBatchId = aiRetouchBatchId
var seenKinds = Set<TravelAlbumPreviewAssetKind>()
self.assets = assets.filter { seenKinds.insert($0.kind).inserted }
}
/// 根据当前 Tab 生成首次修图或覆盖重修工作流。
func aiRetouchWorkflow(
albumId: Int,
selectedKind: TravelAlbumPreviewAssetKind
) -> TravelAlbumAIRetouchWorkflow? {
guard hasVariants else {
return .initial(albumId: albumId, materialIds: [originalMaterialId])
}
switch selectedKind {
case .original:
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .all)
case .retouched:
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .refined)
case .atmosphere:
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .atmosphere)
case .cover:
return nil
}
}
}
extension TravelAlbumAIRetouchTemplate {
/// 使用接口返回的模板示例前后图构建对比页内容。
var comparisonContent: BeforeAfterComparisonContent? {
let content = BeforeAfterComparisonContent(beforeURL: beforeURL, afterURL: afterURL)
return content.isValid ? content : nil
}
}
/// 预览分页节点,记录当前图片所属项目及类型。
struct TravelAlbumPreviewNode: Sendable, Hashable {
let projectIndex: Int
let kind: TravelAlbumPreviewAssetKind
}
/// 预览页纯状态工具,负责构建节点和处理分组滑动边界。
enum TravelAlbumPreviewNavigator {
/// 根据滑动模式生成页面节点。
static func nodes(
projects: [TravelAlbumPreviewProject],
mode: TravelAlbumPreviewSwipeMode
) -> [TravelAlbumPreviewNode] {
projects.enumerated().flatMap { index, project in
switch mode {
case .projectsOnly:
[TravelAlbumPreviewNode(projectIndex: index, kind: .original)]
case .includeVariants:
project.orderedAssets.map { TravelAlbumPreviewNode(projectIndex: index, kind: $0.kind) }
}
}
}
/// 处理反向滑动边界:从项目原图向右滑时直接进入上一项目原图。
static func backwardTargetIndex(
nodes: [TravelAlbumPreviewNode],
currentIndex: Int
) -> Int {
guard nodes.indices.contains(currentIndex), currentIndex > 0 else { return max(0, currentIndex) }
let current = nodes[currentIndex]
guard current.kind == .original, current.projectIndex > 0 else { return currentIndex - 1 }
return nodes.firstIndex {
$0.projectIndex == current.projectIndex - 1 && $0.kind == .original
} ?? currentIndex - 1
}
/// 返回删除当前项目后应展示的项目索引;优先保持原索引以显示下一张,末项则回退上一张。
static func projectIndexAfterDeletion(
deletedProjectIndex: Int,
remainingProjectCount: Int
) -> Int? {
guard remainingProjectCount > 0 else { return nil }
return min(max(0, deletedProjectIndex), remainingProjectCount - 1)
}
}
/// 预览删除操作执行结果,统一表达成功、失败与暂不可用状态。
enum TravelAlbumPreviewActionResult: Sendable, Equatable {
case success(String?)
case failure(String)
case unavailable(String)
}
/// 预览页删除操作协议,以原素材项目 ID 为目标。
protocol TravelAlbumPreviewActionHandling: AnyObject {
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult
}
/// 预览删除接口接入前的占位操作实现,不修改任何业务数据。
final class PlaceholderTravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
.unavailable("项目删除接口待接入")
}
}
@@ -0,0 +1,79 @@
//
// BeforeAfterComparisonViewModel.swift
// suixinkan
//
import Foundation
/// 前后对比分隔线的共享运动参数与计算规则。
enum BeforeAfterComparisonMotion {
static let automaticCenter: CGFloat = 0.5
static let automaticAmplitude: CGFloat = 0.3
static let automaticSpeed: Double = 0.8
static let manualRange: ClosedRange<CGFloat> = 0.05 ... 0.95
/// 返回指定时间点的自动往返位置,运动范围固定为 20% 至 80%。
static func automaticFraction(elapsed: TimeInterval) -> CGFloat {
automaticCenter + CGFloat(sin(elapsed * automaticSpeed)) * automaticAmplitude
}
/// 将手动拖动位置限制在两侧 5% 的安全边界内。
static func clampedManualFraction(_ fraction: CGFloat) -> CGFloat {
min(manualRange.upperBound, max(manualRange.lowerBound, fraction))
}
/// 计算从当前位置恢复正向自动运动所需的正弦相位时间。
static func phaseTime(for fraction: CGFloat) -> TimeInterval {
let normalized = Double(
min(1, max(-1, (fraction - automaticCenter) / automaticAmplitude))
)
return asin(normalized) / automaticSpeed
}
}
/// 前后对比图片在可用区域内完整展示时的等比布局计算。
enum BeforeAfterComparisonLayout {
static let fallbackAspectRatio: CGFloat = 3.0 / 4.0
/// 从图片尺寸提取有效宽高比,异常尺寸回退为 3:4。
static func aspectRatio(for imageSize: CGSize) -> CGFloat {
guard imageSize.width > 0, imageSize.height > 0 else { return fallbackAspectRatio }
let ratio = imageSize.width / imageSize.height
return ratio.isFinite && ratio > 0 ? ratio : fallbackAspectRatio
}
/// 在最大宽高内返回不裁剪、不拉伸的最大显示尺寸。
static func fittedSize(aspectRatio: CGFloat, maximumSize: CGSize) -> CGSize {
guard maximumSize.width > 0, maximumSize.height > 0 else { return .zero }
let ratio = aspectRatio.isFinite && aspectRatio > 0 ? aspectRatio : fallbackAspectRatio
if maximumSize.width / maximumSize.height > ratio {
return CGSize(width: maximumSize.height * ratio, height: maximumSize.height)
}
return CGSize(width: maximumSize.width, height: maximumSize.width / ratio)
}
}
/// 前后图片对比页的只读状态,负责校验并解析两张远程图片地址。
final class BeforeAfterComparisonViewModel {
let content: BeforeAfterComparisonContent
/// 创建指定内容的前后对比页状态。
init(content: BeforeAfterComparisonContent) {
self.content = content
}
/// 原图远程地址。
var beforeImageURL: URL? {
URL(string: content.beforeURL)
}
/// 效果图远程地址。
var afterImageURL: URL? {
URL(string: content.afterURL)
}
/// 两张图片地址都可供页面加载时返回 true。
var isValid: Bool {
content.isValid && beforeImageURL != nil && afterImageURL != nil
}
}
@@ -0,0 +1,144 @@
import Foundation
/// AI 修图任务列表状态,负责筛选、游标分页、去重和静默刷新。
final class TravelAlbumAIJobListViewModel {
private(set) var items: [TravelAlbumAIJobSummary] = []
private(set) var selectedFilter: TravelAlbumAIJobFilter = .all
private(set) var isLoading = false
private(set) var isRefreshing = false
private(set) var isLoadingMore = false
private(set) var errorMessage: String?
private var nextCursor: String?
private var hasMore = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var containsInProgressJobs: Bool { items.contains { $0.status.isInProgress } }
var canLoadMore: Bool { hasMore && !isLoadingMore }
/// 选择筛选项并重新加载第一页。
func selectFilter(_ filter: TravelAlbumAIJobFilter, api: any TravelAlbumServing) async {
guard selectedFilter != filter else { return }
selectedFilter = filter
items = []
notify()
await loadFirstPage(api: api)
}
/// 首次加载或下拉刷新第一页。
func loadFirstPage(api: any TravelAlbumServing, refreshing: Bool = false, silent: Bool = false) async {
guard !isLoading && !isRefreshing else { return }
if refreshing { isRefreshing = true } else if !silent { isLoading = true }
errorMessage = nil
notify()
defer {
isLoading = false
isRefreshing = false
notify()
}
await request(cursor: nil, reset: true, api: api, allowCursorRecovery: false, silent: silent)
}
/// 加载下一页。
func loadMore(api: any TravelAlbumServing) async {
guard canLoadMore, let cursor = nextCursor else { return }
isLoadingMore = true
notify()
defer { isLoadingMore = false; notify() }
await request(cursor: cursor, reset: false, api: api, allowCursorRecovery: true, silent: false)
}
private func request(
cursor: String?,
reset: Bool,
api: any TravelAlbumServing,
allowCursorRecovery: Bool,
silent: Bool
) async {
do {
let response = try await api.aiRetouchJobList(
statusGroup: selectedFilter,
limit: 20,
cursor: cursor
)
if reset {
items = response.items
} else {
var known = Set(items.map(\.id))
items += response.items.filter { known.insert($0.id).inserted }
}
nextCursor = response.nextCursor
hasMore = response.hasMore && response.nextCursor?.isEmpty == false
errorMessage = nil
} catch is CancellationError {
return
} catch {
let message = error.localizedDescription
if allowCursorRecovery && message.localizedCaseInsensitiveContains("cursor") {
nextCursor = nil
hasMore = false
await request(cursor: nil, reset: true, api: api, allowCursorRecovery: false, silent: silent)
return
}
if items.isEmpty { errorMessage = message.isEmpty ? "任务加载失败" : message }
if !silent { onShowMessage?(message.isEmpty ? "任务加载失败" : message) }
}
}
private func notify() { onStateChange?() }
}
/// AI 修图任务详情状态,负责刷新、404 识别和终态判断。
final class TravelAlbumAIJobDetailViewModel {
private(set) var detail: TravelAlbumAIJobDetail?
private(set) var isLoading = false
private(set) var isRefreshing = false
private(set) var errorMessage: String?
private(set) var isNotFound = false
let batchId: Int
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onNotFound: (() -> Void)?
init(batchId: Int) { self.batchId = batchId }
var shouldPoll: Bool { detail?.status.isInProgress == true }
/// 拉取任务详情;静默刷新失败时保留现有内容。
func load(api: any TravelAlbumServing, refreshing: Bool = false, silent: Bool = false) async {
guard batchId > 0, !isLoading && !isRefreshing else {
if batchId <= 0 { markNotFound() }
return
}
if refreshing { isRefreshing = true } else if detail == nil && !silent { isLoading = true }
errorMessage = nil
notify()
defer {
isLoading = false
isRefreshing = false
notify()
}
do {
detail = try await api.aiRetouchJobInfo(batchId: batchId)
isNotFound = false
} catch is CancellationError {
return
} catch APIError.httpStatus(let status, _) where status == 404 {
markNotFound()
} catch {
let message = error.localizedDescription.isEmpty ? "任务详情加载失败" : error.localizedDescription
if detail == nil { errorMessage = message }
if !silent { onShowMessage?(message) }
}
}
private func markNotFound() {
guard !isNotFound else { return }
isNotFound = true
onNotFound?()
}
private func notify() { onStateChange?() }
}
@@ -0,0 +1,266 @@
//
// TravelAlbumAIRetouchTemplateViewModel.swift
// suixinkan
//
import Foundation
/// AI 修图模板选择状态,负责实时加载、按工作流单选、校验和任务提交。
final class TravelAlbumAIRetouchTemplateViewModel {
private(set) var refinedTemplates: [TravelAlbumAIRetouchTemplate] = []
private(set) var atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = []
private(set) var coverTemplates: [TravelAlbumAIRetouchTemplate] = []
private(set) var selectedRefinedTemplateId: Int?
private(set) var selectedAtmosphereTemplateId: Int?
private(set) var selectedCoverTemplateId: Int?
private(set) var remainingQuota: Int?
private(set) var isLoading = false
private(set) var isSubmitting = false
private(set) var loadErrorMessage: String?
let scenicId: Int
let workflow: TravelAlbumAIRetouchWorkflow
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onSubmitted: ((TravelAlbumAIJobSubmission) -> Void)?
/// 创建首次 AI 修图模板状态;素材 ID 会排序并去重,确保提交稳定。
convenience init(albumId: Int, scenicId: Int, materialIds: [Int]) {
self.init(
scenicId: scenicId,
workflow: .initial(albumId: albumId, materialIds: materialIds)
)
}
/// 使用明确工作流创建模板选择状态。
init(scenicId: Int, workflow: TravelAlbumAIRetouchWorkflow) {
self.scenicId = scenicId
switch workflow {
case .initial(let albumId, let materialIds):
self.workflow = .initial(
albumId: albumId,
materialIds: Array(Set(materialIds)).sorted()
)
case .reretouch:
self.workflow = workflow
}
}
/// 当前工作流需要展示的模板分组。
var visibleCategories: [TravelAlbumAIRetouchTemplateCategory] {
workflow.visibleCategories
}
/// 当前工作流选择的原始照片数量。
var selectedPhotoCount: Int {
switch workflow {
case .initial(_, let materialIds): materialIds.count
case .reretouch: 1
}
}
/// 是否展示首次修图的氛围感与封面生成说明。
var shouldShowInitialTips: Bool {
if case .initial = workflow { return true }
return false
}
/// 首次修图页固定展示的氛围感与封面生成规则说明。
var initialTipsText: String {
"Tips:氛围感修图为选填,可横向选择一种样式;选中后每张照片会额外生成1个独立结果,第一张照片仍另生成封面。"
}
/// 当前分组是否为选填。
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
workflow.isOptional(category)
}
/// 当前选择预计消耗的修图次数;封面模板免费,不计入额度。
var requiredQuota: Int {
switch workflow {
case .initial(_, let materialIds):
let outputCount = 1 + (selectedAtmosphereTemplateId == nil ? 0 : 1)
return materialIds.count * outputCount
case .reretouch(_, _, let type):
switch type {
case .refined, .atmosphere:
return 1
case .all:
return 1 + (selectedAtmosphereTemplateId == nil ? 0 : 1)
}
}
}
/// 当前必选模板或业务参数缺失时用于底部提示的文案。
var validationMessage: String? {
guard !isLoading, loadErrorMessage == nil else { return nil }
guard workflow.isValid else {
if case .reretouch = workflow {
return "当前图片缺少修图批次,请刷新后重试"
}
return "请选择要修图的照片"
}
for category in visibleCategories where !isOptional(category) {
if templates(for: category).isEmpty || selectedTemplateId(for: category) == nil {
return unavailableMessage(for: category)
}
}
guard let remainingQuota else {
return "剩余修图次数获取失败,请刷新后重试"
}
if requiredQuota > remainingQuota {
return "剩余修图次数不足,需要\(requiredQuota)次,当前剩余\(remainingQuota)次"
}
return nil
}
/// 是否满足提交条件。
var canSubmit: Bool {
!isLoading
&& !isSubmitting
&& loadErrorMessage == nil
&& workflow.isValid
&& validationMessage == nil
}
/// 实时拉取当前景区模板并按工作流设置默认选择。
func loadTemplates(api: any TravelAlbumServing) async {
guard scenicId > 0 else {
loadErrorMessage = "请先选择景区"
notifyStateChange()
return
}
guard !isLoading else { return }
isLoading = true
loadErrorMessage = nil
remainingQuota = nil
notifyStateChange()
do {
let response = try await api.aiRetouchTemplates(scenicId: scenicId)
refinedTemplates = response.refinedTemplates
atmosphereTemplates = response.atmosphereTemplates
coverTemplates = response.coverTemplates
remainingQuota = response.remainingQuota
selectedRefinedTemplateId = visibleCategories.contains(.refined) ? refinedTemplates.first?.id : nil
selectedAtmosphereTemplateId = visibleCategories.contains(.atmosphere) && !isOptional(.atmosphere)
? atmosphereTemplates.first?.id
: nil
selectedCoverTemplateId = visibleCategories.contains(.cover) ? coverTemplates.first?.id : nil
isLoading = false
notifyStateChange()
} catch is CancellationError {
isLoading = false
notifyStateChange()
} catch {
refinedTemplates = []
atmosphereTemplates = []
coverTemplates = []
selectedRefinedTemplateId = nil
selectedAtmosphereTemplateId = nil
selectedCoverTemplateId = nil
remainingQuota = nil
isLoading = false
loadErrorMessage = error.localizedDescription.isEmpty ? "模板加载失败" : error.localizedDescription
notifyStateChange()
}
}
/// 返回指定分组的模板。
func templates(for category: TravelAlbumAIRetouchTemplateCategory) -> [TravelAlbumAIRetouchTemplate] {
switch category {
case .refined: refinedTemplates
case .atmosphere: atmosphereTemplates
case .cover: coverTemplates
}
}
/// 返回指定分组当前选中的模板 ID。
func selectedTemplateId(for category: TravelAlbumAIRetouchTemplateCategory) -> Int? {
switch category {
case .refined: selectedRefinedTemplateId
case .atmosphere: selectedAtmosphereTemplateId
case .cover: selectedCoverTemplateId
}
}
/// 选择模板;仅选填分组允许再次点击取消,必选分组保持单选。
func toggleTemplate(id: Int, category: TravelAlbumAIRetouchTemplateCategory) {
guard visibleCategories.contains(category),
templates(for: category).contains(where: { $0.id == id })
else { return }
switch category {
case .refined:
selectedRefinedTemplateId = id
case .atmosphere:
selectedAtmosphereTemplateId = isOptional(category) && selectedAtmosphereTemplateId == id ? nil : id
case .cover:
selectedCoverTemplateId = id
}
notifyStateChange()
}
/// 按当前工作流提交首次修图或覆盖重修任务。
func submit(api: any TravelAlbumServing) async {
guard !isSubmitting else { return }
guard canSubmit else {
onShowMessage?(validationMessage ?? "当前无法提交AI修图")
return
}
isSubmitting = true
notifyStateChange()
defer {
isSubmitting = false
notifyStateChange()
}
do {
let submission: TravelAlbumAIJobSubmission
switch workflow {
case .initial(let albumId, let materialIds):
guard let refinedTemplateId = selectedRefinedTemplateId else {
onShowMessage?("请选择原图精修模板")
return
}
submission = try await api.submitAIRetouch(
TravelAlbumAIRetouchRequest(
userEquityTravelId: albumId,
materialIds: materialIds,
refinedTemplateId: refinedTemplateId,
atmosphereTemplateId: selectedAtmosphereTemplateId,
coverTemplateId: selectedCoverTemplateId
)
)
case .reretouch(let materialId, let batchId, let type):
submission = try await api.submitAIReretouch(
TravelAlbumAIReretouchRequest(
id: materialId,
aiRetouchBatchId: batchId,
type: type,
refinedTemplateId: type == .atmosphere ? nil : selectedRefinedTemplateId,
atmosphereTemplateId: type == .refined ? nil : selectedAtmosphereTemplateId
)
)
}
onSubmitted?(submission)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "AI修图任务提交失败" : error.localizedDescription)
}
}
private func unavailableMessage(for category: TravelAlbumAIRetouchTemplateCategory) -> String {
switch category {
case .refined: "暂无可用的原图精修模板"
case .atmosphere: "暂无可用的氛围感修图模板"
case .cover: "暂无可用的封面风格模板"
}
}
private func notifyStateChange() {
onStateChange?()
}
}
@@ -27,11 +27,21 @@ final class TravelAlbumDetailViewModel {
case .fileNameDesc: "文件名倒序" case .fileNameDesc: "文件名倒序"
} }
} }
var compactTitle: String {
switch self {
case .createdAsc: "时间 ↑"
case .createdDesc: "时间 ↓"
case .fileNameAsc: "名称 ↑"
case .fileNameDesc: "名称 ↓"
}
}
} }
private(set) var album: TravelAlbum? private(set) var album: TravelAlbum?
private(set) var materials: [TravelAlbumMaterial] = [] private(set) var materials: [TravelAlbumMaterial] = []
private(set) var allPhotoCount = 0 private(set) var allPhotoCount = 0
private(set) var purchasedPhotoCount = 0
private(set) var selectedTab: Tab = .all private(set) var selectedTab: Tab = .all
private(set) var sortOption: SortOption = .createdDesc private(set) var sortOption: SortOption = .createdDesc
private(set) var isLoading = true private(set) var isLoading = true
@@ -65,6 +75,7 @@ final class TravelAlbumDetailViewModel {
notifyStateChange() notifyStateChange()
await loadAlbumInfo(api: api) await loadAlbumInfo(api: api)
await loadAllPhotoCount(api: api) await loadAllPhotoCount(api: api)
await loadPurchasedPhotoCount(api: api)
await loadMaterials(reset: true, api: api) await loadMaterials(reset: true, api: api)
isRefreshing = false isRefreshing = false
isLoading = false isLoading = false
@@ -102,6 +113,35 @@ final class TravelAlbumDetailViewModel {
} }
} }
/// 拉取已购照片数量;失败时保留已有数量,不影响素材列表加载。
func loadPurchasedPhotoCount(api: any TravelAlbumServing) async {
do {
let response = try await api.materialList(
userEquityTravelId: albumId,
page: 1,
pageSize: 1,
orderBy: sortOption.rawValue,
isPurchased: 1
)
purchasedPhotoCount = response.total
notifyStateChange()
} catch is CancellationError {
return
} catch {
return
}
}
/// 摘要卡封面地址,依次回退相册封面、首张素材封面和首张素材原图。
var displayCoverURL: String {
TravelAlbumDisplayFormatter.albumCoverURL(album: album, materials: materials)
}
/// 当前 Tab 对应的原图项目总数,供全屏预览索引使用。
var currentPhotoCount: Int {
selectedTab == .all ? allPhotoCount : purchasedPhotoCount
}
/// 切换素材 tab。 /// 切换素材 tab。
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async { func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
guard selectedTab != tab else { return } guard selectedTab != tab else { return }
@@ -144,6 +184,8 @@ final class TravelAlbumDetailViewModel {
canLoadMore = materials.count < response.total canLoadMore = materials.count < response.total
if selectedTab == .all { if selectedTab == .all {
allPhotoCount = response.total allPhotoCount = response.total
} else {
purchasedPhotoCount = response.total
} }
isLoadingMore = false isLoadingMore = false
notifyStateChange() notifyStateChange()
@@ -165,6 +207,20 @@ final class TravelAlbumDetailViewModel {
} }
} }
/// 定向刷新单个素材,并同步替换网格列表中的对应缓存。
func refreshMaterial(id: Int, api: any TravelAlbumServing) async throws -> TravelAlbumMaterial {
let material = try await api.materialInfo(
userEquityTravelId: albumId,
materialId: id
)
guard material.id == id else { throw APIError.invalidResponse }
if let index = materials.firstIndex(where: { $0.id == id }) {
materials[index] = material
notifyStateChange()
}
return material
}
/// 切换选择模式。 /// 切换选择模式。
func toggleSelectionMode() { func toggleSelectionMode() {
guard selectedTab == .all else { return } guard selectedTab == .all else { return }
@@ -190,17 +246,34 @@ final class TravelAlbumDetailViewModel {
notifyStateChange() notifyStateChange()
} }
/// AI 修图任务提交成功后退出选择模式并清空当前选择。
func completeAIRetouchSubmission() {
isSelectionMode = false
selectedMaterialIds = []
notifyStateChange()
}
/// 预览页删除成功后移除对应素材,并同步全部/已购计数。
func removeMaterialAfterPreviewDeletion(id: Int) {
guard let index = materials.firstIndex(where: { $0.id == id }) else { return }
let material = materials.remove(at: index)
selectedMaterialIds.remove(id)
allPhotoCount = max(0, allPhotoCount - 1)
if material.isPurchased {
purchasedPhotoCount = max(0, purchasedPhotoCount - 1)
}
notifyStateChange()
}
/// 删除已选素材。 /// 删除已选素材。
func deleteSelectedMaterials(api: any TravelAlbumServing) async { func deleteSelectedMaterials(api: any TravelAlbumServing) async {
let ids = Array(selectedMaterialIds) let ids = selectedMaterialIds.sorted()
guard !ids.isEmpty else { guard !ids.isEmpty else {
onShowMessage?("请选择要删除的素材") onShowMessage?("请选择要删除的素材")
return return
} }
do { do {
for id in ids { try await api.batchDeleteMaterials(ids: ids)
try await api.deleteMaterial(id: id)
}
onShowMessage?("删除成功") onShowMessage?("删除成功")
isSelectionMode = false isSelectionMode = false
selectedMaterialIds = [] selectedMaterialIds = []
@@ -0,0 +1,29 @@
//
// TravelAlbumPreviewActionHandler.swift
// suixinkan
//
import Foundation
/// 图片预览页业务操作实现,负责将底部操作转发到旅拍相册服务。
final class TravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
private let api: any TravelAlbumServing
/// 使用旅拍相册服务创建操作处理器。
init(api: any TravelAlbumServing) {
self.api = api
}
/// 调用素材删除接口,仅在服务端确认成功后返回成功结果。
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
do {
try await api.batchDeleteMaterials(ids: [originalMaterialId])
return .success("删除成功")
} catch is CancellationError {
return .failure("删除已取消")
} catch {
let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
return .failure(message.isEmpty ? "删除失败" : message)
}
}
}
+36 -3
View File
@@ -8,6 +8,7 @@ import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate { class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow? var window: UIWindow?
private var sessionExpiredDialog: SessionExpiredDialogViewController?
func scene( func scene(
_ scene: UIScene, _ scene: UIScene,
@@ -86,18 +87,36 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
} }
@objc private func handleSessionDidExpire() { @objc private func handleSessionDidExpire() {
guard AppStore.shared.session.isLoggedIn else { return }
guard sessionExpiredDialog == nil else { return }
GlobalLoadingManager.shared.hideAll()
let dialog = SessionExpiredDialogViewController { [weak self] in
self?.transitionToLogin()
}
sessionExpiredDialog = dialog
guard let presenter = topViewController(from: window?.rootViewController) else {
sessionExpiredDialog = nil
return
}
presenter.present(dialog, animated: true)
}
private func transitionToLogin() {
sessionExpiredDialog = nil
PushNotificationManager.shared.handleLogout() PushNotificationManager.shared.handleLogout()
AppStore.shared.logout() AppStore.shared.logout()
AppRouter.setRoot(.login, on: window) AppRouter.setRoot(.login, on: window)
} }
@objc private func handleUserDidLogout() { @objc private func handleUserDidLogout() {
PushNotificationManager.shared.handleLogout() transitionToLogin()
AppStore.shared.logout()
AppRouter.setRoot(.login, on: window)
} }
@objc private func handleUserDidLogin() { @objc private func handleUserDidLogin() {
sessionExpiredDialog?.dismiss(animated: false)
sessionExpiredDialog = nil
AppRouter.setRoot(.mainTab, on: window) AppRouter.setRoot(.mainTab, on: window)
DispatchQueue.main.async { DispatchQueue.main.async {
PushNotificationManager.shared.handleLoginCompleted() PushNotificationManager.shared.handleLoginCompleted()
@@ -108,4 +127,18 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
@objc private func handleAccountDidSwitch() { @objc private func handleAccountDidSwitch() {
PushNotificationManager.shared.handleAccountSwitched() PushNotificationManager.shared.handleAccountSwitched()
} }
private func topViewController(from viewController: UIViewController?) -> UIViewController? {
guard let viewController else { return nil }
if let presented = viewController.presentedViewController {
return topViewController(from: presented)
}
if let navigationController = viewController as? UINavigationController {
return topViewController(from: navigationController.visibleViewController)
}
if let tabBarController = viewController as? UITabBarController {
return topViewController(from: tabBarController.selectedViewController)
}
return viewController
}
} }
@@ -0,0 +1,103 @@
//
// SessionExpiredDialogViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// 登录凭证失效时展示的强制确认弹窗,用户确认后由调用方执行退出登录。
@MainActor
final class SessionExpiredDialogViewController: UIViewController {
private let onConfirm: () -> Void
private let confirmButton = AppButton(title: "确认并退出登录")
private var didConfirm = false
/// 创建不可通过点击遮罩关闭的登录失效弹窗。
init(onConfirm: @escaping () -> Void) {
self.onConfirm = onConfirm
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
isModalInPresentation = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
private func setupUI() {
view.backgroundColor = UIColor.black.withAlphaComponent(0.32)
view.accessibilityViewIsModal = true
view.accessibilityIdentifier = "sessionExpired.dialog"
let cardView = UIView()
cardView.backgroundColor = AppColor.cardBackground
cardView.layer.cornerRadius = AppRadius.lg
cardView.clipsToBounds = true
let warningImageView = UIImageView(
image: UIImage(systemName: "exclamationmark.circle.fill")
)
warningImageView.tintColor = UIColor(hex: 0xF05A5A)
warningImageView.contentMode = .scaleAspectFit
warningImageView.isAccessibilityElement = true
warningImageView.accessibilityLabel = "登录状态异常"
let titleLabel = UILabel()
titleLabel.text = "登录提醒"
titleLabel.textColor = AppColor.textPrimary
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
titleLabel.textAlignment = .center
let messageLabel = UILabel()
messageLabel.text = "当前用户已在其他设备登录,\n请重新登录"
messageLabel.textColor = UIColor(hex: 0x4B5563)
messageLabel.font = .systemFont(ofSize: 14)
messageLabel.textAlignment = .center
messageLabel.numberOfLines = 0
confirmButton.accessibilityIdentifier = "sessionExpired.confirmButton"
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
let contentStack = UIStackView(arrangedSubviews: [
warningImageView,
titleLabel,
messageLabel,
confirmButton,
])
contentStack.axis = .vertical
contentStack.alignment = .fill
contentStack.spacing = 16
contentStack.setCustomSpacing(4, after: titleLabel)
contentStack.setCustomSpacing(20, after: messageLabel)
view.addSubview(cardView)
cardView.addSubview(contentStack)
cardView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(32)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(24)
}
warningImageView.snp.makeConstraints { make in
make.height.equalTo(64)
}
}
@objc private func confirmTapped() {
guard !didConfirm else { return }
didConfirm = true
confirmButton.isEnabled = false
onConfirm()
}
}
@@ -81,6 +81,10 @@ final class MainTabBarController: UITabBarController {
controller = PaymentCollectionDetailsViewController() controller = PaymentCollectionDetailsViewController()
case .messageCenter: case .messageCenter:
controller = MessageCenterViewController() controller = MessageCenterViewController()
case .aiRetouchTaskList:
controller = TravelAlbumAIJobListViewController()
case .aiRetouchTaskDetail(let batchId):
controller = TravelAlbumAIJobDetailViewController(batchId: batchId)
} }
if let controller { if let controller {
@@ -12,6 +12,7 @@ final class MessageDetailViewController: BaseViewController {
private let viewModel: MessageDetailViewModel private let viewModel: MessageDetailViewModel
private let api: any MessageCenterServing private let api: any MessageCenterServing
private let travelAlbumAPI: any TravelAlbumServing
private let scrollView = UIScrollView() private let scrollView = UIScrollView()
private let contentView = UIView() private let contentView = UIView()
@@ -20,13 +21,19 @@ final class MessageDetailViewController: BaseViewController {
private let timeContainer = UIView() private let timeContainer = UIView()
private let timeLabel = UILabel() private let timeLabel = UILabel()
private let bodyLabel = UILabel() private let bodyLabel = UILabel()
private let taskDetailButton = UIButton(type: .system)
private let bottomBar = UIView() private let bottomBar = UIView()
private let deleteButton = UIButton(type: .system) private let deleteButton = UIButton(type: .system)
/// 初始化消息详情页。 /// 初始化消息详情页。
init(message: MessageItem, api: (any MessageCenterServing)? = nil) { init(
message: MessageItem,
api: (any MessageCenterServing)? = nil,
travelAlbumAPI: (any TravelAlbumServing)? = nil
) {
viewModel = MessageDetailViewModel(message: message) viewModel = MessageDetailViewModel(message: message)
self.api = api ?? NetworkServices.shared.messageCenterAPI self.api = api ?? NetworkServices.shared.messageCenterAPI
self.travelAlbumAPI = travelAlbumAPI ?? NetworkServices.shared.travelAlbumAPI
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
@@ -67,6 +74,15 @@ final class MessageDetailViewController: BaseViewController {
bodyLabel.numberOfLines = 0 bodyLabel.numberOfLines = 0
bodyLabel.textAlignment = .left bodyLabel.textAlignment = .left
taskDetailButton.setTitle("查看任务详情", for: .normal)
taskDetailButton.setTitleColor(.white, for: .normal)
taskDetailButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
taskDetailButton.backgroundColor = AppColor.primary
taskDetailButton.layer.cornerRadius = 10
taskDetailButton.clipsToBounds = true
taskDetailButton.isHidden = !viewModel.showsAIRetouchTaskAction
taskDetailButton.accessibilityLabel = "查看AI修图任务详情"
bottomBar.backgroundColor = .white bottomBar.backgroundColor = .white
deleteButton.setTitle("删除并返回", for: .normal) deleteButton.setTitle("删除并返回", for: .normal)
deleteButton.setTitleColor(.white, for: .normal) deleteButton.setTitleColor(.white, for: .normal)
@@ -77,7 +93,7 @@ final class MessageDetailViewController: BaseViewController {
view.addSubview(scrollView) view.addSubview(scrollView)
scrollView.addSubview(contentView) scrollView.addSubview(contentView)
[typeImageView, titleLabel, timeContainer, bodyLabel].forEach(contentView.addSubview) [typeImageView, titleLabel, timeContainer, bodyLabel, taskDetailButton].forEach(contentView.addSubview)
timeContainer.addSubview(timeLabel) timeContainer.addSubview(timeLabel)
view.addSubview(bottomBar) view.addSubview(bottomBar)
bottomBar.addSubview(deleteButton) bottomBar.addSubview(deleteButton)
@@ -121,12 +137,23 @@ final class MessageDetailViewController: BaseViewController {
bodyLabel.snp.makeConstraints { make in bodyLabel.snp.makeConstraints { make in
make.top.equalTo(timeContainer.snp.bottom).offset(24) make.top.equalTo(timeContainer.snp.bottom).offset(24)
make.leading.trailing.equalToSuperview().inset(32) make.leading.trailing.equalToSuperview().inset(32)
make.bottom.lessThanOrEqualToSuperview().inset(24) if !viewModel.showsAIRetouchTaskAction {
make.bottom.lessThanOrEqualToSuperview().inset(24)
}
}
if viewModel.showsAIRetouchTaskAction {
taskDetailButton.snp.makeConstraints { make in
make.top.equalTo(bodyLabel.snp.bottom).offset(24)
make.leading.trailing.equalToSuperview().inset(32)
make.height.equalTo(52)
make.bottom.lessThanOrEqualToSuperview().inset(24)
}
} }
} }
override func bindActions() { override func bindActions() {
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
taskDetailButton.addTarget(self, action: #selector(taskDetailTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in viewModel.onStateChange = { [weak self] in
Task { @MainActor in Task { @MainActor in
self?.applyState() self?.applyState()
@@ -145,6 +172,16 @@ final class MessageDetailViewController: BaseViewController {
} }
} }
@objc private func taskDetailTapped() {
let target: UIViewController
if let batchId = viewModel.aiRetouchBatchId {
target = TravelAlbumAIJobDetailViewController(batchId: batchId, api: travelAlbumAPI)
} else {
target = TravelAlbumAIJobListViewController(api: travelAlbumAPI)
}
navigationController?.pushViewController(target, animated: true)
}
@objc private func deleteTapped() { @objc private func deleteTapped() {
let alert = UIAlertController(title: "删除消息", message: "确定要删除这条消息吗?", preferredStyle: .alert) let alert = UIAlertController(title: "删除消息", message: "确定要删除这条消息吗?", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "取消", style: .cancel))
@@ -28,6 +28,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private let qrContainerView = UIView() private let qrContainerView = UIView()
private let qrImageView = UIImageView() private let qrImageView = UIImageView()
private let qrPlaceholderView = UIView() private let qrPlaceholderView = UIView()
private let qrLoadingIndicator = UIActivityIndicatorView(style: .medium)
private let qrErrorLabel = UILabel() private let qrErrorLabel = UILabel()
private let refreshButton = UIButton(type: .system) private let refreshButton = UIButton(type: .system)
private let setAmountButton = UIButton(type: .system) private let setAmountButton = UIButton(type: .system)
@@ -127,6 +128,10 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24 qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24
qrPlaceholderView.isHidden = true qrPlaceholderView.isHidden = true
qrLoadingIndicator.color = AppColor.primary
qrLoadingIndicator.hidesWhenStopped = true
qrLoadingIndicator.accessibilityLabel = "收款码加载中"
qrErrorLabel.text = "未选景区或网络问题" qrErrorLabel.text = "未选景区或网络问题"
qrErrorLabel.font = .systemFont(ofSize: 14) qrErrorLabel.font = .systemFont(ofSize: 14)
qrErrorLabel.textColor = AppColor.danger qrErrorLabel.textColor = AppColor.danger
@@ -150,6 +155,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
trailing: 24 trailing: 24
) )
} }
actionRow.alpha = 0
actionRow.isUserInteractionEnabled = false
actionRow.accessibilityElementsHidden = true
configureLinkButton(setAmountButton, title: "设置金额") configureLinkButton(setAmountButton, title: "设置金额")
configureLinkButton(saveQRButton, title: "保存二维码") configureLinkButton(saveQRButton, title: "保存二维码")
@@ -206,6 +214,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg
qrCardView.addSubview(qrContentStack) qrCardView.addSubview(qrContentStack)
qrPlaceholderView.addSubview(qrLoadingIndicator)
qrPlaceholderView.addSubview(qrErrorLabel) qrPlaceholderView.addSubview(qrErrorLabel)
qrPlaceholderView.addSubview(refreshButton) qrPlaceholderView.addSubview(refreshButton)
@@ -275,6 +284,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
qrPlaceholderView.snp.makeConstraints { make in qrPlaceholderView.snp.makeConstraints { make in
make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182) make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182)
} }
qrLoadingIndicator.snp.makeConstraints { make in
make.center.equalToSuperview()
}
qrErrorLabel.snp.makeConstraints { make in qrErrorLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(56) make.top.equalToSuperview().offset(56)
make.leading.trailing.equalToSuperview().inset(AppSpacing.sm) make.leading.trailing.equalToSuperview().inset(AppSpacing.sm)
@@ -370,9 +382,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private func loadData() async { private func loadData() async {
async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI) async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI)
showLoading()
await viewModel.loadPayCode(api: paymentAPI) await viewModel.loadPayCode(api: paymentAPI)
hideLoading()
await pageConfig await pageConfig
} }
@@ -387,17 +397,33 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen
applyBrandConfigIfNeeded() applyBrandConfigIfNeeded()
if let image = viewModel.qrImage { if viewModel.isPayCodeLoading {
qrImageView.isHidden = true
qrContainerView.isHidden = true
qrPlaceholderView.backgroundColor = viewModel.usesNalatiBranding
? .white
: AppColor.inputBackground
qrPlaceholderView.isHidden = false
qrErrorLabel.isHidden = true
refreshButton.isHidden = true
qrLoadingIndicator.startAnimating()
setActionRowAvailable(false)
} else if let image = viewModel.qrImage {
qrImageView.image = image qrImageView.image = image
qrImageView.isHidden = false qrImageView.isHidden = false
qrContainerView.isHidden = !viewModel.usesNalatiBranding qrContainerView.isHidden = !viewModel.usesNalatiBranding
qrPlaceholderView.isHidden = true qrPlaceholderView.isHidden = true
actionRow.isHidden = false qrLoadingIndicator.stopAnimating()
setActionRowAvailable(true)
} else { } else {
qrImageView.isHidden = true qrImageView.isHidden = true
qrContainerView.isHidden = true qrContainerView.isHidden = true
qrPlaceholderView.backgroundColor = AppColor.inputBackground
qrPlaceholderView.isHidden = false qrPlaceholderView.isHidden = false
actionRow.isHidden = true qrErrorLabel.isHidden = false
refreshButton.isHidden = false
qrLoadingIndicator.stopAnimating()
setActionRowAvailable(false)
} }
if viewModel.showAmountDialog { if viewModel.showAmountDialog {
@@ -408,6 +434,12 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
} }
} }
private func setActionRowAvailable(_ isAvailable: Bool) {
actionRow.alpha = isAvailable ? 1 : 0
actionRow.isUserInteractionEnabled = isAvailable
actionRow.accessibilityElementsHidden = !isAvailable
}
private func presentAmountDialogIfNeeded() { private func presentAmountDialogIfNeeded() {
guard amountDialog == nil else { guard amountDialog == nil else {
amountDialog?.apply(amount: viewModel.amount, remark: viewModel.remark) amountDialog?.apply(amount: viewModel.amount, remark: viewModel.remark)
@@ -625,9 +657,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
@objc private func refreshTapped() { @objc private func refreshTapped() {
Task { Task {
showLoading()
await viewModel.refresh(api: paymentAPI) await viewModel.refresh(api: paymentAPI)
hideLoading()
} }
} }
@@ -38,6 +38,11 @@ final class ProfileViewController: BaseViewController {
title = "我的" title = "我的"
} }
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
}
override func setupUI() { override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF7FAFF) view.backgroundColor = UIColor(hex: 0xF7FAFF)
@@ -104,12 +109,10 @@ final class ProfileViewController: BaseViewController {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
let showLoading = !hasLoadedProfileOnce let showLoading = !hasLoadedProfileOnce && !viewModel.hasCachedBasicInfo
Task { Task {
await reloadProfile(showGlobalLoading: showLoading) await reloadProfile(showGlobalLoading: showLoading)
if showLoading { hasLoadedProfileOnce = true
hasLoadedProfileOnce = true
}
} }
} }
@@ -0,0 +1,520 @@
//
// BeforeAfterComparisonViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// 暗色全屏前后图片对比页,通过可拖拽分隔线查看原图与效果图。
final class BeforeAfterComparisonViewController: UIViewController {
private let viewModel: BeforeAfterComparisonViewModel
private let backButton = UIButton(type: .system)
private let comparisonContainer = UIView()
private let afterImageView = UIImageView()
private let beforeImageView = UIImageView()
private let beforeRevealMask = CAShapeLayer()
private let dividerLine = UIView()
private let dividerHandle = BeforeAfterComparisonHandleView()
private let beforeLabel = BeforeAfterComparisonLabel()
private let afterLabel = BeforeAfterComparisonLabel()
private let statusContainer = UIView()
private let activityIndicator = UIActivityIndicatorView(style: .large)
private let statusLabel = UILabel()
private let retryButton = UIButton(type: .system)
private var dividerFraction: CGFloat = 0.5
private var loadGeneration = 0
private var beforeLoaded = false
private var afterLoaded = false
private var isReady = false
private var displayLink: CADisplayLink?
private var animationStartTime: CFTimeInterval?
private var isDraggingDivider = false
private var imageAspectRatio = BeforeAfterComparisonLayout.fallbackAspectRatio
private var beforeImageSize: CGSize?
private var afterImageSize: CGSize?
/// 创建指定前后图片内容的全屏对比页。
init(viewModel: BeforeAfterComparisonViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent }
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupConstraints()
bindActions()
loadImages()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
layoutComparisonContainer()
layoutDivider()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
startAutomaticMotion(from: dividerFraction)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
stopAutomaticMotion()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
displayLink?.invalidate()
displayLink = nil
}
deinit {
displayLink?.invalidate()
NotificationCenter.default.removeObserver(self)
}
private func setupUI() {
view.backgroundColor = UIColor(hex: 0x05070D)
view.accessibilityIdentifier = "travelAlbum.beforeAfterComparison"
var backConfiguration = UIButton.Configuration.filled()
backConfiguration.image = UIImage(systemName: "chevron.left")
backConfiguration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(
pointSize: 19,
weight: .semibold
)
backConfiguration.baseForegroundColor = .white
backConfiguration.baseBackgroundColor = UIColor.white.withAlphaComponent(0.12)
backConfiguration.background.cornerRadius = 24
backConfiguration.contentInsets = .zero
backButton.configuration = backConfiguration
backButton.accessibilityLabel = "返回"
backButton.accessibilityIdentifier = "travelAlbum.beforeAfterBackButton"
comparisonContainer.backgroundColor = UIColor(hex: 0x111827)
comparisonContainer.layer.cornerRadius = 0
comparisonContainer.layer.borderColor = UIColor(hex: 0x263244).cgColor
comparisonContainer.layer.borderWidth = 1
comparisonContainer.clipsToBounds = true
comparisonContainer.accessibilityIdentifier = "travelAlbum.beforeAfterImageContainer"
[afterImageView, beforeImageView].forEach { imageView in
imageView.contentMode = .scaleAspectFit
imageView.clipsToBounds = true
imageView.backgroundColor = UIColor(hex: 0x111827)
imageView.isHidden = true
}
afterImageView.accessibilityLabel = viewModel.content.afterLabel
beforeImageView.accessibilityLabel = viewModel.content.beforeLabel
beforeImageView.layer.mask = beforeRevealMask
dividerLine.backgroundColor = UIColor.white.withAlphaComponent(0.72)
dividerLine.layer.shadowColor = UIColor.black.cgColor
dividerLine.layer.shadowOpacity = 0.35
dividerLine.layer.shadowRadius = 2
dividerLine.layer.shadowOffset = .zero
dividerLine.isHidden = true
dividerLine.isUserInteractionEnabled = false
dividerHandle.isHidden = true
dividerHandle.accessibilityIdentifier = "travelAlbum.beforeAfterDivider"
beforeLabel.text = viewModel.content.beforeLabel
beforeLabel.accessibilityIdentifier = "travelAlbum.beforeLabel"
afterLabel.text = viewModel.content.afterLabel
afterLabel.accessibilityIdentifier = "travelAlbum.afterLabel"
beforeLabel.isHidden = true
afterLabel.isHidden = true
statusContainer.backgroundColor = UIColor(hex: 0x111827).withAlphaComponent(0.86)
activityIndicator.color = .white
activityIndicator.hidesWhenStopped = true
statusLabel.textColor = UIColor.white.withAlphaComponent(0.78)
statusLabel.font = .systemFont(ofSize: 14, weight: .medium)
statusLabel.textAlignment = .center
statusLabel.numberOfLines = 0
statusLabel.text = "正在加载对比图片"
statusLabel.accessibilityIdentifier = "travelAlbum.beforeAfterStatusLabel"
var retryConfiguration = UIButton.Configuration.filled()
retryConfiguration.title = "重试"
retryConfiguration.baseForegroundColor = .white
retryConfiguration.baseBackgroundColor = UIColor(hex: 0x1677FF)
retryConfiguration.background.cornerRadius = 12
retryButton.configuration = retryConfiguration
retryButton.accessibilityIdentifier = "travelAlbum.beforeAfterRetryButton"
retryButton.isHidden = true
view.addSubview(backButton)
view.addSubview(comparisonContainer)
comparisonContainer.addSubview(afterImageView)
comparisonContainer.addSubview(beforeImageView)
comparisonContainer.addSubview(dividerLine)
comparisonContainer.addSubview(dividerHandle)
comparisonContainer.addSubview(beforeLabel)
comparisonContainer.addSubview(afterLabel)
comparisonContainer.addSubview(statusContainer)
statusContainer.addSubview(activityIndicator)
statusContainer.addSubview(statusLabel)
statusContainer.addSubview(retryButton)
}
private func setupConstraints() {
backButton.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(20)
make.leading.equalToSuperview().offset(18)
make.size.equalTo(48)
}
afterImageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
beforeImageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
beforeLabel.snp.makeConstraints { make in
make.leading.bottom.equalToSuperview().inset(10)
make.height.equalTo(26)
}
afterLabel.snp.makeConstraints { make in
make.trailing.bottom.equalToSuperview().inset(10)
make.height.equalTo(26)
}
statusContainer.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
activityIndicator.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalToSuperview().offset(-28)
}
statusLabel.snp.makeConstraints { make in
make.top.equalTo(activityIndicator.snp.bottom).offset(14)
make.leading.trailing.equalToSuperview().inset(32)
}
retryButton.snp.makeConstraints { make in
make.top.equalTo(statusLabel.snp.bottom).offset(14)
make.centerX.equalToSuperview()
make.width.equalTo(96)
make.height.equalTo(44)
}
}
private func bindActions() {
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
let scrubberGesture = UILongPressGestureRecognizer(target: self, action: #selector(dividerDragged(_:)))
scrubberGesture.minimumPressDuration = 0
scrubberGesture.allowableMovement = .greatestFiniteMagnitude
comparisonContainer.addGestureRecognizer(scrubberGesture)
dividerHandle.onIncrement = { [weak self] in self?.adjustDivider(by: 0.1) }
dividerHandle.onDecrement = { [weak self] in self?.adjustDivider(by: -0.1) }
NotificationCenter.default.addObserver(
self,
selector: #selector(reduceMotionStatusChanged),
name: UIAccessibility.reduceMotionStatusDidChangeNotification,
object: nil
)
}
private func loadImages() {
loadGeneration += 1
let generation = loadGeneration
beforeImageView.kf.cancelDownloadTask()
afterImageView.kf.cancelDownloadTask()
beforeImageView.image = nil
afterImageView.image = nil
beforeLoaded = false
afterLoaded = false
beforeImageSize = nil
afterImageSize = nil
imageAspectRatio = BeforeAfterComparisonLayout.fallbackAspectRatio
isReady = false
dividerFraction = BeforeAfterComparisonMotion.automaticCenter
stopAutomaticMotion()
setComparisonVisible(false)
statusContainer.isHidden = false
statusLabel.text = "正在加载对比图片"
retryButton.isHidden = true
activityIndicator.startAnimating()
guard viewModel.isValid,
let beforeURL = viewModel.beforeImageURL,
let afterURL = viewModel.afterImageURL
else {
showLoadFailure()
return
}
beforeImageView.kf.setImage(with: beforeURL) { [weak self] result in
self?.handleImageResult(result, isBefore: true, generation: generation)
}
afterImageView.kf.setImage(with: afterURL) { [weak self] result in
self?.handleImageResult(result, isBefore: false, generation: generation)
}
}
private func handleImageResult(
_ result: Result<RetrieveImageResult, KingfisherError>,
isBefore: Bool,
generation: Int
) {
guard generation == loadGeneration else { return }
switch result {
case let .success(value):
if isBefore {
beforeLoaded = true
beforeImageSize = value.image.size
} else {
afterLoaded = true
afterImageSize = value.image.size
}
guard beforeLoaded && afterLoaded else { return }
let resolvedSize = afterImageSize ?? beforeImageSize ?? .zero
imageAspectRatio = BeforeAfterComparisonLayout.aspectRatio(for: resolvedSize)
view.setNeedsLayout()
view.layoutIfNeeded()
isReady = true
activityIndicator.stopAnimating()
statusContainer.isHidden = true
setComparisonVisible(true)
layoutDivider()
startAutomaticMotion(from: dividerFraction)
UIAccessibility.post(notification: .announcement, argument: "对比图片已加载")
case .failure:
showLoadFailure()
}
}
private func showLoadFailure() {
isReady = false
activityIndicator.stopAnimating()
statusContainer.isHidden = false
statusLabel.text = "图片加载失败,请重试"
retryButton.isHidden = false
setComparisonVisible(false)
UIAccessibility.post(notification: .announcement, argument: statusLabel.text)
}
private func setComparisonVisible(_ visible: Bool) {
afterImageView.isHidden = !visible
beforeImageView.isHidden = !visible
dividerLine.isHidden = !visible
dividerHandle.isHidden = !visible
beforeLabel.isHidden = !visible
afterLabel.isHidden = !visible
}
private func layoutComparisonContainer() {
let safeFrame = view.bounds.inset(by: view.safeAreaInsets)
let topBoundary = max(safeFrame.minY, backButton.frame.maxY + 24)
let bottomBoundary = safeFrame.maxY - 24
let center = CGPoint(x: view.bounds.midX, y: safeFrame.midY)
let verticalRadius = max(
1,
min(center.y - topBoundary, bottomBoundary - center.y)
)
let maximumSize = CGSize(
width: max(1, view.bounds.width),
height: verticalRadius * 2
)
let fittedSize = BeforeAfterComparisonLayout.fittedSize(
aspectRatio: imageAspectRatio,
maximumSize: maximumSize
)
comparisonContainer.frame = CGRect(
x: center.x - fittedSize.width / 2,
y: center.y - fittedSize.height / 2,
width: fittedSize.width,
height: fittedSize.height
)
comparisonContainer.layoutIfNeeded()
}
private func layoutDivider() {
guard comparisonContainer.bounds.width > 0 else { return }
let bounds = comparisonContainer.bounds
let x = bounds.width * dividerFraction
beforeRevealMask.frame = beforeImageView.bounds
beforeRevealMask.path = UIBezierPath(
rect: CGRect(x: 0, y: 0, width: x, height: bounds.height)
).cgPath
dividerLine.frame = CGRect(x: x - 1.5, y: 0, width: 3, height: bounds.height)
dividerHandle.frame = CGRect(x: x - 24, y: bounds.midY - 24, width: 48, height: 48)
dividerHandle.accessibilityValue = "原图占比 \(Int((dividerFraction * 100).rounded()))%"
}
private func updateDivider(locationX: CGFloat) {
guard isReady, comparisonContainer.bounds.width > 0 else { return }
dividerFraction = BeforeAfterComparisonMotion.clampedManualFraction(
locationX / comparisonContainer.bounds.width
)
layoutDivider()
}
private func adjustDivider(by delta: CGFloat) {
stopAutomaticMotion()
dividerFraction = BeforeAfterComparisonMotion.clampedManualFraction(dividerFraction + delta)
layoutDivider()
}
@objc private func dividerDragged(_ gesture: UILongPressGestureRecognizer) {
switch gesture.state {
case .began:
isDraggingDivider = true
stopAutomaticMotion()
dividerHandle.setActive(true, animated: !UIAccessibility.isReduceMotionEnabled)
updateDivider(locationX: gesture.location(in: comparisonContainer).x)
case .changed:
updateDivider(locationX: gesture.location(in: comparisonContainer).x)
case .ended, .cancelled, .failed:
updateDivider(locationX: gesture.location(in: comparisonContainer).x)
isDraggingDivider = false
dividerHandle.setActive(false, animated: !UIAccessibility.isReduceMotionEnabled)
startAutomaticMotion(from: dividerFraction)
default:
break
}
}
@objc private func automaticMotionTick(_ link: CADisplayLink) {
guard isReady, !isDraggingDivider, let animationStartTime else { return }
dividerFraction = BeforeAfterComparisonMotion.automaticFraction(
elapsed: link.timestamp - animationStartTime
)
layoutDivider()
}
private func startAutomaticMotion(from fraction: CGFloat) {
guard isReady,
viewIfLoaded?.window != nil,
!isDraggingDivider,
!UIAccessibility.isReduceMotionEnabled
else { return }
animationStartTime = CACurrentMediaTime() - BeforeAfterComparisonMotion.phaseTime(for: fraction)
if displayLink == nil {
let link = CADisplayLink(target: self, selector: #selector(automaticMotionTick(_:)))
link.preferredFrameRateRange = CAFrameRateRange(minimum: 30, maximum: 60, preferred: 60)
link.add(to: .main, forMode: .common)
displayLink = link
}
displayLink?.isPaused = false
}
private func stopAutomaticMotion() {
displayLink?.isPaused = true
animationStartTime = nil
}
@objc private func reduceMotionStatusChanged() {
if UIAccessibility.isReduceMotionEnabled {
stopAutomaticMotion()
} else {
startAutomaticMotion(from: dividerFraction)
}
}
@objc private func backTapped() {
dismiss(animated: true)
}
@objc private func retryTapped() {
loadImages()
}
}
/// 前后对比分隔线的可调节圆形手柄。
private final class BeforeAfterComparisonHandleView: UIView {
var onIncrement: (() -> Void)?
var onDecrement: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor.black.withAlphaComponent(0.38)
layer.cornerRadius = 24
layer.borderColor = UIColor.white.withAlphaComponent(0.82).cgColor
layer.borderWidth = 2
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.32
layer.shadowRadius = 7
layer.shadowOffset = CGSize(width: 0, height: 3)
isAccessibilityElement = true
accessibilityLabel = "前后对比分隔线"
accessibilityTraits = [.adjustable]
let imageView = UIImageView(image: UIImage(systemName: "arrow.left.and.right"))
imageView.tintColor = .white
imageView.contentMode = .scaleAspectFit
addSubview(imageView)
imageView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(21)
}
}
/// 更新手柄按压态,为拖动接管提供轻微但不改变布局的视觉反馈。
func setActive(_ active: Bool, animated: Bool) {
let changes = {
self.transform = active ? CGAffineTransform(scaleX: 1.08, y: 1.08) : .identity
self.backgroundColor = UIColor.black.withAlphaComponent(active ? 0.52 : 0.38)
}
guard animated else {
changes()
return
}
UIView.animate(
withDuration: 0.18,
delay: 0,
options: [.curveEaseOut, .beginFromCurrentState],
animations: changes
)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func accessibilityIncrement() {
onIncrement?()
}
override func accessibilityDecrement() {
onDecrement?()
}
}
/// 前后对比图片底部的高对比度胶囊角标。
private final class BeforeAfterComparisonLabel: UILabel {
override init(frame: CGRect) {
super.init(frame: frame)
textColor = .white
font = .systemFont(ofSize: 12, weight: .medium)
textAlignment = .center
backgroundColor = UIColor.black.withAlphaComponent(0.5)
layer.cornerRadius = 13
layer.borderColor = UIColor.white.withAlphaComponent(0.2).cgColor
layer.borderWidth = 0.5
clipsToBounds = true
setContentHuggingPriority(.required, for: .horizontal)
layoutMargins = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
return CGSize(width: size.width + 20, height: 26)
}
}
@@ -0,0 +1,903 @@
import Kingfisher
import SnapKit
import UIKit
/// AI 修图任务详情页,按设计稿展示状态总览、相册信息、任务内容与逐输出处理明细。
final class TravelAlbumAIJobDetailViewController: BaseViewController {
private let viewModel: TravelAlbumAIJobDetailViewModel
private let api: any TravelAlbumServing
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let refreshControl = UIRefreshControl()
private let emptyView = AIJobDetailEmptyView()
private var pollingTask: Task<Void, Never>?
private var isVisible = false
private var isPresentingLoading = false
/// 创建指定批次的任务详情。
init(batchId: Int, api: (any TravelAlbumServing)? = nil) {
viewModel = TravelAlbumAIJobDetailViewModel(batchId: batchId)
self.api = api ?? NetworkServices.shared.travelAlbumAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func setupNavigationBar() {
let titleLabel = UILabel()
titleLabel.text = "任务详情"
titleLabel.textColor = AIJobDetailStyle.textPrimary
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
navigationItem.titleView = titleLabel
navigationItem.backButtonDisplayMode = .minimal
navigationItem.rightBarButtonItem = UIBarButtonItem(
image: UIImage(systemName: "arrow.clockwise"),
style: .plain,
target: self,
action: #selector(manualRefresh)
)
navigationItem.rightBarButtonItem?.tintColor = AIJobDetailStyle.textPrimary
navigationItem.rightBarButtonItem?.accessibilityLabel = "刷新任务详情"
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = .white
appearance.shadowColor = .clear
navigationItem.standardAppearance = appearance
navigationItem.scrollEdgeAppearance = appearance
navigationItem.compactAppearance = appearance
navigationController?.setNavigationBarHidden(false, animated: false)
}
override func setupUI() {
view.backgroundColor = AIJobDetailStyle.pageBackground
scrollView.backgroundColor = .clear
scrollView.alwaysBounceVertical = true
scrollView.refreshControl = refreshControl
contentStack.axis = .vertical
contentStack.spacing = 14
emptyView.onRetry = { [weak self] in self?.reload() }
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
view.addSubview(emptyView)
}
override func setupConstraints() {
scrollView.snp.makeConstraints { $0.edges.equalToSuperview() }
contentStack.snp.makeConstraints { make in
make.top.equalTo(scrollView.contentLayoutGuide).offset(12)
make.leading.trailing.equalTo(scrollView.frameLayoutGuide).inset(18)
make.bottom.equalTo(scrollView.contentLayoutGuide).inset(24)
}
emptyView.snp.makeConstraints { $0.edges.equalToSuperview() }
}
override func bindActions() {
refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } }
viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } }
viewModel.onNotFound = { [weak self] in Task { @MainActor in self?.presentNotFound() } }
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationBecameActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationEnteredBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.load(api: api) }
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
isVisible = true
updateLoadingPresentation()
updatePolling()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
isVisible = false
setLoadingPresented(false)
stopPolling()
}
deinit {
pollingTask?.cancel()
NotificationCenter.default.removeObserver(self)
}
@MainActor
private func applyState() {
refreshControl.endRefreshing()
let unavailable = viewModel.detail == nil && !viewModel.isLoading
emptyView.isHidden = !unavailable
emptyView.apply(
title: "任务详情加载失败",
message: viewModel.errorMessage ?? "暂时无法获取任务信息,请稍后重试。"
)
scrollView.isHidden = viewModel.detail == nil
if let detail = viewModel.detail { rebuildContent(detail) }
updateLoadingPresentation()
updatePolling()
}
@MainActor
private func rebuildContent(_ detail: TravelAlbumAIJobDetail) {
contentStack.arrangedSubviews.forEach {
contentStack.removeArrangedSubview($0)
$0.removeFromSuperview()
}
let statusCard = AIJobDetailStatusCard()
statusCard.apply(detail)
contentStack.addArrangedSubview(statusCard)
let albumCard = AIJobDetailAlbumCard()
albumCard.apply(detail)
contentStack.addArrangedSubview(albumCard)
let contentCard = AIJobDetailContentCard()
contentCard.apply(detail)
contentStack.addArrangedSubview(contentCard)
let processingCard = AIJobDetailProcessingCard()
processingCard.apply(
detail.targets,
onViewResult: { [weak self] target in self?.openResult(target) }
)
contentStack.addArrangedSubview(processingCard)
let albumButton = AIJobDetailAlbumButton()
albumButton.addTarget(self, action: #selector(openAlbumTapped), for: .touchUpInside)
contentStack.addArrangedSubview(albumButton)
albumButton.snp.makeConstraints { $0.height.equalTo(56) }
}
@MainActor
private func updateLoadingPresentation() {
setLoadingPresented(isVisible && viewModel.isLoading && viewModel.detail == nil)
}
@MainActor
private func setLoadingPresented(_ presented: Bool) {
guard isPresentingLoading != presented else { return }
isPresentingLoading = presented
presented ? showLoading() : hideLoading()
}
private func reload() { Task { await viewModel.load(api: api) } }
@objc private func refreshTriggered() { Task { await viewModel.load(api: api, refreshing: true) } }
@objc private func manualRefresh() { Task { await viewModel.load(api: api, refreshing: true) } }
@objc private func applicationBecameActive() { updatePolling() }
@objc private func applicationEnteredBackground() { stopPolling() }
@objc private func openAlbumTapped() { openAlbum() }
private func updatePolling() {
guard isVisible,
UIApplication.shared.applicationState == .active,
viewModel.shouldPoll,
pollingTask == nil
else {
if !viewModel.shouldPoll { stopPolling() }
return
}
pollingTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(8))
guard !Task.isCancelled, let self else { break }
await self.viewModel.load(api: self.api, silent: true)
}
}
}
private func stopPolling() {
pollingTask?.cancel()
pollingTask = nil
}
@MainActor
private func presentNotFound() {
guard presentedViewController == nil else { return }
let alert = UIAlertController(title: nil, message: "任务不存在或已失效", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "知道了", style: .default) { [weak self] _ in
self?.replaceWithTaskList()
})
present(alert, animated: true)
}
private func replaceWithTaskList() {
guard let navigationController else { return }
if let existing = navigationController.viewControllers.first(where: { $0 is TravelAlbumAIJobListViewController }) {
navigationController.popToViewController(existing, animated: true)
return
}
var stack = navigationController.viewControllers.filter { $0 !== self }
stack.append(TravelAlbumAIJobListViewController(api: api))
navigationController.setViewControllers(stack, animated: true)
}
private func openAlbum() {
guard let id = viewModel.detail?.userEquityTravelId, id > 0 else { return }
navigationController?.pushViewController(TravelAlbumDetailViewController(albumId: id, api: api), animated: true)
}
private func openResult(_ target: TravelAlbumAIJobTarget) {
guard let kind = target.outputType.previewKind,
let asset = target.resultAsset,
!asset.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
showToast("结果资源已失效")
return
}
Task {
var project: TravelAlbumPreviewProject
if kind == .cover {
project = TravelAlbumPreviewProject(
originalMaterialId: asset.materialId,
aiRetouchBatchId: viewModel.batchId,
assets: [TravelAlbumPreviewAsset(
id: "cover-\(asset.id)",
kind: .cover,
fileURL: asset.url,
coverURL: asset.thumbnailURL,
fileName: "AI封面",
fileSize: 0
)]
)
} else if let materialId = target.sourceMaterial?.id {
do {
let material = try await api.materialInfo(
userEquityTravelId: viewModel.detail?.userEquityTravelId ?? 0,
materialId: materialId
)
let current = TravelAlbumPreviewProject(material: material)
let result = TravelAlbumPreviewAsset(
id: "job-result-\(asset.id)",
kind: kind,
fileURL: asset.url,
coverURL: asset.thumbnailURL,
fileName: material.fileName,
fileSize: material.fileSize
)
project = TravelAlbumPreviewProject(
originalMaterialId: material.id,
aiRetouchBatchId: viewModel.batchId,
assets: current.assets + [result]
)
} catch {
await MainActor.run { self.showToast("结果资源加载失败,请前往相册查看") }
return
}
} else {
await MainActor.run { self.showToast("结果资源已失效") }
return
}
await MainActor.run {
self.present(
TravelAlbumPhotoPreviewViewController(
projects: [project],
totalCount: 1,
startProjectIndex: 0,
startKind: kind,
allowsActions: false
),
animated: true
)
}
}
}
}
/// AI 修图详情页专用视觉常量。
private enum AIJobDetailStyle {
static let pageBackground = UIColor(hex: 0xF5F7FB)
static let textPrimary = UIColor(hex: 0x111827)
static let textSecondary = UIColor(hex: 0x64748B)
static let border = UIColor(hex: 0xE4EAF3)
static func styleCard(_ view: UIView) {
view.backgroundColor = .white
view.layer.cornerRadius = 14
view.layer.borderWidth = 1
view.layer.borderColor = border.cgColor
view.layer.shadowColor = UIColor(hex: 0x315B94).withAlphaComponent(0.08).cgColor
view.layer.shadowOpacity = 1
view.layer.shadowRadius = 8
view.layer.shadowOffset = CGSize(width: 0, height: 3)
}
}
/// 任务状态总览卡,包含环形状态、完成进度、ETA 和通知说明。
private final class AIJobDetailStatusCard: UIView {
private let ringView = AIJobCircularProgressView()
private let statusLabel = UILabel()
private let completedLabel = UILabel()
private let progressView = UIProgressView(progressViewStyle: .default)
private let percentLabel = UILabel()
private let timeIcon = UIImageView(image: UIImage(systemName: "clock"))
private let timeLabel = UILabel()
private let notificationIcon = UIImageView(image: UIImage(systemName: "bell"))
private let notificationLabel = UILabel()
private let timeRow = UIStackView()
private let notificationRow = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
AIJobDetailStyle.styleCard(self)
accessibilityIdentifier = "aiRetouchJob.detail.statusCard"
statusLabel.font = .systemFont(ofSize: 23, weight: .semibold)
completedLabel.font = .systemFont(ofSize: 15)
completedLabel.textColor = AIJobDetailStyle.textSecondary
progressView.trackTintColor = UIColor(hex: 0xE5EAF2)
progressView.layer.cornerRadius = 3
progressView.clipsToBounds = true
percentLabel.font = .systemFont(ofSize: 14, weight: .medium)
percentLabel.textColor = AIJobDetailStyle.textSecondary
percentLabel.textAlignment = .right
[timeIcon, notificationIcon].forEach {
$0.tintColor = AppColor.primary
$0.contentMode = .scaleAspectFit
$0.snp.makeConstraints { $0.size.equalTo(17) }
}
timeLabel.font = .systemFont(ofSize: 14)
timeLabel.textColor = AIJobDetailStyle.textSecondary
notificationLabel.font = .systemFont(ofSize: 14)
notificationLabel.textColor = AIJobDetailStyle.textSecondary
timeRow.axis = .horizontal
timeRow.spacing = 8
timeRow.alignment = .center
timeRow.addArrangedSubview(timeIcon)
timeRow.addArrangedSubview(timeLabel)
notificationRow.axis = .horizontal
notificationRow.spacing = 8
notificationRow.alignment = .center
notificationRow.addArrangedSubview(notificationIcon)
notificationRow.addArrangedSubview(notificationLabel)
let progressRow = UIStackView(arrangedSubviews: [progressView, percentLabel])
progressRow.axis = .horizontal
progressRow.spacing = 10
progressRow.alignment = .center
progressView.snp.makeConstraints { $0.height.equalTo(7) }
percentLabel.snp.makeConstraints { $0.width.greaterThanOrEqualTo(38) }
let rightStack = UIStackView(arrangedSubviews: [statusLabel, completedLabel, progressRow, timeRow, notificationRow])
rightStack.axis = .vertical
rightStack.spacing = 7
addSubview(ringView)
addSubview(rightStack)
ringView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
make.size.equalTo(92)
}
rightStack.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(16)
make.leading.equalTo(ringView.snp.trailing).offset(20)
make.trailing.equalToSuperview().inset(18)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(_ detail: TravelAlbumAIJobDetail) {
let color = detail.status.semanticColor
statusLabel.text = detail.status.title
statusLabel.textColor = color
completedLabel.text = "已完成 \(detail.progress.completed) / \(detail.progress.total)"
progressView.progressTintColor = color
progressView.progress = Float(detail.progress.fraction)
percentLabel.text = "\(Int((detail.progress.fraction * 100).rounded()))%"
ringView.apply(progress: detail.progress.fraction, color: color, status: detail.status)
if detail.status.isInProgress {
timeLabel.text = detail.estimatedFinishAt.map {
"预计 \(TravelAlbumAIJobDateFormatter.time($0)) 前完成"
} ?? "完成时间暂无法预估"
notificationLabel.text = "完成后将通过消息通知你"
notificationRow.isHidden = false
} else {
timeLabel.text = "完成时间 \(TravelAlbumAIJobDateFormatter.display(detail.finishedAt))"
notificationRow.isHidden = true
}
accessibilityLabel = [statusLabel.text, completedLabel.text, percentLabel.text, timeLabel.text, notificationLabel.text]
.compactMap { $0 }.joined(separator: ",")
}
}
/// 环形进度组件,使用图形、文字与颜色共同表达任务状态。
private final class AIJobCircularProgressView: UIView {
private let trackLayer = CAShapeLayer()
private let progressLayer = CAShapeLayer()
private let iconView = UIImageView(image: UIImage(systemName: "sparkles"))
override init(frame: CGRect) {
super.init(frame: frame)
layer.addSublayer(trackLayer)
layer.addSublayer(progressLayer)
[trackLayer, progressLayer].forEach {
$0.fillColor = UIColor.clear.cgColor
$0.lineWidth = 8
$0.lineCap = .round
}
trackLayer.strokeColor = UIColor(hex: 0xE7F0FF).cgColor
iconView.contentMode = .scaleAspectFit
addSubview(iconView)
iconView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(37)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() {
super.layoutSubviews()
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let radius = max(0, min(bounds.width, bounds.height) / 2 - 6)
let path = UIBezierPath(
arcCenter: center,
radius: radius,
startAngle: -.pi / 2,
endAngle: .pi * 1.5,
clockwise: true
).cgPath
trackLayer.path = path
progressLayer.path = path
}
func apply(progress: Double, color: UIColor, status: TravelAlbumAIJobStatus) {
progressLayer.strokeColor = color.cgColor
progressLayer.strokeEnd = max(0.04, min(1, progress))
iconView.tintColor = color
iconView.image = UIImage(systemName: status.isInProgress ? "sparkles" : status == .succeeded ? "checkmark" : "exclamationmark")
}
}
/// 相册与任务信息卡,匹配设计稿中的封面、账号与提交信息结构。
private final class AIJobDetailAlbumCard: UIView {
private let coverView = UIImageView()
private let albumLabel = UILabel()
private let phoneLabel = UILabel()
private let taskLabel = UILabel()
private let submitLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
AIJobDetailStyle.styleCard(self)
accessibilityIdentifier = "aiRetouchJob.detail.albumCard"
coverView.contentMode = .scaleAspectFill
coverView.clipsToBounds = true
coverView.layer.cornerRadius = 12
coverView.backgroundColor = AppColor.pageBackground
coverView.tintColor = AppColor.textTertiary
albumLabel.font = .systemFont(ofSize: 19, weight: .semibold)
albumLabel.textColor = AIJobDetailStyle.textPrimary
phoneLabel.font = .systemFont(ofSize: 14)
phoneLabel.textColor = AIJobDetailStyle.textSecondary
taskLabel.font = .systemFont(ofSize: 14)
taskLabel.textColor = AIJobDetailStyle.textSecondary
submitLabel.font = .systemFont(ofSize: 14)
submitLabel.textColor = AIJobDetailStyle.textSecondary
addSubview(coverView)
addSubview(albumLabel)
addSubview(phoneLabel)
addSubview(taskLabel)
addSubview(submitLabel)
coverView.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(16)
make.size.equalTo(84)
}
albumLabel.snp.makeConstraints { make in
make.leading.equalTo(coverView.snp.trailing).offset(14)
make.top.equalToSuperview().offset(19)
make.trailing.equalToSuperview().inset(16)
}
phoneLabel.snp.makeConstraints { make in
make.leading.equalTo(albumLabel)
make.top.equalTo(albumLabel.snp.bottom).offset(7)
}
taskLabel.snp.makeConstraints { make in
make.leading.equalTo(albumLabel)
make.bottom.equalToSuperview().inset(19)
}
submitLabel.snp.makeConstraints { make in
make.leading.greaterThanOrEqualTo(taskLabel.snp.trailing).offset(12)
make.trailing.equalToSuperview().inset(16)
make.centerY.equalTo(taskLabel)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(_ detail: TravelAlbumAIJobDetail) {
coverView.kf.setImage(with: URL(string: detail.album.coverURL), placeholder: UIImage(systemName: "photo"))
albumLabel.text = detail.album.name.isEmpty ? "未命名相册" : detail.album.name
let phone = TravelAlbumDisplayFormatter.maskPhone(detail.album.userPhone)
phoneLabel.text = phone.isEmpty ? "未提供手机号" : phone
taskLabel.text = "任务 #\(detail.aiRetouchBatchId)"
submitLabel.text = "\(TravelAlbumAIJobDateFormatter.display(detail.createdAt)) 提交"
accessibilityLabel = [albumLabel.text, phoneLabel.text, taskLabel.text, submitLabel.text]
.compactMap { $0 }.joined(separator: ",")
}
}
/// 任务内容卡,以浅蓝标签展示各输出目标数量并补充额度结算。
private final class AIJobDetailContentCard: UIView {
private let outputStack = UIStackView()
private let quotaLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
AIJobDetailStyle.styleCard(self)
accessibilityIdentifier = "aiRetouchJob.detail.contentCard"
let titleLabel = UILabel()
titleLabel.text = "任务内容"
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
titleLabel.textColor = AIJobDetailStyle.textPrimary
outputStack.axis = .horizontal
outputStack.spacing = 10
outputStack.distribution = .fillEqually
quotaLabel.font = .systemFont(ofSize: 12)
quotaLabel.textColor = AIJobDetailStyle.textSecondary
quotaLabel.numberOfLines = 0
let stack = UIStackView(arrangedSubviews: [titleLabel, outputStack, quotaLabel])
stack.axis = .vertical
stack.spacing = 14
addSubview(stack)
stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) }
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(_ detail: TravelAlbumAIJobDetail) {
outputStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
detail.outputs.forEach { outputStack.addArrangedSubview(makeChip($0)) }
outputStack.isHidden = detail.outputs.isEmpty
let quota = detail.quotaSettlement
quotaLabel.text = "额度:预占 \(quota.reservedUnits) · 消耗 \(quota.consumedUnits) · 释放 \(quota.releasedUnits)"
accessibilityLabel = ["任务内容", detail.outputs.map { "\($0.type.title) \($0.count)张" }.joined(separator: ","), quotaLabel.text]
.compactMap { $0 }.joined(separator: ",")
}
private func makeChip(_ output: TravelAlbumAIJobOutput) -> UIView {
let container = UIView()
container.backgroundColor = AppColor.primaryLight
container.layer.cornerRadius = 9
let iconView = UIImageView(image: UIImage(systemName: output.type.symbolName))
iconView.tintColor = AppColor.primary
iconView.contentMode = .scaleAspectFit
let label = UILabel()
label.text = "\(output.type.shortTitle) \(output.count) 张"
label.font = .systemFont(ofSize: 14, weight: .medium)
label.textColor = AIJobDetailStyle.textPrimary
label.adjustsFontSizeToFitWidth = true
label.minimumScaleFactor = 0.75
container.addSubview(iconView)
container.addSubview(label)
iconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(10)
make.centerY.equalToSuperview()
make.size.equalTo(18)
}
label.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(6)
make.trailing.equalToSuperview().inset(8)
make.centerY.equalToSuperview()
}
container.snp.makeConstraints { $0.height.equalTo(44) }
return container
}
}
/// 处理明细卡,将逐照片、逐输出状态及失败原因放在同一卡片中展示。
private final class AIJobDetailProcessingCard: UIView {
private let rowsStack = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
AIJobDetailStyle.styleCard(self)
accessibilityIdentifier = "aiRetouchJob.detail.processingCard"
let titleLabel = UILabel()
titleLabel.text = "处理明细"
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
titleLabel.textColor = AIJobDetailStyle.textPrimary
rowsStack.axis = .vertical
rowsStack.spacing = 0
let stack = UIStackView(arrangedSubviews: [titleLabel, rowsStack])
stack.axis = .vertical
stack.spacing = 12
addSubview(stack)
stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) }
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(
_ targets: [TravelAlbumAIJobTarget],
onViewResult: @escaping (TravelAlbumAIJobTarget) -> Void
) {
rowsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
if targets.isEmpty {
let label = UILabel()
label.text = "暂无处理明细"
label.font = .systemFont(ofSize: 14)
label.textColor = AIJobDetailStyle.textSecondary
label.textAlignment = .center
label.snp.makeConstraints { $0.height.equalTo(52) }
rowsStack.addArrangedSubview(label)
return
}
for (index, target) in targets.enumerated() {
let row = AIJobDetailTargetRow()
row.apply(target)
row.onViewResult = { onViewResult(target) }
rowsStack.addArrangedSubview(row)
if index < targets.count - 1 {
let separator = UIView()
separator.backgroundColor = AIJobDetailStyle.border
separator.snp.makeConstraints { $0.height.equalTo(1) }
rowsStack.addArrangedSubview(separator)
}
}
}
}
/// 单个输出明细行,内联展示结果操作或后端返回的失败原因。
private final class AIJobDetailTargetRow: UIView {
private let thumbnailView = UIImageView()
private let titleLabel = UILabel()
private let templateLabel = UILabel()
private let statusIconView = UIImageView()
private let statusLabel = UILabel()
private let resultButton = UIButton(type: .system)
private let errorContainer = UIView()
private let errorIconView = UIImageView(image: UIImage(systemName: "exclamationmark.circle.fill"))
private let errorLabel = UILabel()
var onViewResult: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
thumbnailView.contentMode = .scaleAspectFill
thumbnailView.clipsToBounds = true
thumbnailView.layer.cornerRadius = 10
thumbnailView.backgroundColor = AppColor.pageBackground
thumbnailView.tintColor = AppColor.textTertiary
titleLabel.font = .systemFont(ofSize: 15, weight: .medium)
titleLabel.textColor = AIJobDetailStyle.textPrimary
titleLabel.lineBreakMode = .byTruncatingMiddle
templateLabel.font = .systemFont(ofSize: 13)
templateLabel.textColor = AIJobDetailStyle.textSecondary
statusIconView.contentMode = .scaleAspectFit
statusLabel.font = .systemFont(ofSize: 14, weight: .medium)
resultButton.setTitle("查看结果", for: .normal)
resultButton.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
resultButton.addTarget(self, action: #selector(viewResultTapped), for: .touchUpInside)
errorContainer.backgroundColor = AppColor.dangerBackground
errorContainer.layer.cornerRadius = 8
errorIconView.tintColor = AppColor.danger
errorLabel.font = .systemFont(ofSize: 13)
errorLabel.textColor = AppColor.danger
errorLabel.numberOfLines = 0
let statusStack = UIStackView(arrangedSubviews: [statusIconView, statusLabel])
statusStack.axis = .horizontal
statusStack.spacing = 6
statusStack.alignment = .center
statusIconView.snp.makeConstraints { $0.size.equalTo(18) }
addSubview(thumbnailView)
addSubview(titleLabel)
addSubview(templateLabel)
addSubview(statusStack)
addSubview(resultButton)
addSubview(errorContainer)
errorContainer.addSubview(errorIconView)
errorContainer.addSubview(errorLabel)
thumbnailView.snp.makeConstraints { make in
make.leading.top.equalToSuperview().offset(4)
make.size.equalTo(80)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(thumbnailView.snp.trailing).offset(14)
make.top.equalTo(thumbnailView).offset(18)
make.trailing.lessThanOrEqualTo(statusStack.snp.leading).offset(-8)
}
templateLabel.snp.makeConstraints { make in
make.leading.equalTo(titleLabel)
make.top.equalTo(titleLabel.snp.bottom).offset(7)
make.trailing.lessThanOrEqualTo(statusStack.snp.leading).offset(-8)
}
statusStack.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(4)
make.centerY.equalTo(thumbnailView).offset(-7)
}
resultButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(4)
make.top.equalTo(statusStack.snp.bottom).offset(4)
make.height.equalTo(28)
}
errorContainer.snp.makeConstraints { make in
make.top.equalTo(thumbnailView.snp.bottom).offset(10)
make.leading.equalTo(titleLabel)
make.trailing.equalToSuperview().inset(4)
make.bottom.equalToSuperview().inset(12)
make.height.greaterThanOrEqualTo(38)
}
errorIconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(10)
make.top.equalToSuperview().offset(11)
make.size.equalTo(16)
}
errorLabel.snp.makeConstraints { make in
make.leading.equalTo(errorIconView.snp.trailing).offset(7)
make.top.bottom.equalToSuperview().inset(9)
make.trailing.equalToSuperview().inset(10)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(_ target: TravelAlbumAIJobTarget) {
let thumbnail = target.sourceMaterial?.thumbnailURL ?? target.resultAsset?.thumbnailURL ?? ""
thumbnailView.kf.setImage(with: URL(string: thumbnail), placeholder: UIImage(systemName: "photo"))
titleLabel.text = target.outputType == .cover
? "相册封面"
: (target.sourceMaterial?.fileName.isEmpty == false ? target.sourceMaterial?.fileName : "照片 \(target.sourceMaterial?.id ?? 0)")
templateLabel.text = target.template?.name ?? target.outputType.title
let color = target.status.semanticColor
statusIconView.tintColor = color
statusLabel.textColor = color
statusIconView.image = UIImage(systemName: target.status.symbolName)
switch target.status {
case .succeeded:
statusLabel.text = "\(target.outputType.shortTitle)完成"
case .processing:
statusLabel.text = "正在生成"
case .queued:
statusLabel.text = "等待处理"
case .failed:
statusLabel.text = "生成失败"
default:
statusLabel.text = target.status.title
}
let hasResult = target.status == .succeeded && target.resultAsset?.url.isEmpty == false
resultButton.isHidden = !hasResult
errorLabel.text = target.displayFailureMessage.map { "失败原因:\($0)" }
let showsError = target.displayFailureMessage != nil
errorContainer.isHidden = !showsError
errorContainer.snp.remakeConstraints { make in
make.top.equalTo(thumbnailView.snp.bottom).offset(showsError ? 10 : 0)
make.leading.equalTo(titleLabel)
make.trailing.equalToSuperview().inset(4)
make.bottom.equalToSuperview().inset(12)
if showsError {
make.height.greaterThanOrEqualTo(38)
} else {
make.height.equalTo(0)
}
}
accessibilityLabel = [titleLabel.text, templateLabel.text, statusLabel.text, errorLabel.text]
.compactMap { $0 }.joined(separator: ",")
}
@objc private func viewResultTapped() { onViewResult?() }
}
/// 页面底部的查看相册主操作按钮。
private final class AIJobDetailAlbumButton: UIButton {
override init(frame: CGRect) {
super.init(frame: frame)
var configuration = UIButton.Configuration.plain()
configuration.title = "查看相册"
configuration.image = UIImage(systemName: "photo")
configuration.imagePadding = 10
configuration.baseForegroundColor = AppColor.primary
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
var outgoing = incoming
outgoing.font = .systemFont(ofSize: 18, weight: .medium)
return outgoing
}
self.configuration = configuration
layer.cornerRadius = 10
layer.borderWidth = 1.5
layer.borderColor = AppColor.primary.cgColor
backgroundColor = .white
accessibilityIdentifier = "aiRetouchJob.detail.albumButton"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}
/// 任务详情加载失败时的空态。
private final class AIJobDetailEmptyView: UIView {
private let titleLabel = UILabel()
private let messageLabel = UILabel()
private let button = UIButton(type: .system)
var onRetry: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
titleLabel.textAlignment = .center
messageLabel.font = .systemFont(ofSize: 14)
messageLabel.textColor = AppColor.textSecondary
messageLabel.textAlignment = .center
messageLabel.numberOfLines = 0
button.setTitle("重新加载", for: .normal)
button.addTarget(self, action: #selector(retry), for: .touchUpInside)
let stack = UIStackView(arrangedSubviews: [titleLabel, messageLabel, button])
stack.axis = .vertical
stack.spacing = 12
stack.alignment = .center
addSubview(stack)
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(40)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(title: String, message: String) { titleLabel.text = title; messageLabel.text = message }
@objc private func retry() { onRetry?() }
}
private extension TravelAlbumAIJobStatus {
var semanticColor: UIColor {
switch self {
case .queued, .processing: AppColor.primary
case .succeeded: AppColor.success
case .partiallySucceeded: AppColor.warning
case .failed: AppColor.danger
case .canceled, .unknown: AppColor.textSecondary
}
}
var symbolName: String {
switch self {
case .queued: "clock"
case .processing: "arrow.triangle.2.circlepath"
case .succeeded: "checkmark.circle.fill"
case .partiallySucceeded: "exclamationmark.circle.fill"
case .failed: "exclamationmark.circle"
case .canceled, .unknown: "minus.circle"
}
}
}
private extension TravelAlbumAIJobOutputType {
var shortTitle: String {
switch self {
case .refined: "精修"
case .atmosphere: "氛围感"
case .cover: "封面"
case .unknown: "其他"
}
}
var symbolName: String {
switch self {
case .refined: "wand.and.stars"
case .atmosphere: "sun.max"
case .cover: "bookmark"
case .unknown: "sparkles"
}
}
}
@@ -0,0 +1,675 @@
import Kingfisher
import SnapKit
import UIKit
/// 当前账号的 AI 修图任务中心,提供筛选、游标分页、刷新与状态轮询。
final class TravelAlbumAIJobListViewController: BaseViewController {
private enum Section { case main }
private let viewModel = TravelAlbumAIJobListViewModel()
private let api: any TravelAlbumServing
private let filterContainer = UIView()
private let filterStack = UIStackView()
private var filterButtons: [TravelAlbumAIJobFilter: UIButton] = [:]
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
private let refreshControl = UIRefreshControl()
private let emptyView = AIJobEmptyView()
private var dataSource: UICollectionViewDiffableDataSource<Section, TravelAlbumAIJobSummary>!
private var pollingTask: Task<Void, Never>?
private var isVisible = false
private var isPresentingLoading = false
/// 创建任务中心,可注入 API 以支持测试。
init(api: (any TravelAlbumServing)? = nil) {
self.api = api ?? NetworkServices.shared.travelAlbumAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func setupNavigationBar() {
let titleLabel = UILabel()
titleLabel.text = "AI修图任务"
titleLabel.textColor = UIColor(hex: 0x0F1F3D)
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
navigationItem.titleView = titleLabel
navigationItem.backButtonDisplayMode = .minimal
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = .white
appearance.shadowColor = .clear
navigationItem.standardAppearance = appearance
navigationItem.scrollEdgeAppearance = appearance
navigationItem.compactAppearance = appearance
navigationController?.setNavigationBarHidden(false, animated: false)
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackgroundSoft
configureFilters()
collectionView.backgroundColor = .clear
collectionView.alwaysBounceVertical = true
collectionView.delegate = self
collectionView.refreshControl = refreshControl
collectionView.register(AIJobSummaryCell.self, forCellWithReuseIdentifier: AIJobSummaryCell.reuseIdentifier)
dataSource = UICollectionViewDiffableDataSource<Section, TravelAlbumAIJobSummary>(collectionView: collectionView) {
collectionView, indexPath, item in
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: AIJobSummaryCell.reuseIdentifier,
for: indexPath
) as! AIJobSummaryCell
cell.apply(item)
return cell
}
emptyView.onRetry = { [weak self] in self?.reload() }
view.addSubview(filterContainer)
filterContainer.addSubview(filterStack)
view.addSubview(collectionView)
view.addSubview(emptyView)
}
override func setupConstraints() {
filterContainer.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
make.leading.trailing.equalToSuperview().inset(20)
make.height.equalTo(46)
}
filterStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(2)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(filterContainer.snp.bottom).offset(14)
make.leading.trailing.bottom.equalToSuperview()
}
emptyView.snp.makeConstraints { make in
make.top.equalTo(filterContainer.snp.bottom)
make.leading.trailing.bottom.equalToSuperview()
}
}
override func bindActions() {
refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } }
viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } }
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationBecameActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationEnteredBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.loadFirstPage(api: api) }
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
isVisible = true
updateLoadingPresentation()
updatePolling()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
isVisible = false
setLoadingPresented(false)
stopPolling()
}
deinit {
pollingTask?.cancel()
NotificationCenter.default.removeObserver(self)
}
private func configureFilters() {
filterContainer.accessibilityIdentifier = "aiRetouchJob.filterContainer"
filterContainer.backgroundColor = .white
filterContainer.layer.cornerRadius = 12
filterContainer.layer.borderWidth = 1
filterContainer.layer.borderColor = UIColor(hex: 0xE8EEF7).cgColor
filterContainer.layer.shadowColor = UIColor(hex: 0x315B94).withAlphaComponent(0.12).cgColor
filterContainer.layer.shadowOpacity = 1
filterContainer.layer.shadowRadius = 7
filterContainer.layer.shadowOffset = CGSize(width: 0, height: 3)
filterStack.axis = .horizontal
filterStack.spacing = 0
filterStack.distribution = .fillEqually
TravelAlbumAIJobFilter.allCases.forEach { filter in
var configuration = UIButton.Configuration.plain()
configuration.title = filter.title
configuration.contentInsets = .zero
let button = UIButton(configuration: configuration)
button.tag = TravelAlbumAIJobFilter.allCases.firstIndex(of: filter) ?? 0
button.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
button.layer.cornerRadius = 10
button.addTarget(self, action: #selector(filterTapped(_:)), for: .touchUpInside)
button.accessibilityLabel = "筛选:\(filter.title)"
filterButtons[filter] = button
filterStack.addArrangedSubview(button)
}
}
private func makeLayout() -> UICollectionViewLayout {
UICollectionViewCompositionalLayout { _, environment in
let item = NSCollectionLayoutItem(layoutSize: .init(
widthDimension: .fractionalWidth(1),
heightDimension: .estimated(250)
))
let group = NSCollectionLayoutGroup.vertical(
layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .estimated(250)),
subitems: [item]
)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 14
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 20, bottom: 24, trailing: 20)
return section
}
}
@MainActor
private func applyState() {
filterButtons.forEach { filter, button in
let selected = filter == viewModel.selectedFilter
button.configuration?.baseForegroundColor = selected ? .white : AppColor.textSecondary
button.configuration?.background.backgroundColor = .clear
button.backgroundColor = selected ? AppColor.primary : .clear
button.accessibilityTraits = selected ? [.button, .selected] : .button
}
refreshControl.endRefreshing()
var snapshot = NSDiffableDataSourceSnapshot<Section, TravelAlbumAIJobSummary>()
snapshot.appendSections([.main])
snapshot.appendItems(viewModel.items)
dataSource.apply(snapshot, animatingDifferences: true)
let empty = viewModel.items.isEmpty && !viewModel.isLoading
emptyView.isHidden = !empty
emptyView.apply(
title: viewModel.errorMessage == nil ? "暂无AI修图任务" : "任务加载失败",
message: viewModel.errorMessage ?? "提交AI修图后,可以在这里查看进度和结果。",
showsRetry: viewModel.errorMessage != nil
)
updateLoadingPresentation()
updatePolling()
}
@MainActor
private func updateLoadingPresentation() {
setLoadingPresented(isVisible && viewModel.isLoading && viewModel.items.isEmpty)
}
@MainActor
private func setLoadingPresented(_ presented: Bool) {
guard isPresentingLoading != presented else { return }
isPresentingLoading = presented
if presented {
showLoading()
} else {
hideLoading()
}
}
private func reload() { Task { await viewModel.loadFirstPage(api: api) } }
@objc private func refreshTriggered() {
Task { await viewModel.loadFirstPage(api: api, refreshing: true) }
}
@objc private func filterTapped(_ sender: UIButton) {
guard TravelAlbumAIJobFilter.allCases.indices.contains(sender.tag) else { return }
Task { await viewModel.selectFilter(TravelAlbumAIJobFilter.allCases[sender.tag], api: api) }
}
@objc private func applicationBecameActive() { updatePolling() }
@objc private func applicationEnteredBackground() { stopPolling() }
private func updatePolling() {
guard isVisible,
UIApplication.shared.applicationState == .active,
viewModel.containsInProgressJobs,
pollingTask == nil
else {
if !viewModel.containsInProgressJobs { stopPolling() }
return
}
pollingTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(15))
guard !Task.isCancelled, let self else { break }
await self.viewModel.loadFirstPage(api: self.api, silent: true)
}
}
}
private func stopPolling() {
pollingTask?.cancel()
pollingTask = nil
}
}
extension TravelAlbumAIJobListViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
navigationController?.pushViewController(
TravelAlbumAIJobDetailViewController(batchId: item.aiRetouchBatchId, api: api),
animated: true
)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView.contentSize.height > 0,
scrollView.contentOffset.y + scrollView.bounds.height > scrollView.contentSize.height - 240
else { return }
Task { await viewModel.loadMore(api: api) }
}
}
/// AI 修图任务列表卡片。
private final class AIJobSummaryCell: UICollectionViewCell {
static let reuseIdentifier = "AIJobSummaryCell"
private let topInfoView = UIView()
private let albumCoverImageView = UIImageView()
private let albumLabel = UILabel()
private let phoneLabel = UILabel()
private let taskLabel = UILabel()
private let timeIconView = UIImageView(image: UIImage(systemName: "clock"))
private let timeLabel = UILabel()
private let timeStack = UIStackView()
private let statusBadge = AIJobStatusBadgeView()
private let previewStack = UIStackView()
private var previewImageViews: [UIImageView] = []
private let progressRow = UIStackView()
private let completedLabel = UILabel()
private let outputLabel = UILabel()
private let progressView = UIProgressView(progressViewStyle: .default)
private let percentLabel = UILabel()
private let etaLabel = UILabel()
private let bottomRow = UIView()
private let outputPill = UIView()
private let outputIconView = UIImageView(image: UIImage(systemName: "wand.and.stars"))
private let detailsLabel = UILabel()
private let chevronView = UIImageView(image: UIImage(systemName: "chevron.right"))
private let contentStack = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .white
contentView.layer.cornerRadius = 16
contentView.layer.borderWidth = 1
contentView.layer.borderColor = UIColor(hex: 0xE6EDF8).cgColor
contentView.layer.shadowColor = UIColor(hex: 0x315B94).withAlphaComponent(0.1).cgColor
contentView.layer.shadowOpacity = 1
contentView.layer.shadowRadius = 9
contentView.layer.shadowOffset = CGSize(width: 0, height: 4)
configureTopInfo()
configurePreviewGrid()
completedLabel.font = .systemFont(ofSize: 14, weight: .medium)
completedLabel.textColor = UIColor(hex: 0x263754)
progressView.tintColor = AppColor.primary
progressView.trackTintColor = UIColor(hex: 0xE5EAF2)
progressView.layer.cornerRadius = 3
progressView.clipsToBounds = true
percentLabel.font = .systemFont(ofSize: 14, weight: .medium)
percentLabel.textColor = UIColor(hex: 0x263754)
percentLabel.textAlignment = .right
progressRow.axis = .horizontal
progressRow.alignment = .center
progressRow.spacing = 10
progressRow.addArrangedSubview(completedLabel)
progressRow.addArrangedSubview(progressView)
progressRow.addArrangedSubview(percentLabel)
progressView.snp.makeConstraints { $0.height.equalTo(6) }
completedLabel.setContentHuggingPriority(.required, for: .horizontal)
percentLabel.setContentHuggingPriority(.required, for: .horizontal)
etaLabel.font = .systemFont(ofSize: 13)
etaLabel.textColor = UIColor(hex: 0x7F8CA3)
configureBottomRow()
contentStack.axis = .vertical
contentStack.spacing = 12
[topInfoView, previewStack, progressRow, etaLabel, bottomRow].forEach {
contentStack.addArrangedSubview($0)
}
contentView.addSubview(contentStack)
contentStack.snp.makeConstraints { $0.edges.equalToSuperview().inset(14) }
topInfoView.snp.makeConstraints { $0.height.equalTo(72) }
previewStack.snp.makeConstraints { $0.height.equalTo(104) }
bottomRow.snp.makeConstraints { $0.height.equalTo(34) }
accessibilityIdentifier = "aiRetouchJob.summaryCell"
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func prepareForReuse() {
super.prepareForReuse()
albumCoverImageView.kf.cancelDownloadTask()
albumCoverImageView.image = nil
previewImageViews.forEach {
$0.kf.cancelDownloadTask()
$0.image = nil
$0.isHidden = true
}
}
func apply(_ item: TravelAlbumAIJobSummary) {
albumLabel.text = item.album.name.isEmpty ? "未命名相册" : item.album.name
let phone = TravelAlbumDisplayFormatter.maskPhone(item.album.userPhone)
phoneLabel.text = phone.isEmpty ? "未提供手机号" : phone
taskLabel.text = "任务 #\(item.aiRetouchBatchId)"
timeLabel.text = relativeTime(item.createdAt)
statusBadge.apply(item.status)
albumCoverImageView.kf.setImage(
with: URL(string: item.album.coverURL),
placeholder: UIImage(systemName: "photo")
)
previewImageViews.enumerated().forEach { index, imageView in
guard item.previewImages.indices.contains(index) else {
imageView.isHidden = true
return
}
imageView.isHidden = false
imageView.kf.setImage(
with: URL(string: item.previewImages[index].thumbnailURL),
placeholder: UIImage(systemName: "photo")
)
}
previewStack.isHidden = item.previewImages.isEmpty
progressView.progress = Float(item.progress.fraction)
if item.status.isInProgress {
completedLabel.text = "已完成 \(item.progress.completed) / \(item.progress.total)"
percentLabel.text = "\(Int((item.progress.fraction * 100).rounded()))%"
etaLabel.text = item.estimatedFinishAt.map {
"预计 \(TravelAlbumAIJobDateFormatter.time($0)) 前完成"
} ?? "完成后将通过消息通知"
progressRow.isHidden = false
etaLabel.isHidden = false
outputIconView.image = UIImage(systemName: "wand.and.stars")
outputIconView.tintColor = AppColor.primary
outputPill.backgroundColor = AppColor.primaryLight
outputLabel.textColor = AppColor.primary
outputLabel.text = item.outputs.map { "\(shortTitle($0.type)) \($0.count)张" }.joined(separator: " · ")
} else if item.status == .partiallySucceeded {
progressRow.isHidden = true
etaLabel.isHidden = true
outputIconView.image = UIImage(systemName: "exclamationmark.circle.fill")
outputIconView.tintColor = AppColor.warning
outputPill.backgroundColor = .clear
outputLabel.textColor = UIColor(hex: 0xB56A00)
outputLabel.text = "\(item.progress.succeeded)张成功 · \(item.progress.failed)张失败"
} else {
progressRow.isHidden = true
etaLabel.isHidden = true
let succeeded = item.status == .succeeded
outputIconView.image = UIImage(systemName: succeeded ? "checkmark.circle.fill" : "xmark.circle.fill")
outputIconView.tintColor = succeeded ? AppColor.success : AppColor.danger
outputPill.backgroundColor = .clear
outputLabel.textColor = succeeded ? UIColor(hex: 0x178A55) : AppColor.danger
outputLabel.text = succeeded ? "\(item.progress.succeeded)张结果已生成" : item.status.title
}
accessibilityLabel = [albumLabel.text, statusBadge.accessibilityLabel, timeLabel.text, phoneLabel.text, taskLabel.text,
outputLabel.text, completedLabel.text, etaLabel.text]
.compactMap { $0 }.joined(separator: ",")
accessibilityTraits = .button
}
private func configureTopInfo() {
albumCoverImageView.accessibilityIdentifier = "aiRetouchJob.albumCover"
albumCoverImageView.contentMode = .scaleAspectFill
albumCoverImageView.clipsToBounds = true
albumCoverImageView.layer.cornerRadius = 10
albumCoverImageView.backgroundColor = AppColor.pageBackground
albumCoverImageView.tintColor = AppColor.textTertiary
albumLabel.font = .systemFont(ofSize: 17, weight: .semibold)
albumLabel.textColor = UIColor(hex: 0x0F1F3D)
albumLabel.lineBreakMode = .byTruncatingTail
phoneLabel.font = .systemFont(ofSize: 14)
phoneLabel.textColor = UIColor(hex: 0x72809A)
taskLabel.font = .systemFont(ofSize: 14)
taskLabel.textColor = UIColor(hex: 0x72809A)
timeIconView.tintColor = UIColor(hex: 0x7F8CA3)
timeIconView.contentMode = .scaleAspectFit
timeLabel.font = .systemFont(ofSize: 13)
timeLabel.textColor = UIColor(hex: 0x7F8CA3)
timeStack.axis = .horizontal
timeStack.spacing = 5
timeStack.alignment = .center
timeStack.addArrangedSubview(timeIconView)
timeStack.addArrangedSubview(timeLabel)
[albumCoverImageView, albumLabel, phoneLabel, taskLabel, timeStack, statusBadge].forEach {
topInfoView.addSubview($0)
}
albumCoverImageView.snp.makeConstraints { make in
make.leading.top.equalToSuperview()
make.size.equalTo(68)
}
timeIconView.snp.makeConstraints { $0.size.equalTo(15) }
timeStack.snp.makeConstraints { make in
make.top.trailing.equalToSuperview()
}
statusBadge.snp.makeConstraints { make in
make.trailing.bottom.equalToSuperview()
make.height.equalTo(30)
}
albumLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(1)
make.leading.equalTo(albumCoverImageView.snp.trailing).offset(12)
make.trailing.lessThanOrEqualTo(timeStack.snp.leading).offset(-8)
}
phoneLabel.snp.makeConstraints { make in
make.leading.equalTo(albumLabel)
make.top.equalTo(albumLabel.snp.bottom).offset(6)
make.trailing.lessThanOrEqualTo(statusBadge.snp.leading).offset(-8)
}
taskLabel.snp.makeConstraints { make in
make.leading.equalTo(albumLabel)
make.top.equalTo(phoneLabel.snp.bottom).offset(5)
make.trailing.lessThanOrEqualTo(statusBadge.snp.leading).offset(-8)
}
}
private func configurePreviewGrid() {
previewStack.axis = .horizontal
previewStack.spacing = 8
previewStack.distribution = .fillEqually
for index in 0..<3 {
let slot = UIView()
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 10
imageView.backgroundColor = AppColor.pageBackground
imageView.tintColor = AppColor.textTertiary
imageView.accessibilityIdentifier = "aiRetouchJob.preview.\(index)"
slot.addSubview(imageView)
imageView.snp.makeConstraints { $0.edges.equalToSuperview() }
previewStack.addArrangedSubview(slot)
previewImageViews.append(imageView)
}
}
private func configureBottomRow() {
outputPill.layer.cornerRadius = 9
outputPill.clipsToBounds = true
outputIconView.contentMode = .scaleAspectFit
outputLabel.font = .systemFont(ofSize: 13, weight: .medium)
outputLabel.numberOfLines = 1
outputLabel.adjustsFontSizeToFitWidth = true
outputLabel.minimumScaleFactor = 0.8
outputPill.addSubview(outputIconView)
outputPill.addSubview(outputLabel)
outputIconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(10)
make.centerY.equalToSuperview()
make.size.equalTo(17)
}
outputLabel.snp.makeConstraints { make in
make.leading.equalTo(outputIconView.snp.trailing).offset(6)
make.trailing.equalToSuperview().inset(10)
make.centerY.equalToSuperview()
}
detailsLabel.text = "查看详情"
detailsLabel.font = .systemFont(ofSize: 13)
detailsLabel.textColor = UIColor(hex: 0x7F8CA3)
chevronView.tintColor = UIColor(hex: 0x7F8CA3)
chevronView.contentMode = .scaleAspectFit
[outputPill, detailsLabel, chevronView].forEach { bottomRow.addSubview($0) }
outputPill.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview()
make.trailing.lessThanOrEqualTo(detailsLabel.snp.leading).offset(-8)
}
chevronView.snp.makeConstraints { make in
make.trailing.centerY.equalToSuperview()
make.size.equalTo(14)
}
detailsLabel.snp.makeConstraints { make in
make.trailing.equalTo(chevronView.snp.leading).offset(-6)
make.centerY.equalToSuperview()
}
}
private func shortTitle(_ type: TravelAlbumAIJobOutputType) -> String {
switch type {
case .refined: "精修"
case .atmosphere: "氛围感"
case .cover: "封面"
case .unknown: "其他"
}
}
private func relativeTime(_ value: String) -> String {
guard let date = TravelAlbumAIJobDateFormatter.date(value) else { return "--" }
let calendar = Calendar.current
let prefix: String
if calendar.isDateInToday(date) {
prefix = "今天"
} else if calendar.isDateInYesterday(date) {
prefix = "昨天"
} else {
prefix = date.formatted(.dateTime.month().day())
}
return "\(prefix) \(date.formatted(.dateTime.hour().minute()))"
}
}
/// 带图标与底色的任务状态徽标,保证状态不只依赖颜色表达。
private final class AIJobStatusBadgeView: UIView {
private let iconView = UIImageView()
private let titleLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 9
iconView.contentMode = .scaleAspectFit
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
addSubview(iconView)
addSubview(titleLabel)
iconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(9)
make.centerY.equalToSuperview()
make.size.equalTo(17)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(5)
make.trailing.equalToSuperview().inset(10)
make.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(_ status: TravelAlbumAIJobStatus) {
titleLabel.text = status.title
let color: UIColor
let background: UIColor
let symbol: String
switch status {
case .queued:
color = AppColor.primary
background = AppColor.infoBackground
symbol = "clock.arrow.circlepath"
case .processing:
color = AppColor.primary
background = AppColor.infoBackground
symbol = "arrow.triangle.2.circlepath"
case .succeeded:
color = AppColor.success
background = AppColor.successBackground
symbol = "checkmark.circle.fill"
case .partiallySucceeded:
color = AppColor.warning
background = AppColor.warningBackground
symbol = "exclamationmark.circle.fill"
case .failed:
color = AppColor.danger
background = AppColor.dangerBackground
symbol = "xmark.circle.fill"
case .canceled, .unknown:
color = AppColor.textSecondary
background = AppColor.pageBackground
symbol = "minus.circle.fill"
}
titleLabel.textColor = color
iconView.tintColor = color
iconView.image = UIImage(systemName: symbol)
backgroundColor = background
accessibilityLabel = status.title
}
}
/// 任务列表空态和错误态。
private final class AIJobEmptyView: UIView {
private let imageView = UIImageView(image: UIImage(systemName: "photo.on.rectangle.angled"))
private let titleLabel = UILabel()
private let messageLabel = UILabel()
private let retryButton = UIButton(type: .system)
var onRetry: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
imageView.tintColor = AppColor.primary
imageView.contentMode = .scaleAspectFit
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
titleLabel.textAlignment = .center
messageLabel.font = .systemFont(ofSize: 14)
messageLabel.textColor = AppColor.textSecondary
messageLabel.textAlignment = .center
messageLabel.numberOfLines = 0
retryButton.setTitle("重新加载", for: .normal)
retryButton.addTarget(self, action: #selector(retry), for: .touchUpInside)
let stack = UIStackView(arrangedSubviews: [imageView, titleLabel, messageLabel, retryButton])
stack.axis = .vertical
stack.alignment = .center
stack.spacing = 12
addSubview(stack)
stack.snp.makeConstraints { make in
make.centerY.equalToSuperview().offset(-40)
make.leading.trailing.equalToSuperview().inset(40)
}
imageView.snp.makeConstraints { $0.size.equalTo(52) }
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func apply(title: String, message: String, showsRetry: Bool) {
titleLabel.text = title
messageLabel.text = message
retryButton.isHidden = !showsRetry
}
@objc private func retry() { onRetry?() }
}
@@ -0,0 +1,933 @@
//
// TravelAlbumAIRetouchTemplateViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// AI 修图模板选择 Sheet,按工作流展示所需横向模板列表与固定底部操作。
final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
/// 页面使用的 collection section。
private enum Section: Hashable {
case overview
case templates(TravelAlbumAIRetouchTemplateCategory)
case mode
}
/// 页面使用的 diffable item。
private enum Item: Hashable {
case overview
case template(TravelAlbumAIRetouchTemplateCategory, TravelAlbumAIRetouchTemplate)
case mode
}
private let viewModel: TravelAlbumAIRetouchTemplateViewModel
private let api: any TravelAlbumServing
private let onSubmitted: (TravelAlbumAIJobSubmission) -> Void
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private let statusContainer = UIView()
private let statusIndicator = UIActivityIndicatorView(style: .medium)
private let statusLabel = UILabel()
private let retryButton = UIButton(type: .system)
private let bottomBar = UIView()
private let bottomDivider = UIView()
private let footerStack = UIStackView()
private let validationLabel = UILabel()
private let actionStack = UIStackView()
private let cancelButton = UIButton(type: .system)
private let confirmButton = AIRetouchGradientButton(type: .system)
/// 创建 AI 修图模板选择 Sheet。
init(
viewModel: TravelAlbumAIRetouchTemplateViewModel,
api: any TravelAlbumServing,
onSubmitted: @escaping (TravelAlbumAIJobSubmission) -> Void
) {
self.viewModel = viewModel
self.api = api
self.onSubmitted = onSubmitted
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .pageSheet
configureSheetPresentation()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupUI() {
view.backgroundColor = AIRetouchTemplateStyle.pageBackground
view.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateSheet"
collectionView.backgroundColor = .clear
collectionView.showsVerticalScrollIndicator = false
collectionView.alwaysBounceVertical = true
collectionView.delegate = self
collectionView.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateCollection"
collectionView.register(
TravelAlbumAIRetouchTemplateCell.self,
forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier
)
collectionView.register(
TravelAlbumAIRetouchOverviewCell.self,
forCellWithReuseIdentifier: TravelAlbumAIRetouchOverviewCell.reuseIdentifier
)
collectionView.register(
TravelAlbumAIRetouchModeCell.self,
forCellWithReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier
)
collectionView.register(
TravelAlbumAIRetouchSectionHeader.self,
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier
)
configureDataSource()
statusLabel.textColor = AIRetouchTemplateStyle.textSecondary
statusLabel.font = .systemFont(ofSize: 14)
statusLabel.textAlignment = .center
statusLabel.numberOfLines = 0
retryButton.setTitle("重试", for: .normal)
retryButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
retryButton.accessibilityIdentifier = "travelAlbum.aiRetouchRetryButton"
bottomBar.backgroundColor = .white
bottomBar.accessibilityIdentifier = "travelAlbum.aiRetouchBottomBar"
bottomDivider.backgroundColor = AIRetouchTemplateStyle.border
validationLabel.textColor = AIRetouchTemplateStyle.danger
validationLabel.font = .systemFont(ofSize: 12, weight: .medium)
validationLabel.textAlignment = .center
validationLabel.numberOfLines = 0
validationLabel.accessibilityIdentifier = "travelAlbum.aiRetouchValidationLabel"
footerStack.axis = .vertical
footerStack.spacing = 8
footerStack.alignment = .fill
actionStack.axis = .horizontal
actionStack.spacing = 12
actionStack.distribution = .fill
actionStack.alignment = .fill
configureCancelButton()
configureConfirmButton()
actionStack.addArrangedSubview(cancelButton)
actionStack.addArrangedSubview(confirmButton)
footerStack.addArrangedSubview(validationLabel)
footerStack.addArrangedSubview(actionStack)
view.addSubview(collectionView)
view.addSubview(statusContainer)
statusContainer.addSubview(statusIndicator)
statusContainer.addSubview(statusLabel)
statusContainer.addSubview(retryButton)
view.addSubview(bottomBar)
bottomBar.addSubview(bottomDivider)
bottomBar.addSubview(footerStack)
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
bottomDivider.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(0.5)
}
footerStack.snp.makeConstraints { make in
make.top.equalToSuperview().offset(10)
make.leading.trailing.equalToSuperview().inset(18)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
actionStack.snp.makeConstraints { make in
make.height.equalTo(56)
}
cancelButton.snp.makeConstraints { make in
make.width.equalTo(confirmButton.snp.width).multipliedBy(0.78)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
statusContainer.snp.makeConstraints { make in
make.edges.equalTo(collectionView)
}
statusIndicator.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalToSuperview().offset(-32)
}
statusLabel.snp.makeConstraints { make in
make.top.equalTo(statusIndicator.snp.bottom).offset(14)
make.leading.trailing.equalToSuperview().inset(40)
}
retryButton.snp.makeConstraints { make in
make.top.equalTo(statusLabel.snp.bottom).offset(12)
make.centerX.equalToSuperview()
make.height.equalTo(36)
make.bottom.lessThanOrEqualToSuperview()
}
}
override func bindActions() {
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onSubmitted = { [weak self] submission in
Task { @MainActor in
guard let self else { return }
self.dismiss(animated: true) { self.onSubmitted(submission) }
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
Task { await viewModel.loadTemplates(api: api) }
}
private func configureSheetPresentation() {
guard let sheet = sheetPresentationController else { return }
sheet.detents = [.large()]
sheet.selectedDetentIdentifier = .large
sheet.prefersGrabberVisible = true
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
}
private func configureCancelButton() {
cancelButton.setTitle("取消", for: .normal)
cancelButton.setTitleColor(AIRetouchTemplateStyle.primary, for: .normal)
cancelButton.setTitleColor(AIRetouchTemplateStyle.primary.withAlphaComponent(0.45), for: .disabled)
cancelButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
cancelButton.backgroundColor = AIRetouchTemplateStyle.cardBackground
cancelButton.layer.cornerRadius = 12
cancelButton.layer.borderColor = AIRetouchTemplateStyle.primary.cgColor
cancelButton.layer.borderWidth = 1
cancelButton.accessibilityIdentifier = "travelAlbum.aiRetouchCancelButton"
}
private func configureConfirmButton() {
confirmButton.setTitle("确定", for: .normal)
confirmButton.setTitleColor(.white, for: .normal)
confirmButton.setTitleColor(.white, for: .disabled)
confirmButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
confirmButton.accessibilityIdentifier = "travelAlbum.aiRetouchConfirmButton"
}
private func configureDataSource() {
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) {
[weak self] collectionView, indexPath, item in
guard let self else { return nil }
switch item {
case .overview:
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TravelAlbumAIRetouchOverviewCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumAIRetouchOverviewCell
cell.apply(
selectedPhotoCount: self.viewModel.selectedPhotoCount,
tips: self.viewModel.shouldShowInitialTips ? self.viewModel.initialTipsText : nil
)
return cell
case .template(let category, let template):
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumAIRetouchTemplateCell
cell.apply(
template: template,
selected: self.viewModel.selectedTemplateId(for: category) == template.id
)
cell.onPreviewTapped = { [weak self] in
self?.showPreview(for: template)
}
return cell
case .mode:
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumAIRetouchModeCell
cell.apply(
requiredQuota: self.viewModel.requiredQuota,
remainingQuota: self.viewModel.remainingQuota,
insufficient: self.viewModel.remainingQuota.map {
self.viewModel.requiredQuota > $0
} ?? false
)
return cell
}
}
dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
guard kind == UICollectionView.elementKindSectionHeader,
let self,
self.dataSource.snapshot().sectionIdentifiers.indices.contains(indexPath.section) else {
return nil
}
let section = self.dataSource.snapshot().sectionIdentifiers[indexPath.section]
let header = collectionView.dequeueReusableSupplementaryView(
ofKind: kind,
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier,
for: indexPath
) as! TravelAlbumAIRetouchSectionHeader
switch section {
case .templates(let category):
header.apply(
title: category.title,
badge: category == .cover
? .gift
: (self.viewModel.isOptional(category) ? .optional : nil)
)
case .mode:
return nil
case .overview:
return nil
}
return header
}
}
private func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { [weak self] sectionIndex, layoutEnvironment in
guard let self,
self.dataSource != nil,
self.dataSource.snapshot().sectionIdentifiers.indices.contains(sectionIndex) else {
return nil
}
switch self.dataSource.snapshot().sectionIdentifiers[sectionIndex] {
case .overview:
let size = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(self.viewModel.shouldShowInitialTips ? 150 : 82)
)
let item = NSCollectionLayoutItem(layoutSize: size)
let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item])
return NSCollectionLayoutSection(group: group)
case .templates:
let availableWidth = layoutEnvironment.container.effectiveContentSize.width
let cardWidth = min(118, floor((availableWidth - 56) / 3))
let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(cardWidth),
heightDimension: .absolute(156)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
section.interGroupSpacing = 10
section.contentInsets = NSDirectionalEdgeInsets(
top: 4,
leading: 18,
bottom: 14,
trailing: 18
)
section.boundarySupplementaryItems = [
NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(40)
),
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .top
),
]
return section
case .mode:
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(78)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(
top: 8,
leading: 18,
bottom: 18,
trailing: 18
)
return section
}
}
}
@MainActor
private func applyViewModel() {
let isLoadFailure = viewModel.loadErrorMessage != nil
statusContainer.isHidden = !viewModel.isLoading && !isLoadFailure
collectionView.isHidden = viewModel.isLoading || isLoadFailure
if viewModel.isLoading {
statusIndicator.startAnimating()
statusLabel.text = "正在加载修图模板…"
retryButton.isHidden = true
} else if let message = viewModel.loadErrorMessage {
statusIndicator.stopAnimating()
statusLabel.text = message
retryButton.isHidden = false
} else {
statusIndicator.stopAnimating()
statusLabel.text = nil
retryButton.isHidden = true
}
validationLabel.text = viewModel.validationMessage
validationLabel.isHidden = viewModel.validationMessage == nil
cancelButton.isEnabled = !viewModel.isSubmitting
confirmButton.isEnabled = viewModel.canSubmit
confirmButton.alpha = viewModel.canSubmit ? 1 : 0.45
confirmButton.setGradientEnabled(viewModel.canSubmit)
confirmButton.setTitle(viewModel.isSubmitting ? "提交中" : "确定", for: .normal)
confirmButton.setLoading(viewModel.isSubmitting)
isModalInPresentation = viewModel.isSubmitting
applySnapshot()
}
private func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.overview])
snapshot.appendItems([.overview], toSection: .overview)
for category in viewModel.visibleCategories {
appendTemplates(category, to: &snapshot)
}
snapshot.appendSections([.mode])
snapshot.appendItems([.mode], toSection: .mode)
snapshot.reconfigureItems(snapshot.itemIdentifiers)
dataSource.apply(snapshot, animatingDifferences: true)
}
private func appendTemplates(
_ category: TravelAlbumAIRetouchTemplateCategory,
to snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>
) {
let section = Section.templates(category)
snapshot.appendSections([section])
snapshot.appendItems(
viewModel.templates(for: category).map { Item.template(category, $0) },
toSection: section
)
}
@objc private func cancelTapped() {
guard !viewModel.isSubmitting else { return }
dismiss(animated: true)
}
@objc private func confirmTapped() {
guard viewModel.canSubmit else {
if let message = viewModel.validationMessage { showToast(message) }
return
}
Task { await viewModel.submit(api: api) }
}
@objc private func retryTapped() {
Task { await viewModel.loadTemplates(api: api) }
}
private func showPreview(for template: TravelAlbumAIRetouchTemplate) {
guard let content = template.comparisonContent else {
showToast("暂无对比预览")
return
}
let controller = BeforeAfterComparisonViewController(
viewModel: BeforeAfterComparisonViewModel(content: content)
)
present(controller, animated: true)
}
}
extension TravelAlbumAIRetouchTemplateViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = dataSource.itemIdentifier(for: indexPath),
case .template(let category, let template) = item else { return }
collectionView.scrollToItem(
at: indexPath,
at: .centeredHorizontally,
animated: !UIAccessibility.isReduceMotionEnabled
)
viewModel.toggleTemplate(id: template.id, category: category)
}
}
/// AI 修图模板页顶部说明卡,随模板列表一起滚动。
final class TravelAlbumAIRetouchOverviewCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumAIRetouchOverviewCell"
private let stackView = UIStackView()
private let titleLabel = UILabel()
private let selectionCountLabel = UILabel()
private let tipsContainer = UIView()
private let tipsIconContainer = UIView()
private let tipsIconView = UIImageView()
private let tipsLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
titleLabel.text = "AI修图"
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
titleLabel.font = .systemFont(ofSize: 22, weight: .bold)
titleLabel.textAlignment = .center
titleLabel.accessibilityTraits = .header
selectionCountLabel.textColor = AIRetouchTemplateStyle.textSecondary
selectionCountLabel.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(
for: .systemFont(ofSize: 15, weight: .regular)
)
selectionCountLabel.adjustsFontForContentSizeCategory = true
selectionCountLabel.textAlignment = .center
selectionCountLabel.accessibilityIdentifier = "travelAlbum.aiRetouchSelectionCountLabel"
tipsContainer.backgroundColor = AIRetouchTemplateStyle.tipsBackground
tipsContainer.layer.cornerRadius = 12
tipsContainer.layer.borderWidth = 1
tipsContainer.layer.borderColor = AIRetouchTemplateStyle.tipsBorder.cgColor
tipsContainer.accessibilityIdentifier = "travelAlbum.aiRetouchTipsContainer"
tipsIconContainer.backgroundColor = AIRetouchTemplateStyle.primary
tipsIconContainer.layer.cornerRadius = 8
tipsIconView.image = UIImage(systemName: "wand.and.stars")
tipsIconView.tintColor = .white
tipsIconView.contentMode = .scaleAspectFit
tipsLabel.textColor = AIRetouchTemplateStyle.tipsText
tipsLabel.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(
for: .systemFont(ofSize: 12, weight: .regular)
)
tipsLabel.adjustsFontForContentSizeCategory = true
tipsLabel.numberOfLines = 2
tipsLabel.accessibilityIdentifier = "travelAlbum.aiRetouchTipsLabel"
stackView.axis = .vertical
stackView.alignment = .fill
stackView.spacing = 2
stackView.addArrangedSubview(titleLabel)
stackView.addArrangedSubview(selectionCountLabel)
stackView.addArrangedSubview(tipsContainer)
stackView.setCustomSpacing(14, after: selectionCountLabel)
contentView.addSubview(stackView)
tipsContainer.addSubview(tipsIconContainer)
tipsIconContainer.addSubview(tipsIconView)
tipsContainer.addSubview(tipsLabel)
stackView.snp.makeConstraints { make in
make.top.equalToSuperview().offset(8)
make.leading.trailing.equalToSuperview().inset(18)
make.bottom.lessThanOrEqualToSuperview().offset(-8)
}
titleLabel.snp.makeConstraints { make in
make.height.equalTo(32)
}
selectionCountLabel.snp.makeConstraints { make in
make.height.equalTo(22)
}
tipsContainer.snp.makeConstraints { make in
make.height.equalTo(52)
}
tipsIconContainer.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
make.size.equalTo(32)
}
tipsIconView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(7)
}
tipsLabel.snp.makeConstraints { make in
make.leading.equalTo(tipsIconContainer.snp.trailing).offset(10)
make.trailing.equalToSuperview().offset(-12)
make.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 更新选中照片数量和当前工作流说明。
func apply(selectedPhotoCount: Int, tips: String?) {
selectionCountLabel.text = "已选择 \(selectedPhotoCount) 张照片"
tipsLabel.text = tips
tipsContainer.isHidden = tips == nil
tipsIconContainer.isHidden = tips == nil
tipsLabel.isHidden = tips == nil
}
}
/// AI 修图模板卡片,展示模板预览、名称和明确选中态。
final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumAIRetouchTemplateCell"
private let previewImageView = UIImageView()
private let nameLabel = UILabel()
private let checkView = UIImageView()
private let previewButton = AIRetouchTemplatePreviewButton(type: .system)
var onPreviewTapped: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = AIRetouchTemplateStyle.cardBackground
contentView.layer.cornerRadius = 12
contentView.layer.borderWidth = 1
contentView.clipsToBounds = true
layer.shadowColor = UIColor(hex: 0x254A7A).cgColor
layer.shadowOpacity = 0.08
layer.shadowRadius = 7
layer.shadowOffset = CGSize(width: 0, height: 3)
layer.masksToBounds = false
previewImageView.contentMode = .scaleAspectFill
previewImageView.clipsToBounds = true
previewImageView.backgroundColor = UIColor(hex: 0xEDF2F8)
previewImageView.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateImage"
nameLabel.font = .systemFont(ofSize: 13, weight: .semibold)
nameLabel.textColor = AIRetouchTemplateStyle.textPrimary
nameLabel.textAlignment = .center
nameLabel.lineBreakMode = .byTruncatingTail
checkView.image = UIImage(systemName: "checkmark.circle.fill")
checkView.tintColor = AIRetouchTemplateStyle.primary
checkView.backgroundColor = .white
checkView.layer.cornerRadius = 11
var previewConfiguration = UIButton.Configuration.filled()
previewConfiguration.title = "预览"
previewConfiguration.baseForegroundColor = .white
previewConfiguration.baseBackgroundColor = UIColor.black.withAlphaComponent(0.58)
previewConfiguration.background.cornerRadius = 14
previewConfiguration.contentInsets = .zero
previewConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer {
attributes in
var attributes = attributes
attributes.font = .systemFont(ofSize: 12, weight: .regular)
return attributes
}
previewButton.configuration = previewConfiguration
previewButton.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreviewButton"
previewButton.addTarget(self, action: #selector(previewTapped), for: .touchUpInside)
contentView.addSubview(previewImageView)
contentView.addSubview(nameLabel)
contentView.addSubview(checkView)
contentView.addSubview(previewButton)
previewImageView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(118)
}
nameLabel.snp.makeConstraints { make in
make.top.equalTo(previewImageView.snp.bottom)
make.leading.trailing.equalToSuperview().inset(6)
make.bottom.equalToSuperview()
}
checkView.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(7)
make.size.equalTo(22)
}
previewButton.snp.makeConstraints { make in
make.trailing.bottom.equalTo(previewImageView).inset(4)
make.width.equalTo(48)
make.height.equalTo(28)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
previewImageView.kf.cancelDownloadTask()
previewImageView.image = nil
onPreviewTapped = nil
}
/// 更新模板内容与选中状态。
func apply(template: TravelAlbumAIRetouchTemplate, selected: Bool) {
nameLabel.text = template.name
let placeholder = UIImage(named: "ai_retouch_template_placeholder")
if let url = URL(string: template.previewURL), !template.previewURL.isEmpty {
previewImageView.contentMode = .scaleAspectFill
previewImageView.kf.setImage(
with: url,
placeholder: placeholder
)
} else {
previewImageView.image = placeholder
previewImageView.contentMode = .scaleAspectFill
}
contentView.layer.borderColor = (
selected ? AIRetouchTemplateStyle.primary : AIRetouchTemplateStyle.border
).cgColor
contentView.layer.borderWidth = selected ? 2 : 1
layer.shadowOpacity = selected ? 0.16 : 0.08
checkView.isHidden = !selected
isSelected = selected
accessibilityLabel = template.name
accessibilityValue = selected ? "已选择" : "未选择"
accessibilityTraits = selected ? [.button, .selected] : [.button]
}
@objc private func previewTapped() {
onPreviewTapped?()
}
}
/// 扩大模板预览按钮的触摸区域,同时保持截图中的胶囊尺寸。
private final class AIRetouchTemplatePreviewButton: UIButton {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
bounds.insetBy(dx: -8, dy: -8).contains(point)
}
}
/// 模板选择页主操作按钮,使用品牌蓝渐变并支持禁用态切换。
private final class AIRetouchGradientButton: UIButton {
private let gradientLayer = CAGradientLayer()
private let activityIndicator = UIActivityIndicatorView(style: .medium)
override init(frame: CGRect) {
super.init(frame: frame)
gradientLayer.colors = [
AIRetouchTemplateStyle.primary.cgColor,
AIRetouchTemplateStyle.primaryGradientEnd.cgColor,
]
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
gradientLayer.cornerRadius = 12
layer.insertSublayer(gradientLayer, at: 0)
activityIndicator.color = .white
activityIndicator.hidesWhenStopped = true
activityIndicator.isUserInteractionEnabled = false
addSubview(activityIndicator)
clipsToBounds = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
gradientLayer.frame = bounds
activityIndicator.center = CGPoint(x: bounds.midX - 44, y: bounds.midY)
}
/// 根据提交条件切换品牌渐变和禁用灰色。
func setGradientEnabled(_ enabled: Bool) {
gradientLayer.colors = enabled
? [
AIRetouchTemplateStyle.primary.cgColor,
AIRetouchTemplateStyle.primaryGradientEnd.cgColor,
]
: [
AIRetouchTemplateStyle.disabledStart.cgColor,
AIRetouchTemplateStyle.disabledEnd.cgColor,
]
}
/// 切换提交中状态,并在标题左侧展示活动指示器。
func setLoading(_ loading: Bool) {
if loading {
activityIndicator.startAnimating()
} else {
activityIndicator.stopAnimating()
}
setNeedsLayout()
}
}
/// AI 修图计费模式卡,展示按张收费规则与剩余额度。
final class TravelAlbumAIRetouchModeCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumAIRetouchModeCell"
private let iconContainer = UIView()
private let iconView = UIImageView()
private let modeLabel = UILabel()
private let quotaStack = UIStackView()
private let requiredQuotaContainer = UIView()
private let requiredQuotaLabel = UILabel()
private let quotaLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .white
contentView.layer.cornerRadius = 12
contentView.layer.borderWidth = 1
contentView.layer.borderColor = AIRetouchTemplateStyle.border.cgColor
contentView.layer.shadowColor = UIColor(hex: 0x254A7A).cgColor
contentView.layer.shadowOpacity = 0.06
contentView.layer.shadowRadius = 8
contentView.layer.shadowOffset = CGSize(width: 0, height: 3)
iconContainer.backgroundColor = AIRetouchTemplateStyle.primary
iconContainer.layer.cornerRadius = 10
iconView.image = UIImage(systemName: "wand.and.stars")
iconView.tintColor = .white
iconView.contentMode = .scaleAspectFit
modeLabel.text = "AI精修 · 按张收费"
modeLabel.textColor = AIRetouchTemplateStyle.textPrimary
modeLabel.font = .systemFont(ofSize: 15, weight: .semibold)
quotaStack.axis = .vertical
quotaStack.alignment = .trailing
quotaStack.spacing = 3
requiredQuotaContainer.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
requiredQuotaContainer.layer.cornerRadius = 12
requiredQuotaLabel.font = .monospacedDigitSystemFont(ofSize: 12, weight: .semibold)
requiredQuotaLabel.textColor = AIRetouchTemplateStyle.primary
requiredQuotaLabel.textAlignment = .center
requiredQuotaLabel.accessibilityIdentifier = "travelAlbum.aiRetouchRequiredQuotaLabel"
quotaLabel.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular)
quotaLabel.textColor = AIRetouchTemplateStyle.textSecondary
quotaLabel.textAlignment = .right
quotaLabel.accessibilityIdentifier = "travelAlbum.aiRetouchRemainingQuotaLabel"
contentView.addSubview(iconContainer)
iconContainer.addSubview(iconView)
contentView.addSubview(modeLabel)
contentView.addSubview(quotaStack)
quotaStack.addArrangedSubview(requiredQuotaContainer)
requiredQuotaContainer.addSubview(requiredQuotaLabel)
quotaStack.addArrangedSubview(quotaLabel)
iconContainer.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(14)
make.centerY.equalToSuperview()
make.size.equalTo(38)
}
iconView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(9)
}
modeLabel.snp.makeConstraints { make in
make.leading.equalTo(iconContainer.snp.trailing).offset(10)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualTo(quotaStack.snp.leading).offset(-10)
}
quotaStack.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-14)
make.centerY.equalToSuperview()
}
requiredQuotaContainer.snp.makeConstraints { make in
make.height.equalTo(24)
make.width.greaterThanOrEqualTo(108)
}
requiredQuotaLabel.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 更新预计消耗和剩余精修张数,并在额度不足时切换为警示色。
func apply(requiredQuota: Int, remainingQuota: Int?, insufficient: Bool) {
requiredQuotaLabel.text = "预计消耗 \(requiredQuota) 张"
requiredQuotaLabel.accessibilityLabel = "预计消耗\(requiredQuota)张修图额度"
quotaLabel.text = remainingQuota.map { "剩余 \($0) 张" } ?? "剩余 -- 张"
quotaLabel.accessibilityLabel = remainingQuota.map { "剩余\($0)张修图额度" }
?? "剩余修图额度暂不可用"
requiredQuotaLabel.textColor = insufficient
? AIRetouchTemplateStyle.danger
: AIRetouchTemplateStyle.primary
requiredQuotaContainer.backgroundColor = requiredQuotaLabel.textColor.withAlphaComponent(0.1)
quotaLabel.textColor = insufficient ? AIRetouchTemplateStyle.danger : AIRetouchTemplateStyle.textSecondary
}
}
/// 模板分组标题右侧的业务标记。
fileprivate enum AIRetouchTemplateSectionBadge {
case optional
case gift
}
/// AI 修图模板分组标题,可附带选填或赠送标记。
final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
static let reuseIdentifier = "TravelAlbumAIRetouchSectionHeader"
private let titleLabel = UILabel()
private let badgeContainer = UIView()
private let badgeLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
badgeContainer.layer.cornerRadius = 10
badgeContainer.clipsToBounds = true
badgeLabel.font = .systemFont(ofSize: 11, weight: .medium)
badgeLabel.textAlignment = .center
badgeLabel.numberOfLines = 1
addSubview(titleLabel)
addSubview(badgeContainer)
badgeContainer.addSubview(badgeLabel)
titleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(18)
make.centerY.equalToSuperview()
}
badgeContainer.snp.makeConstraints { make in
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
make.centerY.equalTo(titleLabel)
make.height.equalTo(20)
make.trailing.lessThanOrEqualToSuperview().offset(-18)
}
badgeLabel.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(9)
make.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 更新分组标题与业务标记。
fileprivate func apply(title: String, badge: AIRetouchTemplateSectionBadge?) {
titleLabel.text = title
badgeContainer.isHidden = badge == nil
switch badge {
case .optional:
badgeLabel.text = "选填"
badgeLabel.textColor = AIRetouchTemplateStyle.primary
badgeContainer.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
case .gift:
badgeLabel.text = "赠送 · 不占额度"
badgeLabel.textColor = AIRetouchTemplateStyle.gift
badgeContainer.backgroundColor = AIRetouchTemplateStyle.giftBackground
case nil:
badgeLabel.text = nil
}
let badgeText = badgeLabel.text
accessibilityLabel = badgeText.map { "\(title),\($0)" } ?? title
accessibilityTraits = .header
}
}
/// AI 修图模板页视觉常量。
private enum AIRetouchTemplateStyle {
static let primary = UIColor(hex: 0x1677FF)
static let primaryGradientEnd = UIColor(hex: 0x4B9BFF)
static let pageBackground = UIColor(hex: 0xF7FAFF)
static let cardBackground = UIColor.white
static let textPrimary = UIColor(hex: 0x172033)
static let textSecondary = UIColor(hex: 0x7D8799)
static let tipsBackground = UIColor(hex: 0xF4F8FF)
static let tipsBorder = UIColor(hex: 0xD9E7FA)
static let tipsText = UIColor(hex: 0x385778)
static let gift = UIColor(hex: 0xFF9418)
static let giftBackground = UIColor(hex: 0xFFF1D9)
static let border = UIColor(hex: 0xDCE4EF)
static let danger = UIColor(hex: 0xE53935)
static let disabledStart = UIColor(hex: 0xA9C8EE)
static let disabledEnd = UIColor(hex: 0xC6DAF3)
}
@@ -7,30 +7,41 @@ import Kingfisher
import SnapKit import SnapKit
import UIKit import UIKit
/// 相册详情管理页,对齐 Android `TravelAlbumDetailScreen`。 /// 相册管理页,展示相册摘要、素材筛选排序、分页与批量操作。
final class TravelAlbumDetailViewController: BaseViewController { final class TravelAlbumDetailViewController: BaseViewController {
private let viewModel: TravelAlbumDetailViewModel private let viewModel: TravelAlbumDetailViewModel
private let api: any TravelAlbumServing private let api: any TravelAlbumServing
private let previewConfiguration: TravelAlbumPreviewConfiguration
private let scenicIdProvider: () -> Int
private let scrollContainer = UIView() private let contentView = UIView()
private let infoCard = TravelAlbumInfoCard() private let infoCard = TravelAlbumInfoCard()
private let manageCard = UIView() private let sectionTitleLabel = UILabel()
private let tabStack = UIStackView() private let segmentedControl = UIView()
private let allTabButton = UIButton(type: .system) private let allTabButton = UIButton(type: .system)
private let purchasedTabButton = UIButton(type: .system) private let purchasedTabButton = UIButton(type: .system)
private let sortButton = UIButton(type: .system) private let sortButton = UIButton(type: .system)
private let selectButton = UIButton(type: .system) private let selectButton = UIButton(type: .system)
private let refreshControl = UIRefreshControl()
private var collectionView: UICollectionView! private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>! private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>!
private let bottomBar = UIView() private let bottomBar = UIView()
private let bottomActionStack = UIStackView() private let bottomDivider = UIView()
private let selectionActionStack = UIStackView()
private let aiRetouchButton = UIButton(type: .system)
private let deleteSelectedButton = UIButton(type: .system) private let deleteSelectedButton = UIButton(type: .system)
private let uploadButton = UIButton(type: .system) private let uploadButton = UIButton(type: .system)
private var isDeleteSelectedButtonVisible = false
init(albumId: Int, api: (any TravelAlbumServing)? = nil) { init(
albumId: Int,
api: (any TravelAlbumServing)? = nil,
previewConfiguration: TravelAlbumPreviewConfiguration = .init(),
scenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }
) {
viewModel = TravelAlbumDetailViewModel(albumId: albumId) viewModel = TravelAlbumDetailViewModel(albumId: albumId)
self.api = api ?? NetworkServices.shared.travelAlbumAPI self.api = api ?? NetworkServices.shared.travelAlbumAPI
self.previewConfiguration = previewConfiguration
self.scenicIdProvider = scenicIdProvider
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
@@ -40,8 +51,21 @@ final class TravelAlbumDetailViewController: BaseViewController {
} }
override func setupNavigationBar() { override func setupNavigationBar() {
title = "相册管理" let titleLabel = UILabel()
navigationItem.rightBarButtonItem = UIBarButtonItem( titleLabel.text = "相册管理"
titleLabel.textColor = TravelAlbumDetailStyle.textPrimary
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
navigationItem.titleView = titleLabel
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = .white
appearance.shadowColor = .clear
navigationItem.standardAppearance = appearance
navigationItem.scrollEdgeAppearance = appearance
navigationItem.compactAppearance = appearance
let moreItem = UIBarButtonItem(
image: UIImage(systemName: "ellipsis"), image: UIImage(systemName: "ellipsis"),
menu: UIMenu(children: [ menu: UIMenu(children: [
UIAction(title: "删除相册", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in UIAction(title: "删除相册", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
@@ -49,29 +73,50 @@ final class TravelAlbumDetailViewController: BaseViewController {
}, },
]) ])
) )
moreItem.accessibilityLabel = "更多操作"
let taskItem = UIBarButtonItem(
title: "修图任务",
style: .plain,
target: self,
action: #selector(openAIJobList)
)
taskItem.accessibilityLabel = "查看AI修图任务"
navigationItem.rightBarButtonItems = [moreItem, taskItem]
}
@objc private func openAIJobList() {
navigationController?.pushViewController(TravelAlbumAIJobListViewController(api: api), animated: true)
} }
override func setupUI() { override func setupUI() {
view.backgroundColor = AppColor.pageBackground view.backgroundColor = TravelAlbumDetailStyle.pageBackground
manageCard.backgroundColor = .white sectionTitleLabel.text = "照片"
manageCard.layer.cornerRadius = 12 sectionTitleLabel.textColor = TravelAlbumDetailStyle.textPrimary
manageCard.layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor sectionTitleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
manageCard.layer.shadowOpacity = 1
manageCard.layer.shadowRadius = 6
manageCard.layer.shadowOffset = CGSize(width: 0, height: 2)
tabStack.axis = .horizontal segmentedControl.backgroundColor = .white
tabStack.spacing = 8 segmentedControl.layer.cornerRadius = 9
configurePillButton(allTabButton) segmentedControl.layer.borderWidth = 1
configurePillButton(purchasedTabButton) segmentedControl.layer.borderColor = TravelAlbumDetailStyle.border.cgColor
configureIconButton(sortButton, image: UIImage(systemName: "line.3.horizontal.decrease.circle.fill")) segmentedControl.clipsToBounds = true
configureIconButton(selectButton, image: UIImage(systemName: "circle")) configureTabButtonBase(allTabButton)
configureTabButtonBase(purchasedTabButton)
configureSortButton()
configureSelectButton()
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
collectionView.backgroundColor = .white collectionView.backgroundColor = .clear
collectionView.alwaysBounceVertical = true
collectionView.showsVerticalScrollIndicator = false
collectionView.delegate = self collectionView.delegate = self
collectionView.register(TravelAlbumMaterialCell.self, forCellWithReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier) refreshControl.tintColor = TravelAlbumDetailStyle.primary
refreshControl.accessibilityIdentifier = "travelAlbum.refreshControl"
collectionView.refreshControl = refreshControl
collectionView.register(
TravelAlbumMaterialCell.self,
forCellWithReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier
)
dataSource = UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>(collectionView: collectionView) { dataSource = UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>(collectionView: collectionView) {
[weak self] collectionView, indexPath, material in [weak self] collectionView, indexPath, material in
let cell = collectionView.dequeueReusableCell( let cell = collectionView.dequeueReusableCell(
@@ -86,74 +131,105 @@ final class TravelAlbumDetailViewController: BaseViewController {
return cell return cell
} }
bottomBar.backgroundColor = AppColor.pageBackground bottomBar.backgroundColor = .white
bottomActionStack.axis = .vertical bottomDivider.backgroundColor = TravelAlbumDetailStyle.border.withAlphaComponent(0.65)
bottomActionStack.spacing = 8 configureBottomButton(uploadButton, title: "上传照片", color: TravelAlbumDetailStyle.primary)
configureBottomButton(deleteSelectedButton, title: "删除选中(0)", color: UIColor(hex: 0xE53935)) var uploadConfiguration = uploadButton.configuration
configureBottomButton(uploadButton, title: "上传照片", color: AppColor.primary) uploadConfiguration?.image = UIImage(systemName: "plus.circle.fill")
deleteSelectedButton.isHidden = true uploadConfiguration?.imagePadding = 8
deleteSelectedButton.alpha = 0 uploadButton.configuration = uploadConfiguration
uploadButton.accessibilityLabel = "上传照片"
selectionActionStack.axis = .horizontal
selectionActionStack.alignment = .fill
selectionActionStack.distribution = .fillEqually
selectionActionStack.spacing = 12
selectionActionStack.isHidden = true
selectionActionStack.accessibilityIdentifier = "travelAlbum.selectionActionStack"
configureBottomButton(aiRetouchButton, title: "AI修图", color: TravelAlbumDetailStyle.primary)
aiRetouchButton.accessibilityLabel = "AI修图"
aiRetouchButton.accessibilityIdentifier = "travelAlbum.aiRetouchButton"
configureBottomButton(deleteSelectedButton, title: "删除", color: TravelAlbumDetailStyle.danger)
deleteSelectedButton.accessibilityLabel = "删除"
deleteSelectedButton.accessibilityIdentifier = "travelAlbum.deleteButton"
selectionActionStack.addArrangedSubview(aiRetouchButton)
selectionActionStack.addArrangedSubview(deleteSelectedButton)
view.addSubview(scrollContainer) view.addSubview(contentView)
scrollContainer.addSubview(infoCard) contentView.addSubview(infoCard)
scrollContainer.addSubview(manageCard) contentView.addSubview(sectionTitleLabel)
manageCard.addSubview(tabStack) contentView.addSubview(segmentedControl)
tabStack.addArrangedSubview(allTabButton) segmentedControl.addSubview(allTabButton)
tabStack.addArrangedSubview(purchasedTabButton) segmentedControl.addSubview(purchasedTabButton)
manageCard.addSubview(sortButton) contentView.addSubview(sortButton)
manageCard.addSubview(selectButton) contentView.addSubview(selectButton)
manageCard.addSubview(collectionView) contentView.addSubview(collectionView)
view.addSubview(bottomBar) view.addSubview(bottomBar)
bottomBar.addSubview(bottomActionStack) bottomBar.addSubview(bottomDivider)
bottomActionStack.addArrangedSubview(deleteSelectedButton) bottomBar.addSubview(uploadButton)
bottomActionStack.addArrangedSubview(uploadButton) bottomBar.addSubview(selectionActionStack)
} }
override func setupConstraints() { override func setupConstraints() {
bottomBar.snp.makeConstraints { make in bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview() make.leading.trailing.bottom.equalToSuperview()
} }
bottomActionStack.snp.makeConstraints { make in bottomDivider.snp.makeConstraints { make in
make.top.equalToSuperview().offset(12) make.top.leading.trailing.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(20) make.height.equalTo(0.5)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
deleteSelectedButton.snp.makeConstraints { make in
make.height.equalTo(48).priority(.high)
} }
uploadButton.snp.makeConstraints { make in uploadButton.snp.makeConstraints { make in
make.height.equalTo(48).priority(.high) make.top.equalToSuperview().offset(12)
make.leading.trailing.equalToSuperview().inset(18)
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
} }
scrollContainer.snp.makeConstraints { make in selectionActionStack.snp.makeConstraints { make in
make.edges.equalTo(uploadButton)
}
contentView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12) make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
make.leading.trailing.equalToSuperview().inset(16) make.leading.trailing.equalToSuperview().inset(18)
make.bottom.equalTo(bottomBar.snp.top).offset(-8) make.bottom.equalTo(bottomBar.snp.top)
} }
infoCard.snp.makeConstraints { make in infoCard.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview() make.top.leading.trailing.equalToSuperview()
make.height.greaterThanOrEqualTo(76) make.height.equalTo(116)
} }
manageCard.snp.makeConstraints { make in sectionTitleLabel.snp.makeConstraints { make in
make.top.equalTo(infoCard.snp.bottom).offset(12) make.top.equalTo(infoCard.snp.bottom).offset(20)
make.leading.trailing.bottom.equalToSuperview() make.leading.equalToSuperview()
} }
tabStack.snp.makeConstraints { make in segmentedControl.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(12) make.top.equalTo(sectionTitleLabel.snp.bottom).offset(14)
make.leading.equalToSuperview()
make.width.equalTo(182)
make.height.equalTo(32)
}
allTabButton.snp.makeConstraints { make in
make.top.bottom.leading.equalToSuperview()
make.width.equalToSuperview().multipliedBy(0.5)
}
purchasedTabButton.snp.makeConstraints { make in
make.top.bottom.trailing.equalToSuperview()
make.leading.equalTo(allTabButton.snp.trailing)
}
selectButton.snp.makeConstraints { make in
make.centerY.equalTo(segmentedControl)
make.trailing.equalToSuperview()
make.width.equalTo(48)
make.height.equalTo(32) make.height.equalTo(32)
} }
sortButton.snp.makeConstraints { make in sortButton.snp.makeConstraints { make in
make.centerY.equalTo(tabStack) make.centerY.equalTo(segmentedControl)
make.trailing.equalTo(selectButton.snp.leading).offset(-8) make.trailing.equalTo(selectButton.snp.leading).offset(-8)
make.size.equalTo(32) make.width.equalTo(74)
} make.height.equalTo(32)
selectButton.snp.makeConstraints { make in make.leading.greaterThanOrEqualTo(segmentedControl.snp.trailing).offset(8)
make.centerY.equalTo(tabStack)
make.trailing.equalToSuperview().offset(-12)
make.size.equalTo(32)
} }
collectionView.snp.makeConstraints { make in collectionView.snp.makeConstraints { make in
make.top.equalTo(tabStack.snp.bottom).offset(12) make.top.equalTo(segmentedControl.snp.bottom).offset(14)
make.leading.trailing.bottom.equalToSuperview().inset(12) make.leading.trailing.bottom.equalToSuperview()
} }
} }
@@ -161,10 +237,11 @@ final class TravelAlbumDetailViewController: BaseViewController {
infoCard.onCall = { [weak self] phone in self?.call(phone) } infoCard.onCall = { [weak self] phone in self?.call(phone) }
allTabButton.addTarget(self, action: #selector(allTabTapped), for: .touchUpInside) allTabButton.addTarget(self, action: #selector(allTabTapped), for: .touchUpInside)
purchasedTabButton.addTarget(self, action: #selector(purchasedTabTapped), for: .touchUpInside) purchasedTabButton.addTarget(self, action: #selector(purchasedTabTapped), for: .touchUpInside)
sortButton.addTarget(self, action: #selector(sortTapped), for: .touchUpInside)
selectButton.addTarget(self, action: #selector(selectTapped), for: .touchUpInside) selectButton.addTarget(self, action: #selector(selectTapped), for: .touchUpInside)
aiRetouchButton.addTarget(self, action: #selector(aiRetouchTapped), for: .touchUpInside)
deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside) deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside)
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside) uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
viewModel.onStateChange = { [weak self] in viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() } Task { @MainActor in self?.applyViewModel() }
} }
@@ -188,15 +265,40 @@ final class TravelAlbumDetailViewController: BaseViewController {
@MainActor @MainActor
private func applyViewModel() { private func applyViewModel() {
if let album = viewModel.album { if let album = viewModel.album {
infoCard.apply(album: album) infoCard.apply(album: album, coverURL: viewModel.displayCoverURL)
} }
configureTabButton(allTabButton, title: "全部照片\(viewModel.allPhotoCount)", selected: viewModel.selectedTab == .all) configureTabButton(allTabButton, title: "全部 \(viewModel.allPhotoCount)", selected: viewModel.selectedTab == .all)
configureTabButton(purchasedTabButton, title: "已购照片", selected: viewModel.selectedTab == .purchased) configureTabButton(
selectButton.isHidden = viewModel.selectedTab != .all purchasedTabButton,
selectButton.setImage(UIImage(systemName: viewModel.isSelectionMode ? "checkmark.circle.fill" : "circle"), for: .normal) title: "已购 \(viewModel.purchasedPhotoCount)",
selectButton.tintColor = viewModel.isSelectionMode ? AppColor.primary : AppColor.textTertiary selected: viewModel.selectedTab == .purchased
deleteSelectedButton.setTitle("删除选中(\(viewModel.selectedMaterialIds.count))", for: .normal) )
setDeleteSelectedButtonVisible(viewModel.isSelectionMode && !viewModel.selectedMaterialIds.isEmpty)
var sortConfiguration = sortButton.configuration
sortConfiguration?.title = viewModel.sortOption.compactTitle
sortButton.configuration = sortConfiguration
sortButton.accessibilityValue = viewModel.sortOption.title
sortButton.menu = makeSortMenu()
let canSelectMaterials = viewModel.selectedTab == .all
selectButton.isHidden = false
selectButton.isEnabled = canSelectMaterials
selectButton.alpha = canSelectMaterials ? 1 : 0.45
selectButton.setTitle(viewModel.isSelectionMode ? "完成" : "选择", for: .normal)
selectButton.accessibilityValue = canSelectMaterials
? (viewModel.isSelectionMode ? "选择模式已开启" : "选择模式已关闭")
: "已购照片不可删除"
let selectedCount = viewModel.selectedMaterialIds.count
let hasSelection = selectedCount > 0
aiRetouchButton.isEnabled = hasSelection
aiRetouchButton.alpha = hasSelection ? 1 : 0.45
aiRetouchButton.accessibilityValue = "已选择 \(selectedCount) 张照片"
deleteSelectedButton.isEnabled = selectedCount > 0
deleteSelectedButton.alpha = hasSelection ? 1 : 0.45
deleteSelectedButton.accessibilityValue = "已选择 \(selectedCount) 张照片"
selectionActionStack.isHidden = !viewModel.isSelectionMode
uploadButton.isHidden = viewModel.isSelectionMode
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumMaterial>() var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumMaterial>()
snapshot.appendSections([0]) snapshot.appendSections([0])
@@ -206,76 +308,109 @@ final class TravelAlbumDetailViewController: BaseViewController {
} }
dataSource.apply(snapshot, animatingDifferences: true) dataSource.apply(snapshot, animatingDifferences: true)
if viewModel.isLoading && viewModel.album == nil { if !viewModel.isRefreshing {
refreshControl.endRefreshing()
}
if viewModel.isLoading && viewModel.album == nil && !refreshControl.isRefreshing {
showLoading() showLoading()
} else { } else {
hideLoading() hideLoading()
} }
} }
private func configurePillButton(_ button: UIButton) { @objc private func refreshPulled() {
Task { await viewModel.refreshAll(api: api) }
}
private func configureTabButtonBase(_ button: UIButton) {
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium) button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
button.layer.cornerRadius = 16 button.accessibilityTraits.insert(.button)
button.setConfigurationContentInsets(
NSDirectionalEdgeInsets(top: 7, leading: 14, bottom: 7, trailing: 14)
)
} }
private func configureTabButton(_ button: UIButton, title: String, selected: Bool) { private func configureTabButton(_ button: UIButton, title: String, selected: Bool) {
button.setTitle(title, for: .normal) button.setTitle(title, for: .normal)
button.backgroundColor = selected ? AppColor.primary : UIColor(hex: 0xEAF4FF) button.backgroundColor = selected ? TravelAlbumDetailStyle.primary : .white
button.setTitleColor(selected ? .white : AppColor.primary, for: .normal) button.setTitleColor(selected ? .white : TravelAlbumDetailStyle.textSecondary, for: .normal)
button.accessibilityTraits = selected ? [.button, .selected] : [.button]
} }
private func configureIconButton(_ button: UIButton, image: UIImage?) { private func configureSortButton() {
button.setImage(image, for: .normal) var configuration = UIButton.Configuration.plain()
button.backgroundColor = UIColor(hex: 0xEAF4FF) configuration.image = UIImage(named: "travel_album_sort_icon")
button.tintColor = AppColor.primary configuration.imagePadding = 4
button.layer.cornerRadius = 16 configuration.title = viewModel.sortOption.compactTitle
configuration.baseForegroundColor = TravelAlbumDetailStyle.textPrimary
configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 7, bottom: 0, trailing: 7)
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = .systemFont(ofSize: 11, weight: .medium)
return attributes
}
sortButton.configuration = configuration
sortButton.backgroundColor = .white
sortButton.layer.cornerRadius = 9
sortButton.layer.borderWidth = 1
sortButton.layer.borderColor = TravelAlbumDetailStyle.border.cgColor
sortButton.showsMenuAsPrimaryAction = true
sortButton.accessibilityLabel = "排序"
}
private func configureSelectButton() {
selectButton.setTitle("选择", for: .normal)
selectButton.setTitleColor(TravelAlbumDetailStyle.primary, for: .normal)
selectButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
selectButton.backgroundColor = .white
selectButton.layer.cornerRadius = 9
selectButton.layer.borderWidth = 1
selectButton.layer.borderColor = TravelAlbumDetailStyle.primary.withAlphaComponent(0.45).cgColor
selectButton.accessibilityLabel = "选择照片"
} }
private func configureBottomButton(_ button: UIButton, title: String, color: UIColor) { private func configureBottomButton(_ button: UIButton, title: String, color: UIColor) {
button.setTitle(title, for: .normal) var configuration = UIButton.Configuration.filled()
button.setTitleColor(.white, for: .normal) configuration.title = title
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) configuration.baseBackgroundColor = color
button.backgroundColor = color configuration.baseForegroundColor = .white
button.layer.cornerRadius = 10 configuration.cornerStyle = .fixed
configuration.background.cornerRadius = 14
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = .systemFont(ofSize: 16, weight: .semibold)
return attributes
}
button.configuration = configuration
} }
private func setDeleteSelectedButtonVisible(_ visible: Bool) { private func makeSortMenu() -> UIMenu {
guard visible != isDeleteSelectedButtonVisible else { return } let actions = TravelAlbumDetailViewModel.SortOption.allCases.map { option in
isDeleteSelectedButtonVisible = visible UIAction(
title: option.title,
let changes = { state: option == viewModel.sortOption ? .on : .off
self.deleteSelectedButton.isHidden = !visible ) { [weak self] _ in
self.deleteSelectedButton.alpha = visible ? 1 : 0 guard let self else { return }
self.view.layoutIfNeeded() Task { await self.viewModel.setSortOption(option, api: self.api) }
}
} }
guard view.window != nil else { return UIMenu(title: "排序方式", options: .singleSelection, children: actions)
changes()
return
}
view.layoutIfNeeded()
UIView.animate(
withDuration: 0.25,
delay: 0,
options: [.curveEaseInOut, .beginFromCurrentState],
animations: changes
)
} }
private func makeLayout() -> UICollectionViewCompositionalLayout { private func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { _, environment in UICollectionViewCompositionalLayout { _, environment in
let spacing: CGFloat = 8 let spacing: CGFloat = 10
let width = (environment.container.effectiveContentSize.width - spacing * 2) / 3 let width = floor((environment.container.effectiveContentSize.width - spacing * 2) / 3)
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(width), heightDimension: .absolute(width + 38)) let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(width),
heightDimension: .absolute(width + 40)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize) let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(width + 38)) let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(width + 40)
)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 3) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 3)
group.interItemSpacing = .fixed(spacing) group.interItemSpacing = .fixed(spacing)
let section = NSCollectionLayoutSection(group: group) let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 12 section.interGroupSpacing = 14
return section return section
} }
} }
@@ -303,24 +438,54 @@ final class TravelAlbumDetailViewController: BaseViewController {
Task { await viewModel.selectTab(.purchased, api: api) } Task { await viewModel.selectTab(.purchased, api: api) }
} }
@objc private func sortTapped() {
let alert = UIAlertController(title: "排序", message: nil, preferredStyle: .actionSheet)
TravelAlbumDetailViewModel.SortOption.allCases.forEach { option in
alert.addAction(UIAlertAction(title: option.title, style: .default) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.setSortOption(option, api: self.api) }
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
@objc private func selectTapped() { @objc private func selectTapped() {
viewModel.toggleSelectionMode() viewModel.toggleSelectionMode()
} }
@objc private func aiRetouchTapped() {
let materialIds = Array(viewModel.selectedMaterialIds)
guard !materialIds.isEmpty else { return }
let scenicId = scenicIdProvider()
guard scenicId > 0 else {
showToast("请先选择景区")
return
}
let controller = TravelAlbumAIRetouchTemplateViewController(
viewModel: TravelAlbumAIRetouchTemplateViewModel(
albumId: viewModel.albumId,
scenicId: scenicId,
materialIds: materialIds
),
api: api,
onSubmitted: { [weak self] submission in
guard let self else { return }
self.viewModel.completeAIRetouchSubmission()
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() { @objc private func deleteSelectedTapped() {
let count = viewModel.selectedMaterialIds.count let count = viewModel.selectedMaterialIds.count
guard count > 0 else { return }
let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert) let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
@@ -341,12 +506,47 @@ final class TravelAlbumDetailViewController: BaseViewController {
) )
navigationController?.pushViewController(controller, animated: true) navigationController?.pushViewController(controller, animated: true)
} }
private func presentPreview(startingWith material: TravelAlbumMaterial) {
guard let startIndex = viewModel.materials.firstIndex(where: { $0.id == material.id }) else { return }
let previewViewModel = viewModel
let previewAPI = api
let controller = TravelAlbumPhotoPreviewViewController(
projects: viewModel.materials.map(TravelAlbumPreviewProject.init(material:)),
totalCount: viewModel.currentPhotoCount,
startProjectIndex: startIndex,
configuration: previewConfiguration,
actionHandler: TravelAlbumPreviewActionHandler(api: previewAPI),
albumId: viewModel.albumId,
scenicIdProvider: scenicIdProvider,
aiRetouchAPI: previewAPI,
loadMore: {
await previewViewModel.loadMaterials(reset: false, api: previewAPI)
return (
previewViewModel.materials.map(TravelAlbumPreviewProject.init(material:)),
previewViewModel.currentPhotoCount
)
},
reload: { materialId in
let material = try await previewViewModel.refreshMaterial(id: materialId, api: previewAPI)
return TravelAlbumPreviewProject(material: material)
},
onProjectDeleted: { materialId in
previewViewModel.removeMaterialAfterPreviewDeletion(id: materialId)
}
)
present(controller, animated: true)
}
} }
extension TravelAlbumDetailViewController: UICollectionViewDelegate { extension TravelAlbumDetailViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let material = dataSource.itemIdentifier(for: indexPath) else { return } guard let material = dataSource.itemIdentifier(for: indexPath) else { return }
viewModel.toggleMaterialSelection(material) if viewModel.isSelectionMode {
viewModel.toggleMaterialSelection(material)
} else {
presentPreview(startingWith: material)
}
} }
func scrollViewDidScroll(_ scrollView: UIScrollView) { func scrollViewDidScroll(_ scrollView: UIScrollView) {
@@ -355,66 +555,111 @@ extension TravelAlbumDetailViewController: UICollectionViewDelegate {
} }
} }
/// 旅拍相册详情信息卡。 /// 相册管理页专用视觉常量,避免污染全局主题。
private enum TravelAlbumDetailStyle {
static let primary = UIColor(hex: 0x1677FF)
static let pageBackground = UIColor(hex: 0xF8FAFD)
static let textPrimary = UIColor(hex: 0x172033)
static let textSecondary = UIColor(hex: 0x7F8A9E)
static let border = UIColor(hex: 0xDCE4EF)
static let danger = UIColor(hex: 0xE53935)
static func badgeColor(for kind: TravelAlbumMaterialBadgeKind) -> UIColor {
switch kind {
case .purchased: UIColor(hex: 0x475569)
case .pending: UIColor(hex: 0xB45309)
case .processing: UIColor(hex: 0x1D4ED8)
case .retouched: UIColor(hex: 0x047857)
case .cover: UIColor(hex: 0x6D28D9)
case .failed: UIColor(hex: 0xB91C1C)
}
}
}
/// 旅拍相册摘要卡,展示封面、名称、用户手机号与创建时间。
private final class TravelAlbumInfoCard: UIView { private final class TravelAlbumInfoCard: UIView {
var onCall: ((String) -> Void)? var onCall: ((String) -> Void)?
private var phone = "" private var phone = ""
private let iconView = UIImageView(image: UIImage(systemName: "checklist")) private let backgroundImageView = UIImageView(image: UIImage(named: "travel_album_header_background"))
private let coverImageView = UIImageView()
private let coverPhotoIconView = UIImageView(image: UIImage(named: "travel_album_cover_photo_icon"))
private let nameLabel = UILabel()
private let phoneLabel = UILabel() private let phoneLabel = UILabel()
private let timeLabel = UILabel() private let timeLabel = UILabel()
private let callButton = UIButton(type: .system) private let callButton = UIButton(type: .system)
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
backgroundColor = .white clipsToBounds = true
layer.cornerRadius = 12 layer.cornerRadius = 16
layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor
layer.shadowOpacity = 1 backgroundImageView.contentMode = .scaleAspectFill
layer.shadowRadius = 6 coverImageView.contentMode = .scaleAspectFill
layer.shadowOffset = CGSize(width: 0, height: 2) coverImageView.clipsToBounds = true
coverImageView.layer.cornerRadius = 12
coverImageView.backgroundColor = UIColor(hex: 0xDCEEFF)
coverPhotoIconView.contentMode = .scaleAspectFit
coverPhotoIconView.layer.shadowColor = UIColor.black.withAlphaComponent(0.3).cgColor
coverPhotoIconView.layer.shadowOpacity = 1
coverPhotoIconView.layer.shadowRadius = 2
coverPhotoIconView.layer.shadowOffset = .zero
nameLabel.font = .systemFont(ofSize: 16, weight: .semibold)
nameLabel.textColor = TravelAlbumDetailStyle.textPrimary
nameLabel.lineBreakMode = .byTruncatingTail
phoneLabel.font = .systemFont(ofSize: 13, weight: .medium)
phoneLabel.textColor = TravelAlbumDetailStyle.textPrimary
timeLabel.font = .systemFont(ofSize: 11)
timeLabel.textColor = TravelAlbumDetailStyle.textSecondary
timeLabel.adjustsFontSizeToFitWidth = true
timeLabel.minimumScaleFactor = 0.85
let iconBox = UIView()
iconBox.backgroundColor = AppColor.primary
iconBox.layer.cornerRadius = 10
iconView.tintColor = .white
iconView.contentMode = .scaleAspectFit
phoneLabel.font = .systemFont(ofSize: 14, weight: .medium)
phoneLabel.textColor = AppColor.textPrimary
timeLabel.font = .systemFont(ofSize: 12)
timeLabel.textColor = AppColor.textTertiary
callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal) callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal)
callButton.tintColor = AppColor.primary callButton.tintColor = TravelAlbumDetailStyle.primary
callButton.backgroundColor = UIColor(hex: 0xEAF4FF) callButton.backgroundColor = .white.withAlphaComponent(0.9)
callButton.layer.cornerRadius = 20 callButton.layer.cornerRadius = 22
callButton.layer.shadowColor = TravelAlbumDetailStyle.primary.withAlphaComponent(0.18).cgColor
callButton.layer.shadowOpacity = 1
callButton.layer.shadowRadius = 6
callButton.layer.shadowOffset = CGSize(width: 0, height: 2)
callButton.accessibilityLabel = "拨打相册用户电话"
addSubview(iconBox) addSubview(backgroundImageView)
iconBox.addSubview(iconView) addSubview(coverImageView)
coverImageView.addSubview(coverPhotoIconView)
addSubview(nameLabel)
addSubview(phoneLabel) addSubview(phoneLabel)
addSubview(timeLabel) addSubview(timeLabel)
addSubview(callButton) addSubview(callButton)
iconBox.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(14) backgroundImageView.snp.makeConstraints { $0.edges.equalToSuperview() }
make.size.equalTo(48) coverImageView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.top.bottom.equalToSuperview().inset(20)
make.width.equalTo(68)
} }
iconView.snp.makeConstraints { make in coverPhotoIconView.snp.makeConstraints { make in
make.center.equalToSuperview() make.trailing.bottom.equalToSuperview().inset(5)
make.size.equalTo(28) make.size.equalTo(22)
}
callButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-16)
make.centerY.equalToSuperview()
make.size.equalTo(44)
}
nameLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(21)
make.leading.equalTo(coverImageView.snp.trailing).offset(12)
make.trailing.equalTo(callButton.snp.leading).offset(-10)
} }
phoneLabel.snp.makeConstraints { make in phoneLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16) make.top.equalTo(nameLabel.snp.bottom).offset(8)
make.leading.equalTo(iconBox.snp.trailing).offset(12) make.leading.trailing.equalTo(nameLabel)
make.trailing.equalTo(callButton.snp.leading).offset(-12)
} }
timeLabel.snp.makeConstraints { make in timeLabel.snp.makeConstraints { make in
make.top.equalTo(phoneLabel.snp.bottom).offset(6) make.top.equalTo(phoneLabel.snp.bottom).offset(6)
make.leading.trailing.equalTo(phoneLabel) make.leading.trailing.equalTo(nameLabel)
make.bottom.equalToSuperview().offset(-16)
}
callButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-14)
make.centerY.equalToSuperview()
make.size.equalTo(40)
} }
callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside) callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside)
} }
@@ -424,10 +669,21 @@ private final class TravelAlbumInfoCard: UIView {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
func apply(album: TravelAlbum) { func apply(album: TravelAlbum, coverURL: String) {
phone = album.displayPhone phone = album.displayPhone
phoneLabel.text = "手机号 \(TravelAlbumDisplayFormatter.maskPhone(album.displayPhone))" nameLabel.text = album.name.isEmpty ? "旅拍相册" : album.name
timeLabel.text = "创建时间 \(album.createdAt)" phoneLabel.text = TravelAlbumDisplayFormatter.maskPhone(album.displayPhone)
timeLabel.text = "创建于 \(TravelAlbumDisplayFormatter.creationTimeText(album.createdAt))"
if let url = URL(string: coverURL), !coverURL.isEmpty {
coverImageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo.fill"))
coverImageView.tintColor = TravelAlbumDetailStyle.primary.withAlphaComponent(0.45)
} else {
coverImageView.image = UIImage(systemName: "photo.fill")
coverImageView.tintColor = TravelAlbumDetailStyle.primary.withAlphaComponent(0.35)
coverImageView.contentMode = .center
}
callButton.isEnabled = !phone.isEmpty
callButton.alpha = phone.isEmpty ? 0.45 : 1
} }
@objc private func callTapped() { @objc private func callTapped() {
@@ -435,13 +691,14 @@ private final class TravelAlbumInfoCard: UIView {
} }
} }
/// 旅拍相册素材网格 cell。 /// 旅拍相册素材网格单元,展示正方形缩略图、文件名、大小和选择状态。
private final class TravelAlbumMaterialCell: UICollectionViewCell { final class TravelAlbumMaterialCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumMaterialCell" static let reuseIdentifier = "TravelAlbumMaterialCell"
private let imageView = UIImageView() private let imageView = UIImageView()
private let statusLabel = UILabel()
private let checkImageView = UIImageView() private let checkImageView = UIImageView()
private let badgeView = UIView()
private let badgeLabel = UILabel()
private let nameLabel = UILabel() private let nameLabel = UILabel()
private let sizeLabel = UILabel() private let sizeLabel = UILabel()
@@ -449,25 +706,33 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
super.init(frame: frame) super.init(frame: frame)
imageView.contentMode = .scaleAspectFill imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true imageView.clipsToBounds = true
imageView.layer.cornerRadius = 8 imageView.layer.cornerRadius = 12
statusLabel.text = "已上传" imageView.backgroundColor = UIColor(hex: 0xEAF4FF)
statusLabel.font = .systemFont(ofSize: 10)
statusLabel.textColor = .white
statusLabel.backgroundColor = UIColor(hex: 0x34C759)
statusLabel.layer.cornerRadius = 4
statusLabel.clipsToBounds = true
statusLabel.textAlignment = .center
checkImageView.tintColor = .white checkImageView.tintColor = .white
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35) checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
checkImageView.layer.cornerRadius = 10 checkImageView.layer.cornerRadius = 11
nameLabel.font = .systemFont(ofSize: 11) checkImageView.accessibilityIdentifier = "travelAlbum.materialSelectionCheck"
nameLabel.textColor = AppColor.textPrimary badgeView.layer.cornerRadius = 5
badgeView.clipsToBounds = true
badgeView.isHidden = true
badgeView.isAccessibilityElement = false
badgeView.accessibilityIdentifier = "travelAlbum.materialStatusBadge"
badgeLabel.font = .systemFont(ofSize: 10, weight: .semibold)
badgeLabel.textColor = .white
badgeLabel.textAlignment = .center
badgeLabel.isAccessibilityElement = false
badgeLabel.accessibilityIdentifier = "travelAlbum.materialStatusBadgeLabel"
badgeLabel.setContentHuggingPriority(.required, for: .horizontal)
badgeLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
nameLabel.font = .systemFont(ofSize: 12, weight: .medium)
nameLabel.textColor = TravelAlbumDetailStyle.textPrimary
nameLabel.lineBreakMode = .byTruncatingMiddle nameLabel.lineBreakMode = .byTruncatingMiddle
sizeLabel.font = .systemFont(ofSize: 10) sizeLabel.font = .systemFont(ofSize: 10)
sizeLabel.textColor = AppColor.textTertiary sizeLabel.textColor = TravelAlbumDetailStyle.textSecondary
contentView.addSubview(imageView) contentView.addSubview(imageView)
imageView.addSubview(statusLabel) imageView.addSubview(badgeView)
badgeView.addSubview(badgeLabel)
imageView.addSubview(checkImageView) imageView.addSubview(checkImageView)
contentView.addSubview(nameLabel) contentView.addSubview(nameLabel)
contentView.addSubview(sizeLabel) contentView.addSubview(sizeLabel)
@@ -475,17 +740,21 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
make.top.leading.trailing.equalToSuperview() make.top.leading.trailing.equalToSuperview()
make.height.equalTo(imageView.snp.width) make.height.equalTo(imageView.snp.width)
} }
statusLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(4)
make.height.equalTo(18)
make.width.equalTo(48)
}
checkImageView.snp.makeConstraints { make in checkImageView.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(4) make.top.trailing.equalToSuperview().inset(6)
make.size.equalTo(20) make.size.equalTo(22)
}
badgeView.snp.makeConstraints { make in
make.top.leading.equalToSuperview().inset(6)
make.trailing.lessThanOrEqualTo(checkImageView.snp.leading).offset(-4)
}
badgeLabel.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(
UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6)
)
} }
nameLabel.snp.makeConstraints { make in nameLabel.snp.makeConstraints { make in
make.top.equalTo(imageView.snp.bottom).offset(4) make.top.equalTo(imageView.snp.bottom).offset(6)
make.leading.trailing.equalToSuperview() make.leading.trailing.equalToSuperview()
} }
sizeLabel.snp.makeConstraints { make in sizeLabel.snp.makeConstraints { make in
@@ -501,17 +770,25 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
func apply(material: TravelAlbumMaterial, selectionMode: Bool, selected: Bool) { func apply(material: TravelAlbumMaterial, selectionMode: Bool, selected: Bool) {
let urlString = material.coverUrl.isEmpty ? material.fileUrl : material.coverUrl let urlString = material.coverUrl.isEmpty ? material.fileUrl : material.coverUrl
imageView.contentMode = .scaleAspectFill
if let url = URL(string: urlString), !urlString.isEmpty { if let url = URL(string: urlString), !urlString.isEmpty {
imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo")) imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo.fill"))
} else { } else {
imageView.image = UIImage(systemName: "photo") imageView.image = UIImage(systemName: "photo.fill")
imageView.tintColor = AppColor.primary imageView.tintColor = TravelAlbumDetailStyle.primary.withAlphaComponent(0.35)
imageView.backgroundColor = UIColor(hex: 0xEAF2FF) imageView.contentMode = .center
} }
checkImageView.isHidden = !selectionMode checkImageView.isHidden = !selectionMode
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
checkImageView.tintColor = selected ? AppColor.primary : .white checkImageView.tintColor = selected ? TravelAlbumDetailStyle.primary : .white
nameLabel.text = material.fileName let badge = material.badgePresentation
badgeView.isHidden = badge == nil
badgeLabel.text = badge?.text
badgeView.backgroundColor = badge.map { TravelAlbumDetailStyle.badgeColor(for: $0.kind) }
nameLabel.text = material.fileName.isEmpty ? "未命名照片" : material.fileName
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize) sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
let badgeAccessibilityText = badge.map { ",状态:\($0.text)" } ?? ""
accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")\(badgeAccessibilityText)"
accessibilityValue = selectionMode ? (selected ? "已选择" : "未选择") : nil
} }
} }
File diff suppressed because it is too large Load Diff
+36
View File
@@ -62,6 +62,7 @@ final class AuthModelsTests: XCTestCase {
XCTAssertEqual(response.token, "person-temp-token") XCTAssertEqual(response.token, "person-temp-token")
XCTAssertEqual(response.accounts.map(\.businessUserId), [101, 201]) XCTAssertEqual(response.accounts.map(\.businessUserId), [101, 201])
XCTAssertEqual(response.accounts.map(\.subtitle), ["示例景区 · 摄影师", "示例景区 · 店长"]) XCTAssertEqual(response.accounts.map(\.subtitle), ["示例景区 · 摄影师", "示例景区 · 店长"])
XCTAssertEqual(response.accounts.map(\.realName), ["张三", "李四"])
} }
func testV9AuthResponseDecodesMissingAccountListsAsEmpty() throws { func testV9AuthResponseDecodesMissingAccountListsAsEmpty() throws {
@@ -137,4 +138,39 @@ final class AuthModelsTests: XCTestCase {
XCTAssertEqual(user.toAccountSwitchAccount().realName, "张三") XCTAssertEqual(user.toAccountSwitchAccount().realName, "张三")
XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(storeUserId: 201)) XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(storeUserId: 201))
} }
func testV9AccountModelsIgnoreBackendIdentifiersAndUseRealNameFallback() throws {
let json = """
{
"token": "person-temp-token",
"scenic_users": [
{
"account_type": "scenic_user",
"ss_user_id": 101,
"username": "backend_scenic_account",
"user_name": "backend_scenic_unique_id",
"real_name": "景区真实姓名"
}
],
"store_users": [
{
"account_type": "store_user",
"store_user_id": 201,
"username": "backend_store_account",
"user_name": "backend_store_unique_id",
"real_name": "门店真实姓名"
}
]
}
""".data(using: .utf8)!
let response = try JSONDecoder().decode(V9AuthResponse.self, from: json)
XCTAssertEqual(response.scenicUsers.first?.realName, "景区真实姓名")
XCTAssertEqual(response.scenicUsers.first?.displayName, "景区真实姓名")
XCTAssertEqual(response.scenicUsers.first?.toAccountSwitchAccount().title, "景区真实姓名")
XCTAssertEqual(response.storeUsers.first?.realName, "门店真实姓名")
XCTAssertEqual(response.storeUsers.first?.displayName, "门店真实姓名")
XCTAssertEqual(response.storeUsers.first?.toAccountSwitchAccount().title, "门店真实姓名")
}
} }
+4 -4
View File
@@ -19,8 +19,8 @@ final class MessageCenterAPITests: XCTestCase {
"id": 21, "id": 21,
"receiver_id": 9, "receiver_id": 9,
"receiver_type": "staff", "receiver_type": "staff",
"type": 2, "type": 14,
"type_name": "退款成功通知", "type_name": "AI修图任务通知",
"title": "unused", "title": "unused",
"content": "退款已完成", "content": "退款已完成",
"push_at": "2026-07-09 10:00:00", "push_at": "2026-07-09 10:00:00",
@@ -28,7 +28,7 @@ final class MessageCenterAPITests: XCTestCase {
"read_at": "", "read_at": "",
"push_channel": 1, "push_channel": 1,
"push_channel_name": "站内信", "push_channel_name": "站内信",
"extra_data": {"order_no":"NO123","amount":12.5}, "extra_data": {"ai_retouch_batch_id":"59","user_equity_travel_id":17},
"created_at": "2026-07-09 10:00:00", "created_at": "2026-07-09 10:00:00",
"updated_at": "2026-07-09 10:01:00" "updated_at": "2026-07-09 10:01:00"
}] }]
@@ -50,7 +50,7 @@ final class MessageCenterAPITests: XCTestCase {
XCTAssertEqual(response.lastId, 18) XCTAssertEqual(response.lastId, 18)
XCTAssertEqual(response.items.first?.id, 21) XCTAssertEqual(response.items.first?.id, 21)
XCTAssertEqual(response.items.first?.isRead, false) XCTAssertEqual(response.items.first?.isRead, false)
XCTAssertEqual(response.items.first?.extraData?["order_no"], .string("NO123")) XCTAssertEqual(response.items.first?.aiRetouchBatchId, 59)
} }
func testUnreadCountReadAllReadAndDeleteUseExpectedContracts() async throws { func testUnreadCountReadAllReadAndDeleteUseExpectedContracts() async throws {
@@ -178,6 +178,44 @@ final class MessageCenterViewModelTests: XCTestCase {
XCTAssertEqual(failureMessage, "删除失败: server down") XCTAssertEqual(failureMessage, "删除失败: server down")
} }
func testAIRetouchMessageExposesTaskActionAndParsesDirectBatchId() {
let message = makeMessage(
id: 14,
type: 14,
extraData: ["ai_retouch_batch_id": .number(59)]
)
let viewModel = MessageDetailViewModel(message: message)
XCTAssertTrue(viewModel.showsAIRetouchTaskAction)
XCTAssertEqual(viewModel.aiRetouchBatchId, 59)
}
func testAIRetouchMessageParsesNestedStringBatchIdAndFallsBackWhenInvalid() {
let nested = makeMessage(
id: 15,
type: 14,
extraData: ["data": .object(["ai_retouch_batch_id": .string(" 61 ")])]
)
let wrapped = makeMessage(
id: 17,
type: 14,
extraData: ["data": .string(#"{"ai_retouch_batch_id":63}"#)]
)
let missing = makeMessage(id: 16, type: 14, extraData: [:])
let manualRetouch = makeMessage(
id: 10,
type: 10,
extraData: ["ai_retouch_batch_id": .number(59)]
)
XCTAssertEqual(MessageDetailViewModel(message: nested).aiRetouchBatchId, 61)
XCTAssertEqual(MessageDetailViewModel(message: wrapped).aiRetouchBatchId, 63)
XCTAssertTrue(MessageDetailViewModel(message: missing).showsAIRetouchTaskAction)
XCTAssertNil(MessageDetailViewModel(message: missing).aiRetouchBatchId)
XCTAssertFalse(MessageDetailViewModel(message: manualRetouch).showsAIRetouchTaskAction)
XCTAssertNil(MessageDetailViewModel(message: manualRetouch).aiRetouchBatchId)
}
} }
@MainActor @MainActor
@@ -226,12 +264,17 @@ private struct TestError: LocalizedError {
var errorDescription: String? { message } var errorDescription: String? { message }
} }
private func makeMessage(id: Int, isRead: Bool = false) -> MessageItem { private func makeMessage(
id: Int,
isRead: Bool = false,
type: Int = 1,
extraData: [String: MessageJSONValue]? = nil
) -> MessageItem {
MessageItem( MessageItem(
id: id, id: id,
receiverId: 1, receiverId: 1,
receiverType: "staff", receiverType: "staff",
type: 1, type: type,
typeName: "系统通知", typeName: "系统通知",
title: "系统通知", title: "系统通知",
content: "消息内容", content: "消息内容",
@@ -240,6 +283,7 @@ private func makeMessage(id: Int, isRead: Bool = false) -> MessageItem {
readAt: "", readAt: "",
pushChannel: 1, pushChannel: 1,
pushChannelName: "站内信", pushChannelName: "站内信",
extraData: extraData,
createdAt: "2026-07-09 10:00:00", createdAt: "2026-07-09 10:00:00",
updatedAt: "2026-07-09 10:00:00" updatedAt: "2026-07-09 10:00:00"
) )
@@ -69,6 +69,26 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
XCTAssertFalse(viewModel.showAmountDialog) XCTAssertFalse(viewModel.showAmountDialog)
} }
func testPayCodeLoadingStateCoversInitialRenderAndRequest() async {
let payCodeJSON = """
{"code":100000,"msg":"success","data":{"static_pay_url":"https://pay.example.com/static","dynamic_pay_url":"https://pay.example.com/dynamic"}}
""".data(using: .utf8)!
let session = MockURLSession(responses: [payCodeJSON])
let api = PaymentAPI(client: APIClient(environment: .testing, session: session))
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
var loadingStates = [viewModel.isPayCodeLoading]
viewModel.onStateChange = {
loadingStates.append(viewModel.isPayCodeLoading)
}
await viewModel.loadPayCode(api: api)
XCTAssertEqual(loadingStates.first, true)
XCTAssertTrue(loadingStates.dropLast().allSatisfy { $0 })
XCTAssertEqual(loadingStates.last, false)
XCTAssertTrue(viewModel.hasPayCode)
}
func testToggleReceiveVoicePersistsInAppStore() { func testToggleReceiveVoicePersistsInAppStore() {
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
viewModel.refreshLocalState() viewModel.refreshLocalState()
@@ -79,8 +99,9 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
XCTAssertTrue(viewModel.isVoiceBroadcastOpen) XCTAssertTrue(viewModel.isVoiceBroadcastOpen)
} }
func testNalatiAccountInfoUsesLoggedInPhotographerAndAllPermissionStores() { func testNalatiAccountInfoUsesPhotographerRealNameAndAllPermissionStores() {
appStore.session.userName = "摄影师小王" appStore.session.userName = "摄影师昵称"
appStore.session.realName = "摄影师真实姓名"
appStore.permissions.saveRolePermissionList([ appStore.permissions.saveRolePermissionList([
RolePermissionResponse( RolePermissionResponse(
role: RoleInfo(id: 41, name: "摄影师", roleCode: "photographer"), role: RoleInfo(id: 41, name: "摄影师", roleCode: "photographer"),
@@ -93,10 +114,19 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
XCTAssertEqual(viewModel.displayPhotographerName, "摄影师小王") XCTAssertEqual(viewModel.displayPhotographerName, "摄影师真实姓名")
XCTAssertEqual(viewModel.displayStoreNames, "空中草原店、河谷草原店") XCTAssertEqual(viewModel.displayStoreNames, "空中草原店、河谷草原店")
} }
func testPhotographerNameDoesNotFallBackToNicknameWhenRealNameMissing() {
appStore.session.userName = "摄影师昵称"
appStore.session.realName = " "
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
XCTAssertEqual(viewModel.displayPhotographerName, "-")
}
func testLoadPayCodeClearsQRWhenScenicMissing() async { func testLoadPayCodeClearsQRWhenScenicMissing() async {
appStore.session.currentScenicId = 0 appStore.session.currentScenicId = 0
let session = MockURLSession(responses: []) let session = MockURLSession(responses: [])
@@ -105,6 +135,7 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
await viewModel.loadPayCode(api: api) await viewModel.loadPayCode(api: api)
XCTAssertFalse(viewModel.isPayCodeLoading)
XCTAssertFalse(viewModel.hasPayCode) XCTAssertFalse(viewModel.hasPayCode)
XCTAssertEqual(session.requests.count, 0) XCTAssertEqual(session.requests.count, 0)
} }
+4 -3
View File
@@ -55,11 +55,12 @@ final class PaymentModelsTests: XCTestCase {
XCTAssertFalse(PayPageBrandingPolicy.isNalati(scenicId: 0)) XCTAssertFalse(PayPageBrandingPolicy.isNalati(scenicId: 0))
} }
func testPaymentAccountDisplayFormatterUsesCurrentPhotographerAndAllUniqueStores() { func testPaymentAccountDisplayFormatterUsesRealNameAndAllUniqueStores() {
XCTAssertEqual( XCTAssertEqual(
PaymentAccountDisplayFormatter.photographerName(userName: " 当前摄影师 ", realName: "实名"), PaymentAccountDisplayFormatter.photographerName(realName: " 摄影师真实姓名 "),
"当前摄影师" "摄影师真实姓名"
) )
XCTAssertEqual(PaymentAccountDisplayFormatter.photographerName(realName: " "), "-")
XCTAssertEqual( XCTAssertEqual(
PaymentAccountDisplayFormatter.storeNames(["一号店", " 二号店 ", "一号店", ""]), PaymentAccountDisplayFormatter.storeNames(["一号店", " 二号店 ", "一号店", ""]),
"一号店、二号店" "一号店、二号店"
@@ -26,6 +26,22 @@ final class ProfileViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.displayNickname, "未设置昵称") XCTAssertEqual(viewModel.displayNickname, "未设置昵称")
} }
func testColdStartBasicInfoUsesPersistedSessionCache() {
AppStore.shared.session.userName = "缓存昵称"
AppStore.shared.session.realName = "缓存真实姓名"
AppStore.shared.session.phone = "186****7230"
AppStore.shared.session.avatar = "https://cdn.example.com/cached-avatar.jpg"
let viewModel = ProfileViewModel()
XCTAssertTrue(viewModel.hasCachedBasicInfo)
XCTAssertEqual(viewModel.displayNickname, "缓存昵称")
XCTAssertEqual(viewModel.displayRealName, "缓存真实姓名")
XCTAssertEqual(viewModel.displayPhone, "186****7230")
XCTAssertEqual(viewModel.displayAvatarURL, "https://cdn.example.com/cached-avatar.jpg")
XCTAssertEqual(viewModel.displayUID, "profile-user")
}
func testShowPhotographerFieldsWhenRoleCodeIsPhotographer() { func testShowPhotographerFieldsWhenRoleCodeIsPhotographer() {
AppStore.shared.session.roleCode = AppRoleCode.photographer.rawValue AppStore.shared.session.roleCode = AppRoleCode.photographer.rawValue
let viewModel = ProfileViewModel() let viewModel = ProfileViewModel()
@@ -63,6 +63,34 @@ final class PushNotificationTests: XCTestCase {
XCTAssertEqual(nested.destination, .paymentDetails) XCTAssertEqual(nested.destination, .paymentDetails)
} }
func testAIRetouchPushRoutesToDetailAndFallsBackToList() {
XCTAssertEqual(
PushPayload(userInfo: [
"type": 14,
"data": ["ai_retouch_batch_id": 91, "status": "succeeded"],
]).destination,
.aiRetouchTaskDetail(batchId: 91)
)
XCTAssertEqual(
PushPayload(userInfo: ["type": 14, "data": [:]]).destination,
.aiRetouchTaskList
)
XCTAssertEqual(
PushPayload(userInfo: ["type": 14, "data": ["ai_retouch_batch_id": "0"]]).destination,
.aiRetouchTaskList
)
XCTAssertEqual(PushPayload(userInfo: ["type": 10]).destination, .messageCenter)
}
func testAIRetouchPushReadsEncodedDataAndVendorWrapper() {
let payload = PushPayload(userInfo: [
"n_extras": #"{"type":14,"data":"{\"ai_retouch_batch_id\":92}"}"#,
])
XCTAssertEqual(payload.destination, .aiRetouchTaskDetail(batchId: 92))
XCTAssertEqual(payload.normalizedValues["ai_retouch_batch_id"], "92")
}
func testPushAPIUsesAndroidCompatibleEndpointAndQuery() async throws { func testPushAPIUsesAndroidCompatibleEndpointAndQuery() async throws {
let session = MockURLSession(responses: [try TestJSON.envelope(data: EmptyPayload())]) let session = MockURLSession(responses: [try TestJSON.envelope(data: EmptyPayload())])
let api = PushAPI(client: APIClient(environment: .testing, session: session)) let api = PushAPI(client: APIClient(environment: .testing, session: session))
@@ -0,0 +1,62 @@
//
// SessionExpiredDialogViewControllerTests.swift
// suixinkanTests
//
import UIKit
import XCTest
@testable import suixinkan
/// 登录失效弹窗测试,覆盖强制展示配置、Android 对齐文案与确认防重。
@MainActor
final class SessionExpiredDialogViewControllerTests: XCTestCase {
func testDialogUsesForcedPresentationAndAndroidCopy() throws {
let dialog = SessionExpiredDialogViewController(onConfirm: {})
dialog.loadViewIfNeeded()
XCTAssertEqual(dialog.modalPresentationStyle, .overFullScreen)
XCTAssertEqual(dialog.modalTransitionStyle, .crossDissolve)
XCTAssertTrue(dialog.isModalInPresentation)
XCTAssertTrue(dialog.view.accessibilityViewIsModal)
let labels = dialog.view.allSubviews(of: UILabel.self)
XCTAssertTrue(labels.contains { $0.text == "登录提醒" })
XCTAssertTrue(labels.contains { $0.text == "当前用户已在其他设备登录,\n请重新登录" })
let button = try XCTUnwrap(
dialog.view.allSubviews(of: UIButton.self).first {
$0.accessibilityIdentifier == "sessionExpired.confirmButton"
}
)
XCTAssertEqual(button.title(for: .normal), "确认并退出登录")
}
func testConfirmCallbackOnlyRunsOnce() throws {
var confirmationCount = 0
let dialog = SessionExpiredDialogViewController {
confirmationCount += 1
}
dialog.loadViewIfNeeded()
let button = try XCTUnwrap(
dialog.view.allSubviews(of: UIButton.self).first {
$0.accessibilityIdentifier == "sessionExpired.confirmButton"
}
)
button.sendActions(for: .touchUpInside)
button.sendActions(for: .touchUpInside)
XCTAssertEqual(confirmationCount, 1)
XCTAssertFalse(button.isEnabled)
}
}
private extension UIView {
func allSubviews<View: UIView>(of type: View.Type) -> [View] {
subviews.flatMap { subview -> [View] in
let current = (subview as? View).map { [$0] } ?? []
return current + subview.allSubviews(of: type)
}
}
}
@@ -0,0 +1,292 @@
//
// TravelAlbumAIRetouchTemplateViewModelTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// AI 修图模板工作流、选择状态与请求分流测试。
@MainActor
final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
func testInitialWorkflowDefaultsRequiredSelectionsAndLeavesAtmosphereEmpty() async {
let api = makeAPI()
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
albumId: 8,
scenicId: 18,
materialIds: [4, 3, 2, 1, 1]
)
await viewModel.loadTemplates(api: api)
XCTAssertEqual(api.aiRetouchTemplateScenicIds, [18])
XCTAssertEqual(viewModel.workflow, .initial(albumId: 8, materialIds: [1, 2, 3, 4]))
XCTAssertEqual(viewModel.selectedPhotoCount, 4)
XCTAssertTrue(viewModel.shouldShowInitialTips)
XCTAssertEqual(
viewModel.initialTipsText,
"Tips:氛围感修图为选填,可横向选择一种样式;选中后每张照片会额外生成1个独立结果,第一张照片仍另生成封面。"
)
XCTAssertEqual(viewModel.visibleCategories, [.refined, .atmosphere, .cover])
XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11)
XCTAssertNil(viewModel.selectedAtmosphereTemplateId)
XCTAssertEqual(viewModel.selectedCoverTemplateId, 31)
XCTAssertTrue(viewModel.isOptional(.atmosphere))
XCTAssertTrue(viewModel.canSubmit)
}
func testInitialAtmosphereSelectionTogglesOffWhenTappedAgain() async {
let api = makeAPI()
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
albumId: 8,
scenicId: 18,
materialIds: [1]
)
await viewModel.loadTemplates(api: api)
viewModel.toggleTemplate(id: 21, category: .atmosphere)
XCTAssertEqual(viewModel.selectedAtmosphereTemplateId, 21)
viewModel.toggleTemplate(id: 21, category: .atmosphere)
XCTAssertNil(viewModel.selectedAtmosphereTemplateId)
}
func testInitialSubmissionUsesCoverTemplateOnlyForFourOrMoreMaterials() async {
let api = makeAPI()
let threePhotoViewModel = TravelAlbumAIRetouchTemplateViewModel(
albumId: 8,
scenicId: 18,
materialIds: [3, 1, 2]
)
await threePhotoViewModel.loadTemplates(api: api)
await threePhotoViewModel.submit(api: api)
XCTAssertEqual(
api.aiRetouchRequests[0],
TravelAlbumAIRetouchRequest(
userEquityTravelId: 8,
materialIds: [1, 2, 3],
refinedTemplateId: 11,
atmosphereTemplateId: nil,
coverTemplateId: nil
)
)
let fourPhotoViewModel = TravelAlbumAIRetouchTemplateViewModel(
albumId: 8,
scenicId: 18,
materialIds: [1, 2, 3, 4]
)
await fourPhotoViewModel.loadTemplates(api: api)
fourPhotoViewModel.toggleTemplate(id: 12, category: .refined)
fourPhotoViewModel.toggleTemplate(id: 21, category: .atmosphere)
await fourPhotoViewModel.submit(api: api)
XCTAssertEqual(
api.aiRetouchRequests[1],
TravelAlbumAIRetouchRequest(
userEquityTravelId: 8,
materialIds: [1, 2, 3, 4],
refinedTemplateId: 12,
atmosphereTemplateId: 21,
coverTemplateId: 31
)
)
}
func testReretouchWorkflowShowsAndSubmitsOnlyRequiredCategories() async {
let api = makeAPI()
let refined = TravelAlbumAIRetouchTemplateViewModel(
scenicId: 18,
workflow: .reretouch(materialId: 7, batchId: 70, type: .refined)
)
await refined.loadTemplates(api: api)
XCTAssertEqual(refined.selectedPhotoCount, 1)
XCTAssertFalse(refined.shouldShowInitialTips)
XCTAssertEqual(refined.visibleCategories, [.refined])
XCTAssertEqual(refined.selectedRefinedTemplateId, 11)
XCTAssertNil(refined.selectedAtmosphereTemplateId)
await refined.submit(api: api)
let atmosphere = TravelAlbumAIRetouchTemplateViewModel(
scenicId: 18,
workflow: .reretouch(materialId: 8, batchId: 80, type: .atmosphere)
)
await atmosphere.loadTemplates(api: api)
XCTAssertEqual(atmosphere.visibleCategories, [.atmosphere])
XCTAssertEqual(atmosphere.selectedAtmosphereTemplateId, 21)
atmosphere.toggleTemplate(id: 21, category: .atmosphere)
XCTAssertEqual(atmosphere.selectedAtmosphereTemplateId, 21)
await atmosphere.submit(api: api)
let all = TravelAlbumAIRetouchTemplateViewModel(
scenicId: 18,
workflow: .reretouch(materialId: 9, batchId: 90, type: .all)
)
await all.loadTemplates(api: api)
XCTAssertEqual(all.visibleCategories, [.refined, .atmosphere])
XCTAssertEqual(all.selectedRefinedTemplateId, 11)
XCTAssertEqual(all.selectedAtmosphereTemplateId, 21)
await all.submit(api: api)
XCTAssertEqual(api.aiReretouchRequests, [
TravelAlbumAIReretouchRequest(
id: 7,
aiRetouchBatchId: 70,
type: .refined,
refinedTemplateId: 11,
atmosphereTemplateId: nil
),
TravelAlbumAIReretouchRequest(
id: 8,
aiRetouchBatchId: 80,
type: .atmosphere,
refinedTemplateId: nil,
atmosphereTemplateId: 21
),
TravelAlbumAIReretouchRequest(
id: 9,
aiRetouchBatchId: 90,
type: .all,
refinedTemplateId: 11,
atmosphereTemplateId: 21
),
])
}
func testInvalidReretouchBatchDisablesSubmission() async {
let api = makeAPI()
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
scenicId: 18,
workflow: .reretouch(materialId: 7, batchId: 0, type: .refined)
)
var message: String?
viewModel.onShowMessage = { message = $0 }
await viewModel.loadTemplates(api: api)
await viewModel.submit(api: api)
XCTAssertFalse(viewModel.canSubmit)
XCTAssertEqual(viewModel.validationMessage, "当前图片缺少修图批次,请刷新后重试")
XCTAssertEqual(message, "当前图片缺少修图批次,请刷新后重试")
XCTAssertTrue(api.aiReretouchRequests.isEmpty)
}
func testMissingRequiredTemplateDisablesMatchingWorkflow() async {
let api = makeAPI()
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
refinedTemplates: [template(11, "清透")],
atmosphereTemplates: [],
coverTemplates: []
)
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
scenicId: 18,
workflow: .reretouch(materialId: 7, batchId: 70, type: .all)
)
await viewModel.loadTemplates(api: api)
XCTAssertFalse(viewModel.canSubmit)
XCTAssertEqual(viewModel.validationMessage, "暂无可用的氛围感修图模板")
}
func testLoadFailureExposesRetryMessageAndKeepsSubmissionDisabled() async {
let api = makeAPI()
api.aiRetouchTemplatesError = APIError.serverCode(500, "模板服务繁忙")
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
albumId: 8,
scenicId: 18,
materialIds: [1]
)
await viewModel.loadTemplates(api: api)
XCTAssertEqual(viewModel.loadErrorMessage, "模板服务繁忙")
XCTAssertFalse(viewModel.canSubmit)
XCTAssertTrue(viewModel.refinedTemplates.isEmpty)
}
func testSubmitFailureKeepsSelectionAndAllowsRetry() async {
let api = makeAPI()
api.submitAIReretouchError = APIError.serverCode(500, "提交服务繁忙")
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
scenicId: 18,
workflow: .reretouch(materialId: 7, batchId: 70, type: .refined)
)
var message: String?
var submitted = false
viewModel.onShowMessage = { message = $0 }
viewModel.onSubmitted = { _ in submitted = true }
await viewModel.loadTemplates(api: api)
await viewModel.submit(api: api)
XCTAssertEqual(api.aiReretouchRequests.count, 1)
XCTAssertEqual(message, "提交服务繁忙")
XCTAssertFalse(submitted)
XCTAssertFalse(viewModel.isSubmitting)
XCTAssertTrue(viewModel.canSubmit)
XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11)
}
func testSubmittingPreventsDuplicateRequest() async {
let api = makeAPI()
api.submitAIRetouchDelayNanoseconds = 50_000_000
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
albumId: 8,
scenicId: 18,
materialIds: [1]
)
await viewModel.loadTemplates(api: api)
let firstSubmission = Task { await viewModel.submit(api: api) }
await waitUntil { viewModel.isSubmitting }
await viewModel.submit(api: api)
await firstSubmission.value
XCTAssertEqual(api.aiRetouchRequests.count, 1)
XCTAssertFalse(viewModel.isSubmitting)
}
func testRemainingQuotaDisablesSubmissionWhenSelectedOutputsExceedBalance() async {
let api = makeAPI(remainingQuota: 2)
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
albumId: 8,
scenicId: 18,
materialIds: [1, 2]
)
await viewModel.loadTemplates(api: api)
XCTAssertEqual(viewModel.remainingQuota, 2)
XCTAssertEqual(viewModel.requiredQuota, 2)
XCTAssertTrue(viewModel.canSubmit)
viewModel.toggleTemplate(id: 21, category: .atmosphere)
XCTAssertEqual(viewModel.requiredQuota, 4)
XCTAssertFalse(viewModel.canSubmit)
XCTAssertEqual(viewModel.validationMessage, "剩余修图次数不足,需要4次,当前剩余2次")
}
private func makeAPI(remainingQuota: Int = 100) -> TravelAlbumMockAPI {
let api = TravelAlbumMockAPI()
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
refinedTemplates: [template(11, "清透"), template(12, "自然")],
atmosphereTemplates: [template(21, "暖阳")],
coverTemplates: [template(31, "杂志")],
remainingQuota: remainingQuota
)
return api
}
private func template(_ id: Int, _ name: String) -> TravelAlbumAIRetouchTemplate {
TravelAlbumAIRetouchTemplate(id: id, name: name, previewURL: "https://cdn.example.com/\(id).jpg")
}
private func waitUntil(_ condition: @escaping () -> Bool) async {
for _ in 0 ..< 100 {
if condition() { return }
await Task.yield()
}
}
}
+231 -2
View File
@@ -51,16 +51,27 @@ final class TravelAlbumAPITests: XCTestCase {
} }
func testMaterialListAndDeleteAndMpCode() async throws { func testMaterialListAndDeleteAndMpCode() async throws {
let materialList = envelopeJSON(#"{"total":0,"list":[]}"#) let materialList = envelopeJSON(
#"{"total":1,"list":[{"id":6,"user_equity_travel_id":3,"status":1,"order_number":"","user_id":9,"file_name":"A.JPG","file_type":2,"file_url":"https://cdn/a.jpg","file_size":1024,"cover_url":"","is_purchased":false,"ai_retouch_status":3,"ai_retouch_status_name":"AI已修","ai_refined_url":"https://cdn/refined.jpg","ai_atmosphere_url":"https://cdn/atmosphere.jpg","created_at":"","updated_at":""}]}"#
)
let empty = envelopeJSON("{}") let empty = envelopeJSON("{}")
let code = envelopeJSON(#"{"mp_code_oss_url":"https://cdn/qr.png"}"#) let code = envelopeJSON(#"{"mp_code_oss_url":"https://cdn/qr.png"}"#)
let session = MockURLSession(responses: [materialList, empty, code]) let session = MockURLSession(responses: [materialList, empty, code])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
_ = try await api.materialList(userEquityTravelId: 3, page: 1, pageSize: 30, orderBy: 4, isPurchased: 1) let materials = try await api.materialList(
userEquityTravelId: 3,
page: 1,
pageSize: 30,
orderBy: 4,
isPurchased: 1
)
try await api.deleteAlbum(id: 3) try await api.deleteAlbum(id: 3)
let response = try await api.mpCode(id: 3) let response = try await api.mpCode(id: 3)
XCTAssertEqual(materials.list.first?.aiRetouchStatus, 3)
XCTAssertEqual(materials.list.first?.aiRefinedURL, "https://cdn/refined.jpg")
XCTAssertEqual(materials.list.first?.aiAtmosphereURL, "https://cdn/atmosphere.jpg")
XCTAssertEqual(response.mpCodeOssUrl, "https://cdn/qr.png") XCTAssertEqual(response.mpCodeOssUrl, "https://cdn/qr.png")
XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/travel-album/material-list") XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/travel-album/material-list")
let query = URLComponents(url: session.requests[0].url!, resolvingAgainstBaseURL: false)?.queryItems let query = URLComponents(url: session.requests[0].url!, resolvingAgainstBaseURL: false)?.queryItems
@@ -73,6 +84,42 @@ final class TravelAlbumAPITests: XCTestCase {
XCTAssertEqual(session.requests[2].url?.path, "/api/yf-handset-app/photog/travel-album/mp-code") XCTAssertEqual(session.requests[2].url?.path, "/api/yf-handset-app/photog/travel-album/mp-code")
} }
func testMaterialInfoBuildsQueryAndDecodesMaterial() async throws {
let data = envelopeJSON(
#"{"id":6,"user_equity_travel_id":3,"status":1,"order_number":"","user_id":9,"file_name":"A.JPG","file_type":2,"file_url":"https://cdn/a.jpg","file_size":1024,"cover_url":"","is_purchased":false,"ai_retouch_status":3,"ai_retouch_status_name":"AI已修","ai_retouch_batch_id":60,"ai_refined_url":"https://cdn/refined.jpg","ai_atmosphere_url":"https://cdn/atmosphere.jpg","created_at":"","updated_at":""}"#
)
let session = MockURLSession(responses: [data])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
let material = try await api.materialInfo(userEquityTravelId: 3, materialId: 6)
XCTAssertEqual(material.id, 6)
XCTAssertEqual(material.aiRetouchBatchId, 60)
XCTAssertEqual(material.aiRefinedURL, "https://cdn/refined.jpg")
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/material-info")
let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(query?.first { $0.name == "user_equity_travel_id" }?.value, "3")
XCTAssertEqual(query?.first { $0.name == "material_id" }?.value, "6")
}
func testBatchDeleteMaterialsBuildsPathAndBody() async throws {
let session = MockURLSession(responses: [envelopeJSON("{}")])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
try await api.batchDeleteMaterials(ids: [2031, 2032, 2033])
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(
request.url?.path,
"/api/yf-handset-app/photog/travel-album/batch-delete-material"
)
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any]
XCTAssertEqual(body?["ids"] as? [Int], [2031, 2032, 2033])
}
func testUploadMaterialBuildsPathAndBody() async throws { func testUploadMaterialBuildsPathAndBody() async throws {
let material = envelopeJSON(#"{"id":6,"user_equity_travel_id":3,"status":1,"order_number":"","user_id":9,"file_name":"A.JPG","file_type":1,"file_url":"https://cdn/a.jpg","file_size":1024,"cover_url":"","is_purchased":false,"created_at":"2026-07-08 10:00:00","updated_at":"2026-07-08 10:00:00"}"#) let material = envelopeJSON(#"{"id":6,"user_equity_travel_id":3,"status":1,"order_number":"","user_id":9,"file_name":"A.JPG","file_type":1,"file_url":"https://cdn/a.jpg","file_size":1024,"cover_url":"","is_purchased":false,"created_at":"2026-07-08 10:00:00","updated_at":"2026-07-08 10:00:00"}"#)
let session = MockURLSession(responses: [material]) let session = MockURLSession(responses: [material])
@@ -113,9 +160,191 @@ final class TravelAlbumAPITests: XCTestCase {
XCTAssertEqual(query?.first { $0.name == "user_equity_travel_id" }?.value, "3") XCTAssertEqual(query?.first { $0.name == "user_equity_travel_id" }?.value, "3")
} }
func testAIRetouchTemplatesBuildsQueryAndDecodesGroups() async throws {
let data = envelopeJSON(
#"{"refined_templates":[{"id":1,"name":"清透","preview_url":"https://cdn/refined.jpg","before_url":"https://cdn/refined-before.jpg","after_url":"https://cdn/refined-after.jpg"}],"atmosphere_templates":[{"id":2,"name":"暖阳","preview_url":"https://cdn/atmosphere.jpg","before_url":"https://cdn/atmosphere-before.jpg","after_url":"https://cdn/atmosphere-after.jpg"}],"cover_templates":[{"id":3,"name":"杂志","preview_url":"https://cdn/cover.jpg","before_url":"https://cdn/cover-before.jpg","after_url":"https://cdn/cover-after.jpg"}],"remaining_quota":12}"#
)
let session = MockURLSession(responses: [data])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
let response = try await api.aiRetouchTemplates(scenicId: 18)
XCTAssertEqual(response.refinedTemplates.first?.name, "清透")
XCTAssertEqual(response.refinedTemplates.first?.beforeURL, "https://cdn/refined-before.jpg")
XCTAssertEqual(response.refinedTemplates.first?.afterURL, "https://cdn/refined-after.jpg")
XCTAssertEqual(response.atmosphereTemplates.first?.id, 2)
XCTAssertEqual(response.coverTemplates.first?.previewURL, "https://cdn/cover.jpg")
XCTAssertEqual(response.remainingQuota, 12)
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch-templates")
let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(query?.first { $0.name == "scenic_id" }?.value, "18")
}
func testSubmitAIRetouchEncodesRequiredAndSelectedOptionalTemplates() async throws {
let session = MockURLSession(responses: [jobSubmissionJSON(batchId: 9, albumId: 6)])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
try await api.submitAIRetouch(
TravelAlbumAIRetouchRequest(
userEquityTravelId: 6,
materialIds: [11, 12, 13, 14],
refinedTemplateId: 21,
atmosphereTemplateId: 22,
coverTemplateId: 31
)
)
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch")
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any]
XCTAssertEqual(body?["user_equity_travel_id"] as? Int, 6)
XCTAssertEqual(body?["material_ids"] as? [Int], [11, 12, 13, 14])
XCTAssertEqual(body?["refined_template_id"] as? Int, 21)
XCTAssertEqual(body?["atmosphere_template_id"] as? Int, 22)
XCTAssertEqual(body?["cover_template_id"] as? Int, 31)
XCTAssertEqual(Set(body?.keys.map { $0 } ?? []), [
"user_equity_travel_id",
"material_ids",
"refined_template_id",
"atmosphere_template_id",
"cover_template_id",
])
}
func testSubmitAIRetouchOmitsAllOptionalTemplatesWhenAbsent() async throws {
let session = MockURLSession(responses: [jobSubmissionJSON(batchId: 10, albumId: 6)])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
try await api.submitAIRetouch(
TravelAlbumAIRetouchRequest(
userEquityTravelId: 6,
materialIds: [11],
refinedTemplateId: 21,
atmosphereTemplateId: nil,
coverTemplateId: nil
)
)
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(session.requests.first?.httpBody)) as? [String: Any]
XCTAssertNil(body?["atmosphere_template_id"])
XCTAssertNil(body?["cover_template_id"])
XCTAssertEqual(Set(body?.keys.map { $0 } ?? []), [
"user_equity_travel_id",
"material_ids",
"refined_template_id",
])
}
func testSubmitAIReretouchEncodesOnlyFieldsRequiredByEachType() async throws {
let session = MockURLSession(responses: [
jobSubmissionJSON(batchId: 51, albumId: 6),
jobSubmissionJSON(batchId: 52, albumId: 6),
jobSubmissionJSON(batchId: 53, albumId: 6),
])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
try await api.submitAIReretouch(
TravelAlbumAIReretouchRequest(
id: 11,
aiRetouchBatchId: 51,
type: .refined,
refinedTemplateId: 21,
atmosphereTemplateId: nil
)
)
try await api.submitAIReretouch(
TravelAlbumAIReretouchRequest(
id: 12,
aiRetouchBatchId: 52,
type: .atmosphere,
refinedTemplateId: nil,
atmosphereTemplateId: 22
)
)
try await api.submitAIReretouch(
TravelAlbumAIReretouchRequest(
id: 13,
aiRetouchBatchId: 53,
type: .all,
refinedTemplateId: 21,
atmosphereTemplateId: 22
)
)
let bodies = try session.requests.map { request in
try JSONSerialization.jsonObject(with: XCTUnwrap(request.httpBody)) as? [String: Any]
}
XCTAssertEqual(session.requests.map { $0.url?.path }, Array(
repeating: "/api/yf-handset-app/photog/travel-album/ai-reretouch",
count: 3
))
XCTAssertEqual(Set(bodies[0]?.keys.map { $0 } ?? []), ["id", "ai_retouch_batch_id", "type", "refined_template_id"])
XCTAssertEqual(Set(bodies[1]?.keys.map { $0 } ?? []), ["id", "ai_retouch_batch_id", "type", "atmosphere_template_id"])
XCTAssertEqual(Set(bodies[2]?.keys.map { $0 } ?? []), [
"id",
"ai_retouch_batch_id",
"type",
"refined_template_id",
"atmosphere_template_id",
])
XCTAssertEqual(bodies[0]?["type"] as? Int, 1)
XCTAssertEqual(bodies[1]?["type"] as? Int, 2)
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 { private func envelopeJSON(_ dataJSON: String) -> Data {
""" """
{"code":100000,"msg":"success","data":\(dataJSON)} {"code":100000,"msg":"success","data":\(dataJSON)}
""".data(using: .utf8)! """.data(using: .utf8)!
} }
private func jobSubmissionJSON(batchId: Int, albumId: Int) -> Data {
envelopeJSON(
#"{"ai_retouch_batch_id":\#(batchId),"user_equity_travel_id":\#(albumId),"status":"queued","progress":{"total":3,"queued":3,"processing":0,"succeeded":0,"failed":0,"canceled":0},"created_at":"2026-08-14T06:00:00Z"}"#
)
}
} }
File diff suppressed because it is too large Load Diff
+387
View File
@@ -8,6 +8,302 @@ import XCTest
/// 旅拍相册模型与展示工具测试。 /// 旅拍相册模型与展示工具测试。
final class TravelAlbumModelsTests: XCTestCase { final class TravelAlbumModelsTests: XCTestCase {
func testBeforeAfterComparisonMotionMatchesShipSwiftInteractionRange() {
XCTAssertEqual(BeforeAfterComparisonMotion.automaticFraction(elapsed: 0), 0.5, accuracy: 0.001)
XCTAssertEqual(
BeforeAfterComparisonMotion.automaticFraction(
elapsed: .pi / (2 * BeforeAfterComparisonMotion.automaticSpeed)
),
0.8,
accuracy: 0.001
)
XCTAssertEqual(BeforeAfterComparisonMotion.clampedManualFraction(0), 0.05)
XCTAssertEqual(BeforeAfterComparisonMotion.clampedManualFraction(1), 0.95)
XCTAssertEqual(BeforeAfterComparisonMotion.clampedManualFraction(0.42), 0.42)
}
func testBeforeAfterComparisonLayoutFitsVariableRatiosWithoutCropping() {
let landscape = BeforeAfterComparisonLayout.fittedSize(
aspectRatio: 16.0 / 9.0,
maximumSize: CGSize(width: 358, height: 580)
)
let portrait = BeforeAfterComparisonLayout.fittedSize(
aspectRatio: 2.0 / 3.0,
maximumSize: CGSize(width: 358, height: 580)
)
XCTAssertEqual(landscape.width, 358, accuracy: 0.001)
XCTAssertEqual(landscape.height, 201.375, accuracy: 0.001)
XCTAssertEqual(portrait.width, 358, accuracy: 0.001)
XCTAssertEqual(portrait.height, 537, accuracy: 0.001)
XCTAssertEqual(
BeforeAfterComparisonLayout.aspectRatio(for: CGSize(width: 4000, height: 3000)),
4.0 / 3.0
)
}
func testPreviewProjectMapsMaterialToOriginalAsset() {
let material = TravelAlbumMaterial(
id: 8,
fileName: "IMG_0008.JPG",
fileUrl: "https://cdn.example.com/original.jpg",
fileSize: 4096,
coverUrl: "https://cdn.example.com/cover.jpg"
)
let project = TravelAlbumPreviewProject(material: material)
XCTAssertEqual(project.originalMaterialId, 8)
XCTAssertEqual(project.orderedAssets.map(\.kind), [.original])
XCTAssertEqual(project.orderedAssets.first?.displayURL, material.fileUrl)
XCTAssertEqual(project.orderedAssets.first?.previewURL, material.coverUrl)
XCTAssertFalse(project.hasVariants)
}
func testPreviewProjectMapsNonemptyAIResultURLsInCanonicalOrder() {
let material = TravelAlbumMaterial(
id: 8,
fileName: "IMG_0008.JPG",
fileUrl: "https://cdn.example.com/original.jpg",
fileSize: 4096,
coverUrl: "https://cdn.example.com/cover.jpg",
aiRetouchBatchId: 55,
aiRefinedURL: " https://cdn.example.com/refined.jpg ",
aiAtmosphereURL: "https://cdn.example.com/atmosphere.jpg"
)
let project = TravelAlbumPreviewProject(material: material)
XCTAssertEqual(project.orderedAssets.map(\.kind), [.original, .retouched, .atmosphere])
XCTAssertEqual(
project.asset(for: .retouched)?.displayURL,
"https://cdn.example.com/refined.jpg"
)
XCTAssertEqual(
project.asset(for: .atmosphere)?.previewURL,
"https://cdn.example.com/atmosphere.jpg"
)
XCTAssertNil(project.asset(for: .cover))
XCTAssertTrue(project.hasVariants)
XCTAssertEqual(project.aiRetouchBatchId, 55)
}
func testPreviewProjectIgnoresBlankAIResultURLs() {
let material = TravelAlbumMaterial(
id: 8,
fileUrl: "https://cdn.example.com/original.jpg",
aiRefinedURL: " \n ",
aiAtmosphereURL: "\t"
)
XCTAssertEqual(
TravelAlbumPreviewProject(material: material).orderedAssets.map(\.kind),
[.original]
)
}
func testPreviewProjectBuildsComparisonOnlyForValidAIResults() {
let project = makePreviewProject(id: 8, kinds: [.original, .retouched, .atmosphere, .cover])
XCTAssertEqual(
project.comparisonContent(for: .retouched),
BeforeAfterComparisonContent(
beforeURL: "https://cdn.example.com/8-0.jpg",
afterURL: "https://cdn.example.com/8-1.jpg"
)
)
XCTAssertNotNil(project.comparisonContent(for: .atmosphere))
XCTAssertNil(project.comparisonContent(for: .original))
XCTAssertNil(project.comparisonContent(for: .cover))
let missingResultURL = TravelAlbumPreviewProject(
originalMaterialId: 9,
assets: [
TravelAlbumPreviewAsset(
id: "original-9",
kind: .original,
fileURL: "https://cdn.example.com/original.jpg",
coverURL: "",
fileName: "原图.jpg",
fileSize: 1
),
TravelAlbumPreviewAsset(
id: "retouched-9",
kind: .retouched,
fileURL: " ",
coverURL: "",
fileName: "精修.jpg",
fileSize: 1
),
]
)
XCTAssertNil(missingResultURL.comparisonContent(for: .retouched))
}
func testTemplateComparisonContentRequiresBothURLs() {
let valid = TravelAlbumAIRetouchTemplate(
id: 1,
name: "清透",
previewURL: "https://cdn.example.com/preview.jpg",
beforeURL: " https://cdn.example.com/before.jpg ",
afterURL: "https://cdn.example.com/after.jpg"
)
let missingAfter = TravelAlbumAIRetouchTemplate(
id: 2,
name: "暖阳",
previewURL: "https://cdn.example.com/preview.jpg",
beforeURL: "https://cdn.example.com/before.jpg"
)
XCTAssertEqual(valid.comparisonContent?.beforeURL, "https://cdn.example.com/before.jpg")
XCTAssertEqual(valid.comparisonContent?.afterLabel, "效果图")
XCTAssertNil(missingAfter.comparisonContent)
}
func testTemplateDecodingDefaultsMissingComparisonURLsToEmptyStrings() throws {
let data = Data(
#"{"id":3,"name":"旧模板","preview_url":"https://cdn.example.com/preview.jpg"}"#.utf8
)
let template = try JSONDecoder().decode(TravelAlbumAIRetouchTemplate.self, from: data)
XCTAssertEqual(template.previewURL, "https://cdn.example.com/preview.jpg")
XCTAssertEqual(template.beforeURL, "")
XCTAssertEqual(template.afterURL, "")
XCTAssertNil(template.comparisonContent)
}
func testPreviewAssetFallsBackToAvailableURLForBothQualityLevels() {
let missingOriginal = TravelAlbumPreviewAsset(
id: "cover-only",
kind: .cover,
fileURL: " ",
coverURL: "https://cdn.example.com/cover.jpg",
fileName: "cover.jpg",
fileSize: 100
)
let missingCover = TravelAlbumPreviewAsset(
id: "original-only",
kind: .original,
fileURL: "https://cdn.example.com/original.jpg",
coverURL: "\n",
fileName: "original.jpg",
fileSize: 200
)
XCTAssertEqual(missingOriginal.displayURL, missingOriginal.coverURL)
XCTAssertEqual(missingOriginal.previewURL, missingOriginal.coverURL)
XCTAssertEqual(missingCover.displayURL, missingCover.fileURL)
XCTAssertEqual(missingCover.previewURL, missingCover.fileURL)
}
func testPreviewProjectKeepsCanonicalExistingVariantOrder() {
let project = makePreviewProject(kinds: [.cover, .original, .atmosphere])
XCTAssertEqual(project.orderedAssets.map(\.kind), [.original, .atmosphere, .cover])
XCTAssertTrue(project.hasVariants)
}
func testPreviewNavigatorBuildsNodesForBothModes() {
let projects = [
makePreviewProject(id: 1, kinds: [.original, .retouched]),
makePreviewProject(id: 2, kinds: [.original, .cover]),
]
XCTAssertEqual(
TravelAlbumPreviewNavigator.nodes(projects: projects, mode: .projectsOnly),
[
TravelAlbumPreviewNode(projectIndex: 0, kind: .original),
TravelAlbumPreviewNode(projectIndex: 1, kind: .original),
]
)
XCTAssertEqual(
TravelAlbumPreviewNavigator.nodes(projects: projects, mode: .includeVariants).map(\.kind),
[.original, .retouched, .original, .cover]
)
}
func testPreviewNavigatorBackwardFromOriginalTargetsPreviousOriginal() {
let projects = [
makePreviewProject(id: 1, kinds: [.original, .retouched, .cover]),
makePreviewProject(id: 2, kinds: [.original, .atmosphere]),
]
let nodes = TravelAlbumPreviewNavigator.nodes(projects: projects, mode: .includeVariants)
let secondOriginal = nodes.firstIndex {
$0.projectIndex == 1 && $0.kind == .original
}!
let target = TravelAlbumPreviewNavigator.backwardTargetIndex(nodes: nodes, currentIndex: secondOriginal)
XCTAssertEqual(nodes[target], TravelAlbumPreviewNode(projectIndex: 0, kind: .original))
}
func testPreviewDeletionPrefersNextThenFallsBackToPreviousAndClosesWhenEmpty() {
XCTAssertEqual(
TravelAlbumPreviewNavigator.projectIndexAfterDeletion(
deletedProjectIndex: 0,
remainingProjectCount: 2
),
0
)
XCTAssertEqual(
TravelAlbumPreviewNavigator.projectIndexAfterDeletion(
deletedProjectIndex: 1,
remainingProjectCount: 2
),
1
)
XCTAssertEqual(
TravelAlbumPreviewNavigator.projectIndexAfterDeletion(
deletedProjectIndex: 2,
remainingProjectCount: 2
),
1
)
XCTAssertNil(
TravelAlbumPreviewNavigator.projectIndexAfterDeletion(
deletedProjectIndex: 0,
remainingProjectCount: 0
)
)
}
func testPreviewProjectMapsCurrentTabToMinimalAIRetouchWorkflow() {
let originalOnly = makePreviewProject(id: 9, kinds: [.original])
let variants = TravelAlbumPreviewProject(
originalMaterialId: 10,
aiRetouchBatchId: 88,
assets: makePreviewProject(id: 10, kinds: [.original, .retouched, .atmosphere]).assets
)
XCTAssertEqual(
originalOnly.aiRetouchWorkflow(albumId: 7, selectedKind: .original),
.initial(albumId: 7, materialIds: [9])
)
XCTAssertEqual(
variants.aiRetouchWorkflow(albumId: 7, selectedKind: .original),
.reretouch(materialId: 10, batchId: 88, type: .all)
)
XCTAssertEqual(
variants.aiRetouchWorkflow(albumId: 7, selectedKind: .retouched),
.reretouch(materialId: 10, batchId: 88, type: .refined)
)
XCTAssertEqual(
variants.aiRetouchWorkflow(albumId: 7, selectedKind: .atmosphere),
.reretouch(materialId: 10, batchId: 88, type: .atmosphere)
)
XCTAssertNil(variants.aiRetouchWorkflow(albumId: 7, selectedKind: .cover))
}
func testPlaceholderPreviewDeleteReturnsUnavailableWithoutMutation() async {
let handler = PlaceholderTravelAlbumPreviewActionHandler()
let deleteResult = await handler.deleteProject(originalMaterialId: 9)
XCTAssertEqual(
deleteResult,
.unavailable("项目删除接口待接入")
)
}
func testTravelAlbumDecodesSnakeCaseFields() throws { func testTravelAlbumDecodesSnakeCaseFields() throws {
let json = """ let json = """
{ {
@@ -50,6 +346,11 @@ final class TravelAlbumModelsTests: XCTestCase {
"file_size": 2048, "file_size": 2048,
"cover_url": "", "cover_url": "",
"is_purchased": false, "is_purchased": false,
"ai_retouch_status": 3,
"ai_retouch_status_name": "AI已修",
"ai_retouch_batch_id": 66,
"ai_refined_url": "https://cdn/refined.jpg",
"ai_atmosphere_url": "https://cdn/atmosphere.jpg",
"created_at": "", "created_at": "",
"updated_at": "" "updated_at": ""
} }
@@ -59,6 +360,73 @@ final class TravelAlbumModelsTests: XCTestCase {
XCTAssertEqual(material.userEquityTravelId, 7) XCTAssertEqual(material.userEquityTravelId, 7)
XCTAssertEqual(material.fileName, "IMG_0001.JPG") XCTAssertEqual(material.fileName, "IMG_0001.JPG")
XCTAssertFalse(material.isPurchased) XCTAssertFalse(material.isPurchased)
XCTAssertEqual(material.aiRetouchStatus, 3)
XCTAssertEqual(material.aiRetouchStatusName, "AI已修")
XCTAssertEqual(material.aiRetouchBatchId, 66)
XCTAssertEqual(material.aiRefinedURL, "https://cdn/refined.jpg")
XCTAssertEqual(material.aiAtmosphereURL, "https://cdn/atmosphere.jpg")
}
func testTravelAlbumMaterialDefaultsInvalidAIFields() throws {
let json = """
{
"id": 11,
"user_equity_travel_id": 7,
"status": 1,
"order_number": "",
"user_id": 9,
"file_name": "IMG_0001.JPG",
"file_type": 2,
"file_url": "https://cdn/a.jpg",
"file_size": 2048,
"cover_url": "",
"is_purchased": false,
"ai_retouch_status": "invalid",
"ai_retouch_status_name": null,
"ai_retouch_batch_id": "invalid",
"ai_refined_url": 123,
"created_at": "",
"updated_at": ""
}
""".data(using: .utf8)!
let material = try JSONDecoder().decode(TravelAlbumMaterial.self, from: json)
XCTAssertEqual(material.aiRetouchStatus, 0)
XCTAssertEqual(material.aiRetouchStatusName, "")
XCTAssertEqual(material.aiRetouchBatchId, 0)
XCTAssertEqual(material.aiRefinedURL, "")
XCTAssertEqual(material.aiAtmosphereURL, "")
}
func testTravelAlbumMaterialBadgePriorityAndFallbacks() {
XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 0).badgePresentation)
XCTAssertEqual(
TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 0, aiRetouchStatusName: "已上传")
.badgePresentation,
TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
)
XCTAssertEqual(
TravelAlbumMaterial(aiRetouchStatus: 1, aiRetouchStatusName: " 排队中 ").badgePresentation,
TravelAlbumMaterialBadgePresentation(kind: .pending, text: "排队中")
)
XCTAssertEqual(
TravelAlbumMaterial(aiRetouchStatus: 2, aiRetouchStatusName: " ").badgePresentation,
TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中")
)
XCTAssertEqual(
TravelAlbumMaterial(aiRetouchStatus: 3).badgePresentation,
TravelAlbumMaterialBadgePresentation(kind: .retouched, text: "AI已修")
)
XCTAssertEqual(
TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面").badgePresentation,
TravelAlbumMaterialBadgePresentation(kind: .cover, text: "AI封面")
)
XCTAssertEqual(
TravelAlbumMaterial(aiRetouchStatus: 4).badgePresentation,
TravelAlbumMaterialBadgePresentation(kind: .failed, text: "失败")
)
XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 99, aiRetouchStatusName: "未知").badgePresentation)
} }
func testDisplayFormatters() { func testDisplayFormatters() {
@@ -68,6 +436,25 @@ final class TravelAlbumModelsTests: XCTestCase {
XCTAssertEqual(TravelAlbumDisplayFormatter.fileSizeText(2 * 1024 * 1024), "2.0MB") XCTAssertEqual(TravelAlbumDisplayFormatter.fileSizeText(2 * 1024 * 1024), "2.0MB")
} }
private func makePreviewProject(
id: Int = 1,
kinds: [TravelAlbumPreviewAssetKind]
) -> TravelAlbumPreviewProject {
TravelAlbumPreviewProject(
originalMaterialId: id,
assets: kinds.map { kind in
TravelAlbumPreviewAsset(
id: "\(id)-\(kind.rawValue)",
kind: kind,
fileURL: "https://cdn.example.com/\(id)-\(kind.rawValue).jpg",
coverURL: "",
fileName: "\(kind.title).jpg",
fileSize: 1024
)
}
)
}
@MainActor @MainActor
func testBuildTodayAlbumNameUsesExistingSameDayCount() { func testBuildTodayAlbumNameUsesExistingSameDayCount() {
let date = Calendar(identifier: .gregorian).date(from: DateComponents(year: 2026, month: 7, day: 7))! let date = Calendar(identifier: .gregorian).date(from: DateComponents(year: 2026, month: 7, day: 7))!
+323 -3
View File
@@ -6,6 +6,96 @@
import XCTest import XCTest
@testable import suixinkan @testable import suixinkan
/// AI 修图任务中心 ViewModel 测试。
@MainActor
final class TravelAlbumAIJobViewModelTests: XCTestCase {
func testListFiltersPaginatesAndDeduplicatesJobs() async {
let api = TravelAlbumMockAPI()
api.aiJobListResponses = [
TravelAlbumAIJobListResponse(
items: [makeSummary(id: 1, status: .processing)],
nextCursor: "page-2",
hasMore: true
),
TravelAlbumAIJobListResponse(
items: [makeSummary(id: 1, status: .processing), makeSummary(id: 2, status: .succeeded)],
nextCursor: nil,
hasMore: false
),
]
let viewModel = TravelAlbumAIJobListViewModel()
await viewModel.selectFilter(.inProgress, api: api)
await viewModel.loadMore(api: api)
XCTAssertEqual(viewModel.selectedFilter, .inProgress)
XCTAssertEqual(viewModel.items.map(\.id), [1, 2])
XCTAssertTrue(viewModel.containsInProgressJobs)
XCTAssertEqual(api.aiJobListRequests.map(\.cursor), [nil, "page-2"])
XCTAssertEqual(api.aiJobListRequests.map(\.statusGroup), [.inProgress, .inProgress])
}
func testDetailStopsPollingAtTerminalStateAndRecognizes404() async {
let api = TravelAlbumMockAPI()
api.aiJobDetailResponse = makeDetail(status: .succeeded)
let viewModel = TravelAlbumAIJobDetailViewModel(batchId: 91)
await viewModel.load(api: api)
XCTAssertFalse(viewModel.shouldPoll)
XCTAssertEqual(viewModel.detail?.targets.first?.displayFailureMessage, "处理失败,请前往相册重新修图")
let missing = TravelAlbumAIJobDetailViewModel(batchId: 92)
api.aiJobDetailResponse = nil
var notFoundCount = 0
missing.onNotFound = { notFoundCount += 1 }
await missing.load(api: api)
await missing.load(api: api)
XCTAssertTrue(missing.isNotFound)
XCTAssertEqual(notFoundCount, 1)
}
private func makeSummary(id: Int, status: TravelAlbumAIJobStatus) -> TravelAlbumAIJobSummary {
TravelAlbumAIJobSummary(
aiRetouchBatchId: id,
userEquityTravelId: 6,
scope: "album",
status: status,
album: TravelAlbumAIJobAlbum(id: 6, name: "九寨沟", userPhone: "138****0000", coverURL: ""),
sourceCount: 1,
outputs: [TravelAlbumAIJobOutput(type: .refined, count: 1)],
previewImages: [],
progress: TravelAlbumAIJobProgress(total: 1, queued: 0, processing: status.isInProgress ? 1 : 0, succeeded: status == .succeeded ? 1 : 0, failed: 0, canceled: 0),
estimatedFinishAt: nil,
failureSummary: nil,
createdAt: "2026-08-14T06:00:00Z",
startedAt: nil,
finishedAt: nil
)
}
private func makeDetail(status: TravelAlbumAIJobStatus) -> TravelAlbumAIJobDetail {
TravelAlbumAIJobDetail(
aiRetouchBatchId: 91,
userEquityTravelId: 6,
scope: "album",
status: status,
album: TravelAlbumAIJobAlbum(id: 6, name: "九寨沟", userPhone: "138****0000", coverURL: ""),
sourceCount: 1,
outputs: [TravelAlbumAIJobOutput(type: .refined, count: 1)],
progress: TravelAlbumAIJobProgress(total: 1, queued: 0, processing: 0, succeeded: 0, failed: 1, canceled: 0),
quotaSettlement: TravelAlbumAIJobQuotaSettlement(status: "settled", reservedUnits: 1, consumedUnits: 0, releasedUnits: 1, coverUnits: 0),
targets: [TravelAlbumAIJobTarget(targetId: 1, sourceMaterial: nil, inputMaterialIds: [11], outputType: .refined, template: nil, status: .failed, resultAsset: nil, error: nil, createdAt: "", startedAt: nil, finishedAt: nil)],
estimatedFinishAt: nil,
createdAt: "2026-08-14T06:00:00Z",
startedAt: nil,
finishedAt: "2026-08-14T06:01:00Z",
durationSeconds: 60
)
}
}
@MainActor @MainActor
/// 旅拍相册 ViewModel 测试。 /// 旅拍相册 ViewModel 测试。
final class TravelAlbumEntryViewModelTests: XCTestCase { final class TravelAlbumEntryViewModelTests: XCTestCase {
@@ -80,6 +170,7 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
api.infoResponse = TravelAlbum(id: 2, name: "详情") api.infoResponse = TravelAlbum(id: 2, name: "详情")
api.materialListResponses = [ api.materialListResponses = [
TravelAlbumListResponse(total: 2, list: []), TravelAlbumListResponse(total: 2, list: []),
TravelAlbumListResponse(total: 1, list: []),
TravelAlbumListResponse(total: 2, list: [TravelAlbumMaterial(id: 1), TravelAlbumMaterial(id: 2)]), TravelAlbumListResponse(total: 2, list: [TravelAlbumMaterial(id: 1), TravelAlbumMaterial(id: 2)]),
] ]
let viewModel = TravelAlbumDetailViewModel(albumId: 2) let viewModel = TravelAlbumDetailViewModel(albumId: 2)
@@ -88,7 +179,28 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.album?.name, "详情") XCTAssertEqual(viewModel.album?.name, "详情")
XCTAssertEqual(viewModel.allPhotoCount, 2) XCTAssertEqual(viewModel.allPhotoCount, 2)
XCTAssertEqual(viewModel.purchasedPhotoCount, 1)
XCTAssertEqual(viewModel.materials.count, 2) XCTAssertEqual(viewModel.materials.count, 2)
XCTAssertEqual(api.materialRequests.map(\.pageSize), [1, 1, 30])
XCTAssertEqual(api.materialRequests.map(\.isPurchased), [nil, 1, nil])
}
func testCountFailuresDoNotBlockMaterialList() async {
let api = TravelAlbumMockAPI()
api.infoResponse = TravelAlbum(id: 2, name: "详情")
api.materialListFailingCallIndexes = [0, 1]
api.materialListResponses = [
TravelAlbumListResponse(total: 3, list: [TravelAlbumMaterial(id: 7)]),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.refreshAll(api: api)
XCTAssertEqual(api.materialRequests.count, 3)
XCTAssertEqual(viewModel.materials.map(\.id), [7])
XCTAssertEqual(viewModel.allPhotoCount, 3)
XCTAssertEqual(viewModel.purchasedPhotoCount, 0)
XCTAssertFalse(viewModel.isLoading)
} }
func testTabAndSortTriggerMaterialRequests() async { func testTabAndSortTriggerMaterialRequests() async {
@@ -106,6 +218,41 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
XCTAssertEqual(api.materialRequests.last?.orderBy, 4) XCTAssertEqual(api.materialRequests.last?.orderBy, 4)
} }
func testSortOptionsProvideCompactCurrentStateTitles() {
XCTAssertEqual(TravelAlbumDetailViewModel.SortOption.createdAsc.compactTitle, "时间 ↑")
XCTAssertEqual(TravelAlbumDetailViewModel.SortOption.createdDesc.compactTitle, "时间 ↓")
XCTAssertEqual(TravelAlbumDetailViewModel.SortOption.fileNameAsc.compactTitle, "名称 ↑")
XCTAssertEqual(TravelAlbumDetailViewModel.SortOption.fileNameDesc.compactTitle, "名称 ↓")
}
func testAlbumCoverFallsBackToFirstMaterial() {
let album = TravelAlbum(id: 2, coverUrl: "")
let coverMaterial = TravelAlbumMaterial(id: 1, fileUrl: "original", coverUrl: "https://cdn.example.com/cover.jpg")
let originalMaterial = TravelAlbumMaterial(id: 2, fileUrl: "https://cdn.example.com/original.jpg", coverUrl: "")
XCTAssertEqual(
TravelAlbumDisplayFormatter.albumCoverURL(album: album, materials: [coverMaterial]),
"https://cdn.example.com/cover.jpg"
)
XCTAssertEqual(
TravelAlbumDisplayFormatter.albumCoverURL(album: album, materials: [originalMaterial]),
"https://cdn.example.com/original.jpg"
)
XCTAssertEqual(
TravelAlbumDisplayFormatter.albumCoverURL(
album: TravelAlbum(id: 2, coverUrl: "https://cdn.example.com/album.jpg"),
materials: [coverMaterial]
),
"https://cdn.example.com/album.jpg"
)
}
func testCreationTimeFormatting() {
XCTAssertEqual(TravelAlbumDisplayFormatter.creationTimeText("2026-08-03 14:05:22"), "2026/08/03 14:05")
XCTAssertEqual(TravelAlbumDisplayFormatter.creationTimeText("2026-08-03T14:05:22+08:00"), "2026/08/03 14:05")
XCTAssertEqual(TravelAlbumDisplayFormatter.creationTimeText(""), "--")
}
func testOnlyUnpurchasedMaterialCanBeSelected() { func testOnlyUnpurchasedMaterialCanBeSelected() {
let viewModel = TravelAlbumDetailViewModel(albumId: 2) let viewModel = TravelAlbumDetailViewModel(albumId: 2)
viewModel.toggleSelectionMode() viewModel.toggleSelectionMode()
@@ -116,6 +263,64 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.selectedMaterialIds, [1]) XCTAssertEqual(viewModel.selectedMaterialIds, [1])
} }
func testCompletingAIRetouchClearsSelectionModeAndSelectedMaterials() {
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
viewModel.toggleSelectionMode()
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 1))
viewModel.completeAIRetouchSubmission()
XCTAssertFalse(viewModel.isSelectionMode)
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
}
func testPreviewDeletionRemovesMaterialAndSynchronizesCounts() async {
let api = TravelAlbumMockAPI()
api.materialListResponses = [
TravelAlbumListResponse(
total: 3,
list: [
TravelAlbumMaterial(id: 1),
TravelAlbumMaterial(id: 2, isPurchased: true),
TravelAlbumMaterial(id: 3),
]
),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.loadMaterials(reset: true, api: api)
viewModel.removeMaterialAfterPreviewDeletion(id: 2)
XCTAssertEqual(viewModel.materials.map(\.id), [1, 3])
XCTAssertEqual(viewModel.allPhotoCount, 2)
XCTAssertEqual(viewModel.purchasedPhotoCount, 0)
}
func testRefreshMaterialUpdatesOnlyMatchingCachedMaterial() async throws {
let api = TravelAlbumMockAPI()
api.materialListResponses = [
TravelAlbumListResponse(
total: 2,
list: [TravelAlbumMaterial(id: 7), TravelAlbumMaterial(id: 8)]
),
]
api.materialInfoResponse = TravelAlbumMaterial(
id: 7,
aiRetouchBatchId: 700,
aiRefinedURL: "https://cdn/refined.jpg"
)
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
await viewModel.loadMaterials(reset: true, api: api)
let material = try await viewModel.refreshMaterial(id: 7, api: api)
XCTAssertEqual(material.aiRetouchBatchId, 700)
XCTAssertEqual(viewModel.materials.first { $0.id == 7 }?.aiRefinedURL, "https://cdn/refined.jpg")
XCTAssertEqual(viewModel.materials.first { $0.id == 8 }?.aiRetouchBatchId, 0)
XCTAssertEqual(api.materialInfoRequests, [.init(userEquityTravelId: 2, materialId: 7)])
XCTAssertEqual(api.materialRequests.count, 1)
}
func testDeleteAlbumCallsCallback() async { func testDeleteAlbumCallsCallback() async {
let api = TravelAlbumMockAPI() let api = TravelAlbumMockAPI()
let viewModel = TravelAlbumDetailViewModel(albumId: 5) let viewModel = TravelAlbumDetailViewModel(albumId: 5)
@@ -127,6 +332,26 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
XCTAssertEqual(api.deletedAlbumIds, [5]) XCTAssertEqual(api.deletedAlbumIds, [5])
XCTAssertEqual(deletedId, 5) XCTAssertEqual(deletedId, 5)
} }
func testDeleteSelectedMaterialsUsesSingleBatchRequest() async {
let api = TravelAlbumMockAPI()
api.materialListResponses = [
TravelAlbumListResponse(total: 0, list: []),
TravelAlbumListResponse(total: 0, list: []),
TravelAlbumListResponse(total: 0, list: []),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
viewModel.toggleSelectionMode()
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 2033, status: 1))
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 2031, status: 1))
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 2032, status: 1))
await viewModel.deleteSelectedMaterials(api: api)
XCTAssertEqual(api.deletedMaterialIDBatches, [[2031, 2032, 2033]])
XCTAssertFalse(viewModel.isSelectionMode)
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
}
} }
/// 有线传输 ViewModel 测试。 /// 有线传输 ViewModel 测试。
@@ -941,6 +1166,11 @@ private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
/// 旅拍相册 API 测试替身。 /// 旅拍相册 API 测试替身。
@MainActor @MainActor
final class TravelAlbumMockAPI: TravelAlbumServing { final class TravelAlbumMockAPI: TravelAlbumServing {
struct AIJobListRequest: Equatable {
let statusGroup: TravelAlbumAIJobFilter
let limit: Int
let cursor: String?
}
struct MaterialRequest: Equatable { struct MaterialRequest: Equatable {
let userEquityTravelId: Int let userEquityTravelId: Int
let page: Int let page: Int
@@ -949,23 +1179,55 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
let isPurchased: Int? let isPurchased: Int?
} }
struct MaterialInfoRequest: Equatable {
let userEquityTravelId: Int
let materialId: Int
}
var availableOrdersResponse: [TravelAlbumAvailableOrder] = [] var availableOrdersResponse: [TravelAlbumAvailableOrder] = []
var listResponse = TravelAlbumListResponse<TravelAlbum>() var listResponse = TravelAlbumListResponse<TravelAlbum>()
var createResponse = TravelAlbumCreateResponse(id: 0) var createResponse = TravelAlbumCreateResponse(id: 0)
var infoResponse = TravelAlbum() var infoResponse = TravelAlbum()
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = [] var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
var materialListFailingCallIndexes: Set<Int> = []
var materialInfoResponse = TravelAlbumMaterial()
var materialInfoError: Error?
var uploadMaterialResponse = TravelAlbumMaterial() var uploadMaterialResponse = TravelAlbumMaterial()
var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: []) var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [])
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "") var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
var aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse()
var createError: Error? var createError: Error?
var aiRetouchTemplatesError: Error?
var submitAIRetouchError: Error?
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
private(set) var availableOrdersCallCount = 0 private(set) var availableOrdersCallCount = 0
private(set) var createRequests: [TravelAlbumCreateRequest] = [] private(set) var createRequests: [TravelAlbumCreateRequest] = []
private(set) var materialRequests: [MaterialRequest] = [] private(set) var materialRequests: [MaterialRequest] = []
private(set) var materialInfoRequests: [MaterialInfoRequest] = []
private(set) var uploadMaterialRequests: [TravelAlbumUploadMaterialRequest] = [] private(set) var uploadMaterialRequests: [TravelAlbumUploadMaterialRequest] = []
private(set) var materialClientPhotoIdsCallCount = 0 private(set) var materialClientPhotoIdsCallCount = 0
private(set) var deletedAlbumIds: [Int] = [] private(set) var deletedAlbumIds: [Int] = []
private(set) var deletedMaterialIds: [Int] = [] private(set) var deletedMaterialIDBatches: [[Int]] = []
private(set) var aiRetouchTemplateScenicIds: [Int] = []
private(set) var aiRetouchRequests: [TravelAlbumAIRetouchRequest] = []
private(set) var aiReretouchRequests: [TravelAlbumAIReretouchRequest] = []
private(set) var aiJobListRequests: [AIJobListRequest] = []
func availableOrders() async throws -> [TravelAlbumAvailableOrder] { func availableOrders() async throws -> [TravelAlbumAvailableOrder] {
availableOrdersCallCount += 1 availableOrdersCallCount += 1
@@ -993,6 +1255,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
orderBy: Int, orderBy: Int,
isPurchased: Int? isPurchased: Int?
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial> { ) async throws -> TravelAlbumListResponse<TravelAlbumMaterial> {
let callIndex = materialRequests.count
materialRequests.append( materialRequests.append(
MaterialRequest( MaterialRequest(
userEquityTravelId: userEquityTravelId, userEquityTravelId: userEquityTravelId,
@@ -1002,10 +1265,21 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
isPurchased: isPurchased isPurchased: isPurchased
) )
) )
if materialListFailingCallIndexes.contains(callIndex) {
throw APIError.serverCode(500, "素材数量加载失败")
}
if materialListResponses.isEmpty { return TravelAlbumListResponse() } if materialListResponses.isEmpty { return TravelAlbumListResponse() }
return materialListResponses.removeFirst() return materialListResponses.removeFirst()
} }
func materialInfo(userEquityTravelId: Int, materialId: Int) async throws -> TravelAlbumMaterial {
materialInfoRequests.append(
MaterialInfoRequest(userEquityTravelId: userEquityTravelId, materialId: materialId)
)
if let materialInfoError { throw materialInfoError }
return materialInfoResponse
}
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial { func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
uploadMaterialRequests.append(request) uploadMaterialRequests.append(request)
return uploadMaterialResponse return uploadMaterialResponse
@@ -1020,11 +1294,57 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
deletedAlbumIds.append(id) deletedAlbumIds.append(id)
} }
func deleteMaterial(id: Int) async throws { func batchDeleteMaterials(ids: [Int]) async throws {
deletedMaterialIds.append(id) deletedMaterialIDBatches.append(ids)
if deleteMaterialDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: deleteMaterialDelayNanoseconds)
}
if let deleteMaterialError { throw deleteMaterialError }
} }
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse { func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse {
mpCodeResponse mpCodeResponse
} }
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse {
aiRetouchTemplateScenicIds.append(scenicId)
if let aiRetouchTemplatesError { throw aiRetouchTemplatesError }
return aiRetouchTemplatesResponse
}
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 -> 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
}
} }