Author SHA1 Message Date
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
69 changed files with 6641 additions and 273 deletions
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;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1010501;
CURRENT_PROJECT_VERSION = 1020101;
DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
@@ -460,7 +460,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.5;
MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -502,7 +502,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1010501;
CURRENT_PROJECT_VERSION = 1020101;
DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
@@ -526,7 +526,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.5;
MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -741,7 +741,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1010501;
CURRENT_PROJECT_VERSION = 1020101;
DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
@@ -761,7 +761,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.5;
MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-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

@@ -70,10 +70,10 @@ enum AuthSessionHelper {
AppStore.shared.session.userId = String(user.businessUserId)
AppStore.shared.session.accountType = .scenicUser
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.realName = firstNonEmpty(user.realName, user.nickname, user.username)
AppStore.shared.session.userName = firstNonEmpty(user.nickname, user.realName)
AppStore.shared.session.realName = user.realName
AppStore.shared.session.phone = user.phone
AppStore.shared.session.currentScenicId = user.scenicId
AppStore.shared.session.currentScenicName = user.scenicName
@@ -90,10 +90,10 @@ enum AuthSessionHelper {
AppStore.shared.session.userId = String(user.businessUserId)
AppStore.shared.session.accountType = .storeUser
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.realName = firstNonEmpty(user.realName, user.userName, user.username)
AppStore.shared.session.userName = user.realName
AppStore.shared.session.realName = user.realName
AppStore.shared.session.phone = user.phone
AppStore.shared.session.avatar = user.avatar
AppStore.shared.session.currentScenicId = user.scenicId
@@ -196,7 +196,6 @@ struct V9ScenicUser: Decodable, Equatable {
let userId: Int
let scenicUserId: Int
let ssUserId: Int
let username: String
let realName: String
let nickname: String
let phone: String
@@ -212,7 +211,7 @@ struct V9ScenicUser: Decodable, Equatable {
}
var displayName: String {
scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? username
scenicName.nonEmpty ?? nickname.nonEmpty ?? realName
}
func toAccountSwitchAccount() -> AccountSwitchAccount {
@@ -238,8 +237,6 @@ struct V9ScenicUser: Decodable, Equatable {
case userId = "user_id"
case scenicUserId = "scenic_user_id"
case ssUserId = "ss_user_id"
case username = "user_name"
case legacyUsername = "username"
case realName = "real_name"
case nickname
case phone
@@ -258,9 +255,6 @@ struct V9ScenicUser: Decodable, Equatable {
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
scenicUserId = try container.decodeLossyInt(forKey: .scenicUserId) ?? 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)
nickname = try container.decodeLossyString(forKey: .nickname)
phone = try container.decodeLossyString(forKey: .phone)
@@ -281,8 +275,6 @@ struct V9StoreUser: Decodable, Equatable {
let id: Int
let userId: Int
let storeUserId: Int
let username: String
let userName: String
let realName: String
let phone: String
let avatar: String
@@ -300,14 +292,14 @@ struct V9StoreUser: Decodable, Equatable {
}
var displayName: String {
storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? username
storeName.nonEmpty ?? scenicName.nonEmpty ?? realName
}
func toAccountSwitchAccount() -> AccountSwitchAccount {
AccountSwitchAccount(
accountType: accountType.nonEmpty ?? Self.accountTypeValue,
businessUserId: businessUserId,
title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? "门店账号",
title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? "门店账号",
subtitle: joinAccountSubtitle(scenicName, roleName),
phone: phone,
realName: realName,
@@ -325,8 +317,6 @@ struct V9StoreUser: Decodable, Equatable {
case id
case userId = "user_id"
case storeUserId = "store_user_id"
case username
case userName = "user_name"
case realName = "real_name"
case phone
case avatar
@@ -346,8 +336,6 @@ struct V9StoreUser: Decodable, Equatable {
id = try container.decodeLossyInt(forKey: .id) ?? 0
userId = try container.decodeLossyInt(forKey: .userId) ?? 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)
phone = try container.decodeLossyString(forKey: .phone)
avatar = try container.decodeLossyString(forKey: .avatar)
@@ -69,9 +69,9 @@ enum PayPageBrandingPolicy {
/// 收款页当前账号信息的展示文本格式化规则。
enum PaymentAccountDisplayFormatter {
/// 解析当前登录摄影师名称,昵称为空时使用实名,仍为空时显示占位符。
static func photographerName(userName: String, realName: String) -> String {
userName.trimmedNonEmpty ?? realName.trimmedNonEmpty ?? "-"
/// 解析当前登录摄影师的真实姓名,空值显示占位符。
static func photographerName(realName: String) -> String {
realName.trimmedNonEmpty ?? "-"
}
/// 去除空值与重复项后,合并当前账号的全部店铺名称。
@@ -28,6 +28,7 @@ final class PaymentCollectionDetailsViewModel {
private var staticPayURL = ""
private var dynamicPayURL = ""
private var hasCompletedPayCodeRequest = false
private let appStore: AppStore
private let payPageConfigCache: PayPageConfigCaching
@@ -50,6 +51,11 @@ final class PaymentCollectionDetailsViewModel {
qrImage != nil && !staticPayURL.isEmpty
}
/// 是否应在二维码区域展示加载状态,首次请求开始前也视为加载中,避免短暂闪现错误占位。
var isPayCodeLoading: Bool {
loading || !hasCompletedPayCodeRequest
}
var displayScenicName: String {
scenicName.isEmpty ? "暂无景区" : scenicName
}
@@ -60,7 +66,6 @@ final class PaymentCollectionDetailsViewModel {
var displayPhotographerName: String {
PaymentAccountDisplayFormatter.photographerName(
userName: appStore.session.userName,
realName: appStore.session.realName
)
}
@@ -85,11 +90,11 @@ final class PaymentCollectionDetailsViewModel {
func loadPayCode(api: PaymentAPI) async {
guard !loading else { return }
refreshLocalState()
loading = true
notifyStateChange()
refreshLocalState()
defer {
loading = false
hasCompletedPayCodeRequest = true
notifyStateChange()
}
@@ -17,11 +17,11 @@ final class ProfileViewModel {
var onStateChange: (() -> Void)?
var displayNickname: String {
nonEmpty(userInfo?.nickname) ?? "未设置昵称"
nonEmpty(userInfo?.nickname) ?? AppStore.shared.session.userName.nonEmpty ?? "未设置昵称"
}
var displayRealName: String {
nonEmpty(userInfo?.realName) ?? "--"
nonEmpty(userInfo?.realName) ?? AppStore.shared.session.realName.nonEmpty ?? "--"
}
var displayPhone: String {
@@ -37,6 +37,17 @@ final class ProfileViewModel {
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 {
let name = AppStore.shared.session.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? "--" : name
@@ -112,6 +123,7 @@ final class ProfileViewModel {
if !info.roleName.isEmpty {
AppStore.shared.session.roleName = info.roleName
}
notifyStateChange()
if showPhotographerFields {
async let realName = api.realNameInfo()
@@ -0,0 +1,524 @@
# 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",
"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",
"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",
"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 张显示封面”规则。
- 客户端可默认选中必选分组的第一个可用模板,可选分组默认不选。
- 此接口用于 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?
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial>
/// 拉取单个相册素材及其最新关联图片信息。
func materialInfo(userEquityTravelId: Int, materialId: Int) async throws -> TravelAlbumMaterial
/// 上传并登记旅拍相册素材。
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial
@@ -38,11 +41,20 @@ protocol TravelAlbumServing {
/// 删除旅拍相册。
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
/// 拉取当前景区可用的 AI 修图模板。
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse
/// 提交相册素材 AI 修图任务。
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws
/// 提交单张素材重新修图任务。
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws
}
@MainActor
@@ -113,6 +125,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 {
try await client.send(APIRequest(method: .post, path: "\(basePath)/upload-material", body: request))
@@ -138,10 +164,14 @@ final class TravelAlbumAPI: TravelAlbumServing {
)
}
/// 删除旅拍相册素材。
func deleteMaterial(id: Int) async throws {
/// 批量删除旅拍相册素材。
func batchDeleteMaterials(ids: [Int]) async throws {
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 +185,39 @@ 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 修图任务;服务端 data 内容无需客户端消费。
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request)
)
}
/// 提交重新修图任务;服务端返回的批次与额度信息当前无需消费。
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "\(basePath)/ai-reretouch", body: request)
)
}
}
/// 旅拍相册 ID 请求体。
private struct TravelAlbumIDRequest: Encodable, Sendable {
let id: Int
}
/// 旅拍相册素材批量删除请求体。
private struct TravelAlbumBatchDeleteMaterialRequest: Encodable, Sendable {
let ids: [Int]
}
@@ -127,6 +127,11 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
let fileSize: Int
let coverUrl: String
let isPurchased: Bool
let aiRetouchStatus: Int
let aiRetouchStatusName: String
let aiRetouchBatchId: Int
let aiRefinedURL: String
let aiAtmosphereURL: String
let createdAt: String
let updatedAt: String
@@ -142,10 +147,42 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
case fileSize = "file_size"
case coverUrl = "cover_url"
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 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(
id: Int = 0,
userEquityTravelId: Int = 0,
@@ -158,6 +195,11 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
fileSize: Int = 0,
coverUrl: String = "",
isPurchased: Bool = false,
aiRetouchStatus: Int = 0,
aiRetouchStatusName: String = "",
aiRetouchBatchId: Int = 0,
aiRefinedURL: String = "",
aiAtmosphereURL: String = "",
createdAt: String = "",
updatedAt: String = ""
) {
@@ -172,11 +214,69 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
self.fileSize = fileSize
self.coverUrl = coverUrl
self.isPurchased = isPurchased
self.aiRetouchStatus = aiRetouchStatus
self.aiRetouchStatusName = aiRetouchStatusName
self.aiRetouchBatchId = aiRetouchBatchId
self.aiRefinedURL = aiRefinedURL
self.aiAtmosphereURL = aiAtmosphereURL
self.createdAt = createdAt
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`。
struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable {
let name: String
@@ -247,6 +347,160 @@ 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
enum CodingKeys: String, CodingKey {
case id
case name
case previewURL = "preview_url"
}
/// 创建 AI 修图模板。
init(id: Int, name: String, previewURL: String) {
self.id = id
self.name = name
self.previewURL = previewURL
}
}
/// 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 {
/// 脱敏手机号。
@@ -277,4 +531,22 @@ enum TravelAlbumDisplayFormatter {
}
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,223 @@
//
// TravelAlbumPreviewModels.swift
// suixinkan
//
import Foundation
/// 相册预览页横向滑动策略,由调用方通过配置注入。
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 }
}
/// 将素材映射为原图及实际存在的 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
}
}
}
/// 预览分页节点,记录当前图片所属项目及类型。
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,265 @@
//
// 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: (() -> 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 {
switch workflow {
case .initial(let albumId, let materialIds):
guard let refinedTemplateId = selectedRefinedTemplateId else {
onShowMessage?("请选择原图精修模板")
return
}
try await api.submitAIRetouch(
TravelAlbumAIRetouchRequest(
userEquityTravelId: albumId,
materialIds: materialIds,
refinedTemplateId: refinedTemplateId,
atmosphereTemplateId: selectedAtmosphereTemplateId,
coverTemplateId: selectedCoverTemplateId
)
)
case .reretouch(let materialId, let batchId, let type):
try await api.submitAIReretouch(
TravelAlbumAIReretouchRequest(
id: materialId,
aiRetouchBatchId: batchId,
type: type,
refinedTemplateId: type == .atmosphere ? nil : selectedRefinedTemplateId,
atmosphereTemplateId: type == .refined ? nil : selectedAtmosphereTemplateId
)
)
}
onSubmitted?()
} 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: "文件名倒序"
}
}
var compactTitle: String {
switch self {
case .createdAsc: "时间 ↑"
case .createdDesc: "时间 ↓"
case .fileNameAsc: "名称 ↑"
case .fileNameDesc: "名称 ↓"
}
}
}
private(set) var album: TravelAlbum?
private(set) var materials: [TravelAlbumMaterial] = []
private(set) var allPhotoCount = 0
private(set) var purchasedPhotoCount = 0
private(set) var selectedTab: Tab = .all
private(set) var sortOption: SortOption = .createdDesc
private(set) var isLoading = true
@@ -65,6 +75,7 @@ final class TravelAlbumDetailViewModel {
notifyStateChange()
await loadAlbumInfo(api: api)
await loadAllPhotoCount(api: api)
await loadPurchasedPhotoCount(api: api)
await loadMaterials(reset: true, api: api)
isRefreshing = 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。
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
guard selectedTab != tab else { return }
@@ -144,6 +184,8 @@ final class TravelAlbumDetailViewModel {
canLoadMore = materials.count < response.total
if selectedTab == .all {
allPhotoCount = response.total
} else {
purchasedPhotoCount = response.total
}
isLoadingMore = false
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() {
guard selectedTab == .all else { return }
@@ -190,17 +246,34 @@ final class TravelAlbumDetailViewModel {
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 {
let ids = Array(selectedMaterialIds)
let ids = selectedMaterialIds.sorted()
guard !ids.isEmpty else {
onShowMessage?("请选择要删除的素材")
return
}
do {
for id in ids {
try await api.deleteMaterial(id: id)
}
try await api.batchDeleteMaterials(ids: ids)
onShowMessage?("删除成功")
isSelectionMode = false
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 {
var window: UIWindow?
private var sessionExpiredDialog: SessionExpiredDialogViewController?
func scene(
_ scene: UIScene,
@@ -86,18 +87,36 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}
@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()
AppStore.shared.logout()
AppRouter.setRoot(.login, on: window)
}
@objc private func handleUserDidLogout() {
PushNotificationManager.shared.handleLogout()
AppStore.shared.logout()
AppRouter.setRoot(.login, on: window)
transitionToLogin()
}
@objc private func handleUserDidLogin() {
sessionExpiredDialog?.dismiss(animated: false)
sessionExpiredDialog = nil
AppRouter.setRoot(.mainTab, on: window)
DispatchQueue.main.async {
PushNotificationManager.shared.handleLoginCompleted()
@@ -108,4 +127,18 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
@objc private func handleAccountDidSwitch() {
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()
}
}
@@ -28,6 +28,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private let qrContainerView = UIView()
private let qrImageView = UIImageView()
private let qrPlaceholderView = UIView()
private let qrLoadingIndicator = UIActivityIndicatorView(style: .medium)
private let qrErrorLabel = UILabel()
private let refreshButton = UIButton(type: .system)
private let setAmountButton = UIButton(type: .system)
@@ -127,6 +128,10 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24
qrPlaceholderView.isHidden = true
qrLoadingIndicator.color = AppColor.primary
qrLoadingIndicator.hidesWhenStopped = true
qrLoadingIndicator.accessibilityLabel = "收款码加载中"
qrErrorLabel.text = "未选景区或网络问题"
qrErrorLabel.font = .systemFont(ofSize: 14)
qrErrorLabel.textColor = AppColor.danger
@@ -150,6 +155,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
trailing: 24
)
}
actionRow.alpha = 0
actionRow.isUserInteractionEnabled = false
actionRow.accessibilityElementsHidden = true
configureLinkButton(setAmountButton, title: "设置金额")
configureLinkButton(saveQRButton, title: "保存二维码")
@@ -206,6 +214,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg
qrCardView.addSubview(qrContentStack)
qrPlaceholderView.addSubview(qrLoadingIndicator)
qrPlaceholderView.addSubview(qrErrorLabel)
qrPlaceholderView.addSubview(refreshButton)
@@ -275,6 +284,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
qrPlaceholderView.snp.makeConstraints { make in
make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182)
}
qrLoadingIndicator.snp.makeConstraints { make in
make.center.equalToSuperview()
}
qrErrorLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(56)
make.leading.trailing.equalToSuperview().inset(AppSpacing.sm)
@@ -370,9 +382,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private func loadData() async {
async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI)
showLoading()
await viewModel.loadPayCode(api: paymentAPI)
hideLoading()
await pageConfig
}
@@ -387,17 +397,33 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen
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.isHidden = false
qrContainerView.isHidden = !viewModel.usesNalatiBranding
qrPlaceholderView.isHidden = true
actionRow.isHidden = false
qrLoadingIndicator.stopAnimating()
setActionRowAvailable(true)
} else {
qrImageView.isHidden = true
qrContainerView.isHidden = true
qrPlaceholderView.backgroundColor = AppColor.inputBackground
qrPlaceholderView.isHidden = false
actionRow.isHidden = true
qrErrorLabel.isHidden = false
refreshButton.isHidden = false
qrLoadingIndicator.stopAnimating()
setActionRowAvailable(false)
}
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() {
guard amountDialog == nil else {
amountDialog?.apply(amount: viewModel.amount, remark: viewModel.remark)
@@ -625,9 +657,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
@objc private func refreshTapped() {
Task {
showLoading()
await viewModel.refresh(api: paymentAPI)
hideLoading()
}
}
@@ -38,6 +38,11 @@ final class ProfileViewController: BaseViewController {
title = "我的"
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF7FAFF)
@@ -104,14 +109,12 @@ final class ProfileViewController: BaseViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
let showLoading = !hasLoadedProfileOnce
let showLoading = !hasLoadedProfileOnce && !viewModel.hasCachedBasicInfo
Task {
await reloadProfile(showGlobalLoading: showLoading)
if showLoading {
hasLoadedProfileOnce = true
}
}
}
private let headerContainer = UIView()
private let infoCardWrapper = UIView()
File diff suppressed because it is too large Load Diff
@@ -7,30 +7,41 @@ import Kingfisher
import SnapKit
import UIKit
/// 相册详情管理页,对齐 Android `TravelAlbumDetailScreen`。
/// 相册管理页,展示相册摘要、素材筛选排序、分页与批量操作。
final class TravelAlbumDetailViewController: BaseViewController {
private let viewModel: TravelAlbumDetailViewModel
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 manageCard = UIView()
private let tabStack = UIStackView()
private let sectionTitleLabel = UILabel()
private let segmentedControl = UIView()
private let allTabButton = UIButton(type: .system)
private let purchasedTabButton = UIButton(type: .system)
private let sortButton = UIButton(type: .system)
private let selectButton = UIButton(type: .system)
private let refreshControl = UIRefreshControl()
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>!
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 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)
self.api = api ?? NetworkServices.shared.travelAlbumAPI
self.previewConfiguration = previewConfiguration
self.scenicIdProvider = scenicIdProvider
super.init(nibName: nil, bundle: nil)
}
@@ -40,7 +51,20 @@ final class TravelAlbumDetailViewController: BaseViewController {
}
override func setupNavigationBar() {
title = "相册管理"
let titleLabel = UILabel()
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
navigationItem.rightBarButtonItem = UIBarButtonItem(
image: UIImage(systemName: "ellipsis"),
menu: UIMenu(children: [
@@ -49,29 +73,38 @@ final class TravelAlbumDetailViewController: BaseViewController {
},
])
)
navigationItem.rightBarButtonItem?.accessibilityLabel = "更多操作"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
view.backgroundColor = TravelAlbumDetailStyle.pageBackground
manageCard.backgroundColor = .white
manageCard.layer.cornerRadius = 12
manageCard.layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor
manageCard.layer.shadowOpacity = 1
manageCard.layer.shadowRadius = 6
manageCard.layer.shadowOffset = CGSize(width: 0, height: 2)
sectionTitleLabel.text = "照片"
sectionTitleLabel.textColor = TravelAlbumDetailStyle.textPrimary
sectionTitleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
tabStack.axis = .horizontal
tabStack.spacing = 8
configurePillButton(allTabButton)
configurePillButton(purchasedTabButton)
configureIconButton(sortButton, image: UIImage(systemName: "line.3.horizontal.decrease.circle.fill"))
configureIconButton(selectButton, image: UIImage(systemName: "circle"))
segmentedControl.backgroundColor = .white
segmentedControl.layer.cornerRadius = 9
segmentedControl.layer.borderWidth = 1
segmentedControl.layer.borderColor = TravelAlbumDetailStyle.border.cgColor
segmentedControl.clipsToBounds = true
configureTabButtonBase(allTabButton)
configureTabButtonBase(purchasedTabButton)
configureSortButton()
configureSelectButton()
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
collectionView.backgroundColor = .white
collectionView.backgroundColor = .clear
collectionView.alwaysBounceVertical = true
collectionView.showsVerticalScrollIndicator = false
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) {
[weak self] collectionView, indexPath, material in
let cell = collectionView.dequeueReusableCell(
@@ -86,74 +119,105 @@ final class TravelAlbumDetailViewController: BaseViewController {
return cell
}
bottomBar.backgroundColor = AppColor.pageBackground
bottomActionStack.axis = .vertical
bottomActionStack.spacing = 8
configureBottomButton(deleteSelectedButton, title: "删除选中(0)", color: UIColor(hex: 0xE53935))
configureBottomButton(uploadButton, title: "上传照片", color: AppColor.primary)
deleteSelectedButton.isHidden = true
deleteSelectedButton.alpha = 0
bottomBar.backgroundColor = .white
bottomDivider.backgroundColor = TravelAlbumDetailStyle.border.withAlphaComponent(0.65)
configureBottomButton(uploadButton, title: "上传照片", color: TravelAlbumDetailStyle.primary)
var uploadConfiguration = uploadButton.configuration
uploadConfiguration?.image = UIImage(systemName: "plus.circle.fill")
uploadConfiguration?.imagePadding = 8
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)
scrollContainer.addSubview(infoCard)
scrollContainer.addSubview(manageCard)
manageCard.addSubview(tabStack)
tabStack.addArrangedSubview(allTabButton)
tabStack.addArrangedSubview(purchasedTabButton)
manageCard.addSubview(sortButton)
manageCard.addSubview(selectButton)
manageCard.addSubview(collectionView)
view.addSubview(contentView)
contentView.addSubview(infoCard)
contentView.addSubview(sectionTitleLabel)
contentView.addSubview(segmentedControl)
segmentedControl.addSubview(allTabButton)
segmentedControl.addSubview(purchasedTabButton)
contentView.addSubview(sortButton)
contentView.addSubview(selectButton)
contentView.addSubview(collectionView)
view.addSubview(bottomBar)
bottomBar.addSubview(bottomActionStack)
bottomActionStack.addArrangedSubview(deleteSelectedButton)
bottomActionStack.addArrangedSubview(uploadButton)
bottomBar.addSubview(bottomDivider)
bottomBar.addSubview(uploadButton)
bottomBar.addSubview(selectionActionStack)
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
bottomActionStack.snp.makeConstraints { make in
make.top.equalToSuperview().offset(12)
make.leading.trailing.equalToSuperview().inset(20)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
deleteSelectedButton.snp.makeConstraints { make in
make.height.equalTo(48).priority(.high)
bottomDivider.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(0.5)
}
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.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(bottomBar.snp.top).offset(-8)
make.leading.trailing.equalToSuperview().inset(18)
make.bottom.equalTo(bottomBar.snp.top)
}
infoCard.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.greaterThanOrEqualTo(76)
make.height.equalTo(116)
}
manageCard.snp.makeConstraints { make in
make.top.equalTo(infoCard.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview()
sectionTitleLabel.snp.makeConstraints { make in
make.top.equalTo(infoCard.snp.bottom).offset(20)
make.leading.equalToSuperview()
}
tabStack.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(12)
segmentedControl.snp.makeConstraints { make in
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)
}
sortButton.snp.makeConstraints { make in
make.centerY.equalTo(tabStack)
make.centerY.equalTo(segmentedControl)
make.trailing.equalTo(selectButton.snp.leading).offset(-8)
make.size.equalTo(32)
}
selectButton.snp.makeConstraints { make in
make.centerY.equalTo(tabStack)
make.trailing.equalToSuperview().offset(-12)
make.size.equalTo(32)
make.width.equalTo(74)
make.height.equalTo(32)
make.leading.greaterThanOrEqualTo(segmentedControl.snp.trailing).offset(8)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(tabStack.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview().inset(12)
make.top.equalTo(segmentedControl.snp.bottom).offset(14)
make.leading.trailing.bottom.equalToSuperview()
}
}
@@ -161,10 +225,11 @@ final class TravelAlbumDetailViewController: BaseViewController {
infoCard.onCall = { [weak self] phone in self?.call(phone) }
allTabButton.addTarget(self, action: #selector(allTabTapped), 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)
aiRetouchButton.addTarget(self, action: #selector(aiRetouchTapped), for: .touchUpInside)
deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside)
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
@@ -188,15 +253,40 @@ final class TravelAlbumDetailViewController: BaseViewController {
@MainActor
private func applyViewModel() {
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(purchasedTabButton, title: "已购照片", selected: viewModel.selectedTab == .purchased)
selectButton.isHidden = viewModel.selectedTab != .all
selectButton.setImage(UIImage(systemName: viewModel.isSelectionMode ? "checkmark.circle.fill" : "circle"), for: .normal)
selectButton.tintColor = viewModel.isSelectionMode ? AppColor.primary : AppColor.textTertiary
deleteSelectedButton.setTitle("删除选中(\(viewModel.selectedMaterialIds.count))", for: .normal)
setDeleteSelectedButtonVisible(viewModel.isSelectionMode && !viewModel.selectedMaterialIds.isEmpty)
configureTabButton(allTabButton, title: "全部 \(viewModel.allPhotoCount)", selected: viewModel.selectedTab == .all)
configureTabButton(
purchasedTabButton,
title: "已购 \(viewModel.purchasedPhotoCount)",
selected: viewModel.selectedTab == .purchased
)
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>()
snapshot.appendSections([0])
@@ -206,76 +296,109 @@ final class TravelAlbumDetailViewController: BaseViewController {
}
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()
} else {
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.layer.cornerRadius = 16
button.setConfigurationContentInsets(
NSDirectionalEdgeInsets(top: 7, leading: 14, bottom: 7, trailing: 14)
)
button.accessibilityTraits.insert(.button)
}
private func configureTabButton(_ button: UIButton, title: String, selected: Bool) {
button.setTitle(title, for: .normal)
button.backgroundColor = selected ? AppColor.primary : UIColor(hex: 0xEAF4FF)
button.setTitleColor(selected ? .white : AppColor.primary, for: .normal)
button.backgroundColor = selected ? TravelAlbumDetailStyle.primary : .white
button.setTitleColor(selected ? .white : TravelAlbumDetailStyle.textSecondary, for: .normal)
button.accessibilityTraits = selected ? [.button, .selected] : [.button]
}
private func configureIconButton(_ button: UIButton, image: UIImage?) {
button.setImage(image, for: .normal)
button.backgroundColor = UIColor(hex: 0xEAF4FF)
button.tintColor = AppColor.primary
button.layer.cornerRadius = 16
private func configureSortButton() {
var configuration = UIButton.Configuration.plain()
configuration.image = UIImage(named: "travel_album_sort_icon")
configuration.imagePadding = 4
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) {
button.setTitle(title, for: .normal)
button.setTitleColor(.white, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
button.backgroundColor = color
button.layer.cornerRadius = 10
var configuration = UIButton.Configuration.filled()
configuration.title = title
configuration.baseBackgroundColor = color
configuration.baseForegroundColor = .white
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) {
guard visible != isDeleteSelectedButtonVisible else { return }
isDeleteSelectedButtonVisible = visible
let changes = {
self.deleteSelectedButton.isHidden = !visible
self.deleteSelectedButton.alpha = visible ? 1 : 0
self.view.layoutIfNeeded()
private func makeSortMenu() -> UIMenu {
let actions = TravelAlbumDetailViewModel.SortOption.allCases.map { option in
UIAction(
title: option.title,
state: option == viewModel.sortOption ? .on : .off
) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.setSortOption(option, api: self.api) }
}
guard view.window != nil else {
changes()
return
}
view.layoutIfNeeded()
UIView.animate(
withDuration: 0.25,
delay: 0,
options: [.curveEaseInOut, .beginFromCurrentState],
animations: changes
)
return UIMenu(title: "排序方式", options: .singleSelection, children: actions)
}
private func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { _, environment in
let spacing: CGFloat = 8
let width = (environment.container.effectiveContentSize.width - spacing * 2) / 3
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(width), heightDimension: .absolute(width + 38))
let spacing: CGFloat = 10
let width = floor((environment.container.effectiveContentSize.width - spacing * 2) / 3)
let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(width),
heightDimension: .absolute(width + 40)
)
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)
group.interItemSpacing = .fixed(spacing)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 12
section.interGroupSpacing = 14
return section
}
}
@@ -303,24 +426,38 @@ final class TravelAlbumDetailViewController: BaseViewController {
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() {
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] in
guard let self else { return }
self.viewModel.completeAIRetouchSubmission()
self.showToast("AI修图任务已提交")
}
)
present(controller, animated: true)
}
@objc private func deleteSelectedTapped() {
let count = viewModel.selectedMaterialIds.count
guard count > 0 else { return }
let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
@@ -341,12 +478,47 @@ final class TravelAlbumDetailViewController: BaseViewController {
)
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 {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let material = dataSource.itemIdentifier(for: indexPath) else { return }
if viewModel.isSelectionMode {
viewModel.toggleMaterialSelection(material)
} else {
presentPreview(startingWith: material)
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
@@ -355,66 +527,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 {
var onCall: ((String) -> Void)?
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 timeLabel = UILabel()
private let callButton = UIButton(type: .system)
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
layer.cornerRadius = 12
layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor
layer.shadowOpacity = 1
layer.shadowRadius = 6
layer.shadowOffset = CGSize(width: 0, height: 2)
clipsToBounds = true
layer.cornerRadius = 16
backgroundImageView.contentMode = .scaleAspectFill
coverImageView.contentMode = .scaleAspectFill
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.tintColor = AppColor.primary
callButton.backgroundColor = UIColor(hex: 0xEAF4FF)
callButton.layer.cornerRadius = 20
callButton.tintColor = TravelAlbumDetailStyle.primary
callButton.backgroundColor = .white.withAlphaComponent(0.9)
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)
iconBox.addSubview(iconView)
addSubview(backgroundImageView)
addSubview(coverImageView)
coverImageView.addSubview(coverPhotoIconView)
addSubview(nameLabel)
addSubview(phoneLabel)
addSubview(timeLabel)
addSubview(callButton)
iconBox.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(14)
make.size.equalTo(48)
backgroundImageView.snp.makeConstraints { $0.edges.equalToSuperview() }
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
make.center.equalToSuperview()
make.size.equalTo(28)
coverPhotoIconView.snp.makeConstraints { make in
make.trailing.bottom.equalToSuperview().inset(5)
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
make.top.equalToSuperview().offset(16)
make.leading.equalTo(iconBox.snp.trailing).offset(12)
make.trailing.equalTo(callButton.snp.leading).offset(-12)
make.top.equalTo(nameLabel.snp.bottom).offset(8)
make.leading.trailing.equalTo(nameLabel)
}
timeLabel.snp.makeConstraints { make in
make.top.equalTo(phoneLabel.snp.bottom).offset(6)
make.leading.trailing.equalTo(phoneLabel)
make.bottom.equalToSuperview().offset(-16)
}
callButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-14)
make.centerY.equalToSuperview()
make.size.equalTo(40)
make.leading.trailing.equalTo(nameLabel)
}
callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside)
}
@@ -424,10 +641,21 @@ private final class TravelAlbumInfoCard: UIView {
fatalError("init(coder:) has not been implemented")
}
func apply(album: TravelAlbum) {
func apply(album: TravelAlbum, coverURL: String) {
phone = album.displayPhone
phoneLabel.text = "手机号 \(TravelAlbumDisplayFormatter.maskPhone(album.displayPhone))"
timeLabel.text = "创建时间 \(album.createdAt)"
nameLabel.text = album.name.isEmpty ? "旅拍相册" : album.name
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() {
@@ -435,13 +663,14 @@ private final class TravelAlbumInfoCard: UIView {
}
}
/// 旅拍相册素材网格 cell。
private final class TravelAlbumMaterialCell: UICollectionViewCell {
/// 旅拍相册素材网格单元,展示正方形缩略图、文件名、大小和选择状态。
final class TravelAlbumMaterialCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumMaterialCell"
private let imageView = UIImageView()
private let statusLabel = UILabel()
private let checkImageView = UIImageView()
private let badgeView = UIView()
private let badgeLabel = UILabel()
private let nameLabel = UILabel()
private let sizeLabel = UILabel()
@@ -449,25 +678,33 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
super.init(frame: frame)
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 8
statusLabel.text = "已上传"
statusLabel.font = .systemFont(ofSize: 10)
statusLabel.textColor = .white
statusLabel.backgroundColor = UIColor(hex: 0x34C759)
statusLabel.layer.cornerRadius = 4
statusLabel.clipsToBounds = true
statusLabel.textAlignment = .center
imageView.layer.cornerRadius = 12
imageView.backgroundColor = UIColor(hex: 0xEAF4FF)
checkImageView.tintColor = .white
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
checkImageView.layer.cornerRadius = 10
nameLabel.font = .systemFont(ofSize: 11)
nameLabel.textColor = AppColor.textPrimary
checkImageView.layer.cornerRadius = 11
checkImageView.accessibilityIdentifier = "travelAlbum.materialSelectionCheck"
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
sizeLabel.font = .systemFont(ofSize: 10)
sizeLabel.textColor = AppColor.textTertiary
sizeLabel.textColor = TravelAlbumDetailStyle.textSecondary
contentView.addSubview(imageView)
imageView.addSubview(statusLabel)
imageView.addSubview(badgeView)
badgeView.addSubview(badgeLabel)
imageView.addSubview(checkImageView)
contentView.addSubview(nameLabel)
contentView.addSubview(sizeLabel)
@@ -475,17 +712,21 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
make.top.leading.trailing.equalToSuperview()
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
make.top.trailing.equalToSuperview().inset(4)
make.size.equalTo(20)
make.top.trailing.equalToSuperview().inset(6)
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
make.top.equalTo(imageView.snp.bottom).offset(4)
make.top.equalTo(imageView.snp.bottom).offset(6)
make.leading.trailing.equalToSuperview()
}
sizeLabel.snp.makeConstraints { make in
@@ -501,17 +742,25 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
func apply(material: TravelAlbumMaterial, selectionMode: Bool, selected: Bool) {
let urlString = material.coverUrl.isEmpty ? material.fileUrl : material.coverUrl
imageView.contentMode = .scaleAspectFill
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 {
imageView.image = UIImage(systemName: "photo")
imageView.tintColor = AppColor.primary
imageView.backgroundColor = UIColor(hex: 0xEAF2FF)
imageView.image = UIImage(systemName: "photo.fill")
imageView.tintColor = TravelAlbumDetailStyle.primary.withAlphaComponent(0.35)
imageView.contentMode = .center
}
checkImageView.isHidden = !selectionMode
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
checkImageView.tintColor = selected ? AppColor.primary : .white
nameLabel.text = material.fileName
checkImageView.tintColor = selected ? TravelAlbumDetailStyle.primary : .white
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)
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.accounts.map(\.businessUserId), [101, 201])
XCTAssertEqual(response.accounts.map(\.subtitle), ["示例景区 · 摄影师", "示例景区 · 店长"])
XCTAssertEqual(response.accounts.map(\.realName), ["张三", "李四"])
}
func testV9AuthResponseDecodesMissingAccountListsAsEmpty() throws {
@@ -137,4 +138,39 @@ final class AuthModelsTests: XCTestCase {
XCTAssertEqual(user.toAccountSwitchAccount().realName, "张三")
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, "门店真实姓名")
}
}
@@ -69,6 +69,26 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
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() {
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
viewModel.refreshLocalState()
@@ -79,8 +99,9 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
XCTAssertTrue(viewModel.isVoiceBroadcastOpen)
}
func testNalatiAccountInfoUsesLoggedInPhotographerAndAllPermissionStores() {
appStore.session.userName = "摄影师小王"
func testNalatiAccountInfoUsesPhotographerRealNameAndAllPermissionStores() {
appStore.session.userName = "摄影师昵称"
appStore.session.realName = "摄影师真实姓名"
appStore.permissions.saveRolePermissionList([
RolePermissionResponse(
role: RoleInfo(id: 41, name: "摄影师", roleCode: "photographer"),
@@ -93,10 +114,19 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
XCTAssertEqual(viewModel.displayPhotographerName, "摄影师小王")
XCTAssertEqual(viewModel.displayPhotographerName, "摄影师真实姓名")
XCTAssertEqual(viewModel.displayStoreNames, "空中草原店、河谷草原店")
}
func testPhotographerNameDoesNotFallBackToNicknameWhenRealNameMissing() {
appStore.session.userName = "摄影师昵称"
appStore.session.realName = " "
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
XCTAssertEqual(viewModel.displayPhotographerName, "-")
}
func testLoadPayCodeClearsQRWhenScenicMissing() async {
appStore.session.currentScenicId = 0
let session = MockURLSession(responses: [])
@@ -105,6 +135,7 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
await viewModel.loadPayCode(api: api)
XCTAssertFalse(viewModel.isPayCodeLoading)
XCTAssertFalse(viewModel.hasPayCode)
XCTAssertEqual(session.requests.count, 0)
}
+4 -3
View File
@@ -55,11 +55,12 @@ final class PaymentModelsTests: XCTestCase {
XCTAssertFalse(PayPageBrandingPolicy.isNalati(scenicId: 0))
}
func testPaymentAccountDisplayFormatterUsesCurrentPhotographerAndAllUniqueStores() {
func testPaymentAccountDisplayFormatterUsesRealNameAndAllUniqueStores() {
XCTAssertEqual(
PaymentAccountDisplayFormatter.photographerName(userName: " 当前摄影师 ", realName: "实名"),
"当前摄影师"
PaymentAccountDisplayFormatter.photographerName(realName: " 摄影师真实姓名 "),
"摄影师真实姓名"
)
XCTAssertEqual(PaymentAccountDisplayFormatter.photographerName(realName: " "), "-")
XCTAssertEqual(
PaymentAccountDisplayFormatter.storeNames(["一号店", " 二号店 ", "一号店", ""]),
"一号店、二号店"
@@ -26,6 +26,22 @@ final class ProfileViewModelTests: XCTestCase {
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() {
AppStore.shared.session.roleCode = AppRoleCode.photographer.rawValue
let viewModel = ProfileViewModel()
@@ -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 = { 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()
}
}
}
+182 -2
View File
@@ -51,16 +51,27 @@ final class TravelAlbumAPITests: XCTestCase {
}
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 code = envelopeJSON(#"{"mp_code_oss_url":"https://cdn/qr.png"}"#)
let session = MockURLSession(responses: [materialList, empty, code])
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)
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(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
@@ -73,6 +84,42 @@ final class TravelAlbumAPITests: XCTestCase {
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 {
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])
@@ -113,6 +160,139 @@ final class TravelAlbumAPITests: XCTestCase {
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"}],"atmosphere_templates":[{"id":2,"name":"暖阳","preview_url":"https://cdn/atmosphere.jpg"}],"cover_templates":[{"id":3,"name":"杂志","preview_url":"https://cdn/cover.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.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: [envelopeJSON(#"{"task_id":9}"#)])
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: [envelopeJSON(#"{"accepted":true}"#)])
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: [
envelopeJSON(#"{"accepted":true}"#),
envelopeJSON(#"{"accepted":true}"#),
envelopeJSON(#"{"accepted":true}"#),
])
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)
}
private func envelopeJSON(_ dataJSON: String) -> Data {
"""
{"code":100000,"msg":"success","data":\(dataJSON)}
@@ -0,0 +1,933 @@
//
// TravelAlbumDetailViewControllerTests.swift
// suixinkanTests
//
import UIKit
import XCTest
@testable import suixinkan
/// 相册管理页刷新、预览与选择态交互测试。
@MainActor
final class TravelAlbumDetailViewControllerTests: XCTestCase {
func testPullToRefreshReloadsGridAndEndsRefreshing() async throws {
let api = TravelAlbumMockAPI()
api.infoResponse = TravelAlbum(id: 2, name: "测试相册")
api.materialListResponses = [
TravelAlbumListResponse(total: 1, list: []),
TravelAlbumListResponse(total: 0, list: []),
TravelAlbumListResponse(total: 1, list: [TravelAlbumMaterial(id: 1)]),
TravelAlbumListResponse(total: 2, list: []),
TravelAlbumListResponse(total: 1, list: []),
TravelAlbumListResponse(
total: 2,
list: [TravelAlbumMaterial(id: 1), TravelAlbumMaterial(id: 2)]
),
]
let controller = TravelAlbumDetailViewController(albumId: 2, api: api)
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
window.rootViewController = controller
window.makeKeyAndVisible()
defer { window.isHidden = true }
controller.loadViewIfNeeded()
controller.view.layoutIfNeeded()
let collectionView = try XCTUnwrap(
controller.view.findSubview { $0 is UICollectionView } as? UICollectionView
)
let refreshControl = try XCTUnwrap(collectionView.refreshControl)
await waitUntil { api.materialRequests.count == 3 }
refreshControl.beginRefreshing()
XCTAssertTrue(refreshControl.isRefreshing)
refreshControl.sendActions(for: .valueChanged)
await waitUntil {
api.materialRequests.count == 6
&& !refreshControl.isRefreshing
&& collectionView.numberOfItems(inSection: 0) == 2
}
XCTAssertEqual(refreshControl.accessibilityIdentifier, "travelAlbum.refreshControl")
XCTAssertEqual(collectionView.numberOfItems(inSection: 0), 2)
XCTAssertFalse(refreshControl.isRefreshing)
}
func testPreviewDeleteHandlerCallsMaterialAPIAndPreservesServerFailure() async {
let api = TravelAlbumMockAPI()
let handler = TravelAlbumPreviewActionHandler(api: api)
let success = await handler.deleteProject(originalMaterialId: 8)
XCTAssertEqual(api.deletedMaterialIDBatches, [[8]])
XCTAssertEqual(success, .success("删除成功"))
api.deleteMaterialError = APIError.serverCode(500, "服务暂不可用")
let failure = await handler.deleteProject(originalMaterialId: 9)
XCTAssertEqual(api.deletedMaterialIDBatches, [[8], [9]])
XCTAssertEqual(failure, .failure("服务暂不可用"))
}
func testPreviewSuccessfulDeletionShowsNextProjectAndPreventsDuplicateRequests() async throws {
let api = TravelAlbumMockAPI()
api.deleteMaterialDelayNanoseconds = 1_000_000
var deletedIds: [Int] = []
let controller = TravelAlbumPhotoPreviewViewController(
projects: [
makePreviewProject(id: 1, fileName: "第一张.jpg"),
makePreviewProject(id: 2, fileName: "第二张.jpg"),
makePreviewProject(id: 3, fileName: "第三张.jpg"),
],
totalCount: 3,
startProjectIndex: 0,
actionHandler: TravelAlbumPreviewActionHandler(api: api),
onProjectDeleted: { deletedIds.append($0) }
)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
controller.view.layoutIfNeeded()
controller.deleteCurrentProjectAfterConfirmation()
controller.deleteCurrentProjectAfterConfirmation()
await waitUntil {
controller.view.allAccessibilityLabels().contains("第 1 张,共 2 张")
}
XCTAssertEqual(api.deletedMaterialIDBatches, [[1]])
XCTAssertEqual(deletedIds, [1])
XCTAssertTrue(
controller.view.allLabels().contains { $0.text == "第二张.jpg" }
)
}
func testPreviewDeletingLastProjectShowsPreviousProject() async {
let api = TravelAlbumMockAPI()
let controller = TravelAlbumPhotoPreviewViewController(
projects: [
makePreviewProject(id: 1, fileName: "第一张.jpg"),
makePreviewProject(id: 2, fileName: "第二张.jpg"),
makePreviewProject(id: 3, fileName: "第三张.jpg"),
],
totalCount: 3,
startProjectIndex: 2,
actionHandler: TravelAlbumPreviewActionHandler(api: api)
)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
controller.view.layoutIfNeeded()
controller.deleteCurrentProjectAfterConfirmation()
await waitUntil {
controller.view.allAccessibilityLabels().contains("第 2 张,共 2 张")
}
XCTAssertEqual(api.deletedMaterialIDBatches, [[3]])
XCTAssertTrue(
controller.view.allLabels().contains { $0.text == "第二张.jpg" }
)
}
func testPreviewDeletingOnlyProjectDismissesPage() async {
UIView.setAnimationsEnabled(false)
defer { UIView.setAnimationsEnabled(true) }
let api = TravelAlbumMockAPI()
let host = UIViewController()
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
window.rootViewController = host
window.makeKeyAndVisible()
let controller = TravelAlbumPhotoPreviewViewController(
projects: [makePreviewProject(id: 1, fileName: "仅有一张.jpg")],
totalCount: 1,
startProjectIndex: 0,
actionHandler: TravelAlbumPreviewActionHandler(api: api)
)
host.present(controller, animated: false)
await waitUntil { controller.presentingViewController === host }
controller.deleteCurrentProjectAfterConfirmation()
await waitUntil { host.presentedViewController == nil }
XCTAssertEqual(api.deletedMaterialIDBatches, [[1]])
XCTAssertNil(host.presentedViewController)
window.isHidden = true
}
func testPreviewImageViewKeepsViewportConstraintsWhenSwitchingVariant() throws {
UIView.setAnimationsEnabled(false)
defer { UIView.setAnimationsEnabled(true) }
let project = TravelAlbumPreviewProject(
originalMaterialId: 1,
assets: [
TravelAlbumPreviewAsset(
id: "original-1",
kind: .original,
fileURL: "",
coverURL: "",
fileName: "原图.jpg",
fileSize: 0
),
TravelAlbumPreviewAsset(
id: "retouched-1",
kind: .retouched,
fileURL: "",
coverURL: "",
fileName: "精修后.jpg",
fileSize: 0
),
]
)
let controller = TravelAlbumPhotoPreviewViewController(
projects: [project],
totalCount: 1,
startProjectIndex: 0
)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
controller.view.layoutIfNeeded()
let segmentedControl = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewVariantSegmentedControl"
} as? UISegmentedControl
)
segmentedControl.selectedSegmentIndex = 1
segmentedControl.sendActions(for: .valueChanged)
controller.view.layoutIfNeeded()
let imageView = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewImageView"
} as? UIImageView
)
let scrollView = try XCTUnwrap(imageView.superview as? UIScrollView)
scrollView.layoutIfNeeded()
XCTAssertEqual(imageView.bounds.size, scrollView.bounds.size)
XCTAssertFalse(imageView.constraintsAffectingLayout(for: .horizontal).isEmpty)
XCTAssertFalse(imageView.constraintsAffectingLayout(for: .vertical).isEmpty)
}
func testPreviewUsesScreenshotChromeOverFullScreenImageViewport() throws {
UIView.setAnimationsEnabled(false)
defer { UIView.setAnimationsEnabled(true) }
let project = TravelAlbumPreviewProject(
originalMaterialId: 1,
assets: [
TravelAlbumPreviewAsset(
id: "original-1",
kind: .original,
fileURL: "https://example.com/original.jpg",
coverURL: "https://example.com/cover.jpg",
fileName: "IMG_0002.jpeg",
fileSize: 2_516_582
),
TravelAlbumPreviewAsset(
id: "retouched-1",
kind: .retouched,
fileURL: "",
coverURL: "",
fileName: "IMG_0002.jpeg",
fileSize: 2_516_582
),
TravelAlbumPreviewAsset(
id: "atmosphere-1",
kind: .atmosphere,
fileURL: "",
coverURL: "",
fileName: "IMG_0002.jpeg",
fileSize: 2_516_582
),
]
)
let controller = TravelAlbumPhotoPreviewViewController(
projects: [project],
totalCount: 10,
startProjectIndex: 0
)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
controller.view.layoutIfNeeded()
let topChrome = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewTopChrome"
}
)
let bottomChrome = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewBottomChrome"
}
)
let collectionView = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewCollectionView"
} as? UICollectionView
)
let highResolutionButton = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewHighResolutionButton"
} as? UIButton
)
let segmentedControl = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewVariantSegmentedControl"
} as? UISegmentedControl
)
let backButton = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewBackButton"
} as? UIButton
)
let actionStack = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewActionStack"
} as? UIStackView
)
let aiRetouchButton = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewAIRetouchButton"
} as? UIButton
)
let deleteButton = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewDeleteButton"
} as? UIButton
)
let refreshButton = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewRefreshButton"
} as? UIButton
)
XCTAssertFalse(controller.prefersStatusBarHidden)
XCTAssertEqual(topChrome.backgroundColor, UIColor.black.withAlphaComponent(0.82))
XCTAssertEqual(bottomChrome.backgroundColor, UIColor.black.withAlphaComponent(0.82))
XCTAssertEqual(highResolutionButton.configuration?.title, "查看高清图")
XCTAssertEqual(segmentedControl.numberOfSegments, 3)
XCTAssertEqual(segmentedControl.selectedSegmentIndex, 0)
XCTAssertEqual(backButton.bounds.size, CGSize(width: 44, height: 44))
XCTAssertNil(backButton.configuration?.baseBackgroundColor)
XCTAssertEqual(actionStack.spacing, 10)
XCTAssertTrue(actionStack.isLayoutMarginsRelativeArrangement)
XCTAssertEqual(actionStack.directionalLayoutMargins.top, 9)
XCTAssertEqual(actionStack.arrangedSubviews, [aiRetouchButton, deleteButton, refreshButton])
XCTAssertEqual(aiRetouchButton.bounds.height, 52, accuracy: 0.5)
XCTAssertEqual(deleteButton.bounds.height, 52, accuracy: 0.5)
XCTAssertEqual(refreshButton.bounds.height, 52, accuracy: 0.5)
XCTAssertEqual(aiRetouchButton.configuration?.baseBackgroundColor?.travelAlbumTestHexRGB, 0x1677FF)
XCTAssertEqual(deleteButton.configuration?.baseForegroundColor?.travelAlbumTestHexRGB, 0xFF6B6B)
XCTAssertEqual(refreshButton.configuration?.baseBackgroundColor?.travelAlbumTestHexRGB, 0x1C1C1E)
XCTAssertEqual(aiRetouchButton.configuration?.imagePlacement, .leading)
XCTAssertEqual(collectionView.frame, controller.view.bounds)
XCTAssertLessThan(collectionView.frame.minY, topChrome.frame.maxY)
XCTAssertGreaterThan(collectionView.frame.maxY, bottomChrome.frame.minY)
XCTAssertEqual(collectionView.contentInsetAdjustmentBehavior, .never)
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "IMG_0002.jpeg" })
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "2.4MB" })
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "1/10" })
highResolutionButton.sendActions(for: .touchUpInside)
XCTAssertEqual(topChrome.alpha, 0)
XCTAssertEqual(bottomChrome.alpha, 0)
}
func testPreviewHidesVariantControlsAndHighResolutionButtonWithoutVariants() throws {
let project = TravelAlbumPreviewProject(
originalMaterialId: 1,
assets: [
TravelAlbumPreviewAsset(
id: "original-1",
kind: .original,
fileURL: "https://example.com/original.jpg",
coverURL: "https://example.com/cover.jpg",
fileName: "IMG_0002.jpeg",
fileSize: 2_516_582
),
]
)
let controller = TravelAlbumPhotoPreviewViewController(
projects: [project],
totalCount: 1,
startProjectIndex: 0
)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
controller.view.layoutIfNeeded()
let segmentedControl = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewVariantSegmentedControl"
} as? UISegmentedControl
)
let highResolutionButton = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewHighResolutionButton"
} as? UIButton
)
let collectionView = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewCollectionView"
} as? UICollectionView
)
let bottomChrome = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewBottomChrome"
}
)
XCTAssertTrue(segmentedControl.isHidden)
XCTAssertTrue(highResolutionButton.isHidden)
XCTAssertEqual(collectionView.frame, controller.view.bounds)
XCTAssertGreaterThan(collectionView.frame.maxY, bottomChrome.frame.minY)
}
func testSelectionModeShowsAIRetouchOnLeftAndDeleteOnRight() async throws {
let controller = TravelAlbumDetailViewController(albumId: 0)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
let selectButton = try XCTUnwrap(
controller.view.findSubview {
($0 as? UIButton)?.accessibilityLabel == "选择照片"
} as? UIButton
)
selectButton.sendActions(for: .touchUpInside)
await Task.yield()
controller.view.layoutIfNeeded()
let actionStack = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.selectionActionStack"
} as? UIStackView
)
let aiRetouchButton = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchButton"
} as? UIButton
)
let deleteButton = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.deleteButton"
} as? UIButton
)
XCTAssertFalse(actionStack.isHidden)
XCTAssertEqual(actionStack.arrangedSubviews, [aiRetouchButton, deleteButton])
XCTAssertEqual(actionStack.distribution, .fillEqually)
XCTAssertEqual(actionStack.spacing, 12)
XCTAssertEqual(aiRetouchButton.configuration?.title, "AI修图")
XCTAssertEqual(deleteButton.configuration?.title, "删除")
XCTAssertEqual(aiRetouchButton.bounds.width, deleteButton.bounds.width, accuracy: 0.5)
XCTAssertLessThan(aiRetouchButton.frame.minX, deleteButton.frame.minX)
}
func testAIRetouchTemplateSheetUsesLargeDetentAndFixedBottomActions() async throws {
let api = TravelAlbumMockAPI()
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")],
atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")],
coverTemplates: [TravelAlbumAIRetouchTemplate(id: 3, name: "杂志", previewURL: "")],
remainingQuota: 8
)
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
albumId: 9,
scenicId: 18,
materialIds: [1, 2, 3, 4]
)
let controller = TravelAlbumAIRetouchTemplateViewController(
viewModel: viewModel,
api: api,
onSubmitted: {}
)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
await waitUntil { !viewModel.isLoading && api.aiRetouchTemplateScenicIds.count == 1 }
controller.view.layoutIfNeeded()
let sheet = try XCTUnwrap(controller.sheetPresentationController)
XCTAssertEqual(sheet.detents.count, 1)
XCTAssertEqual(sheet.selectedDetentIdentifier, .large)
XCTAssertTrue(sheet.prefersGrabberVisible)
let collectionView = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplateCollection"
} as? UICollectionView
)
let bottomBar = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchBottomBar"
}
)
let cancelButton = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchCancelButton"
} as? UIButton
)
let confirmButton = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchConfirmButton"
} as? UIButton
)
await waitUntil { confirmButton.isEnabled }
controller.view.layoutIfNeeded()
let selectionCountLabel = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchSelectionCountLabel"
} as? UILabel
)
let tipsLabel = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTipsLabel"
} as? UILabel
)
let tipsContainer = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTipsContainer"
}
)
XCTAssertTrue(collectionView.collectionViewLayout is UICollectionViewCompositionalLayout)
XCTAssertEqual(selectionCountLabel.text, "已选择 4 张照片")
XCTAssertEqual(
tipsLabel.text,
"每张照片生成精修结果,氛围感可选;本次可免费生成 1 张封面"
)
XCTAssertFalse(tipsContainer.isHidden)
XCTAssertEqual(tipsContainer.backgroundColor?.travelAlbumTestHexRGB, 0xF4F8FF)
XCTAssertEqual(tipsLabel.textColor.travelAlbumTestHexRGB, 0x385778)
XCTAssertEqual(tipsContainer.layer.cornerRadius, 12)
XCTAssertTrue(tipsContainer.isDescendant(of: collectionView))
XCTAssertFalse(bottomBar.isDescendant(of: collectionView))
XCTAssertEqual(cancelButton.currentTitle, "取消")
XCTAssertEqual(confirmButton.currentTitle, "确定")
XCTAssertLessThan(cancelButton.bounds.width, confirmButton.bounds.width)
XCTAssertTrue(confirmButton.isEnabled)
}
func testAIRetouchSheetShowsOptionalAtmosphereCoverAndModeCopy() async throws {
let api = TravelAlbumMockAPI()
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")],
atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")],
coverTemplates: [TravelAlbumAIRetouchTemplate(id: 3, name: "杂志", previewURL: "")],
remainingQuota: 8
)
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
albumId: 9,
scenicId: 18,
materialIds: [1, 2, 3, 4]
)
let controller = TravelAlbumAIRetouchTemplateViewController(
viewModel: viewModel,
api: api,
onSubmitted: {}
)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
await waitUntil { !viewModel.isLoading && api.aiRetouchTemplateScenicIds.count == 1 }
controller.view.layoutIfNeeded()
let collectionView = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplateCollection"
} as? UICollectionView
)
let accessibleLabels = controller.view.allAccessibilityLabels()
XCTAssertTrue(accessibleLabels.contains("原图精修,必选"))
XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填"))
XCTAssertTrue(accessibleLabels.contains("封面风格,赠送 · 不占额度"))
collectionView.scrollToItem(
at: IndexPath(item: 0, section: 4),
at: .top,
animated: false
)
controller.view.layoutIfNeeded()
let quotaLabel = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchRemainingQuotaLabel"
} as? UILabel
)
let requiredQuotaLabel = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchRequiredQuotaLabel"
} as? UILabel
)
XCTAssertEqual(requiredQuotaLabel.text, "预计消耗 4 张")
XCTAssertEqual(requiredQuotaLabel.accessibilityLabel, "预计消耗4张修图额度")
XCTAssertEqual(quotaLabel.text, "剩余 8 张")
XCTAssertEqual(quotaLabel.accessibilityLabel, "剩余8张修图额度")
}
func testAIRetouchTemplateCellExposesSelectedState() {
let cell = TravelAlbumAIRetouchTemplateCell(frame: .zero)
let template = TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")
var previewTapped = false
cell.onPreviewTapped = { previewTapped = true }
cell.apply(template: template, selected: true)
let previewButton = cell.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewButton"
} as? UIButton
let previewImageView = cell.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplateImage"
} as? UIImageView
previewButton?.sendActions(for: .touchUpInside)
XCTAssertNotNil(UIImage(named: "ai_retouch_template_placeholder"))
XCTAssertNotNil(previewImageView?.image)
XCTAssertEqual(cell.contentView.layer.borderWidth, 2)
XCTAssertTrue(cell.accessibilityTraits.contains(.selected))
XCTAssertEqual(cell.accessibilityValue, "已选择")
XCTAssertTrue(previewTapped)
cell.apply(template: template, selected: false)
XCTAssertEqual(cell.contentView.layer.borderWidth, 1)
XCTAssertFalse(cell.accessibilityTraits.contains(.selected))
XCTAssertEqual(cell.accessibilityValue, "未选择")
}
func testAIRetouchTemplatePreviewUsesImmersiveZoomableLayout() throws {
let template = TravelAlbumAIRetouchTemplate(
id: 2,
name: "简约留白",
previewURL: ""
)
let controller = TravelAlbumAIRetouchTemplatePreviewViewController(template: template)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
controller.view.layoutIfNeeded()
let scrollView = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewScrollView"
} as? UIScrollView
)
let backButton = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewBackButton"
} as? UIButton
)
XCTAssertEqual(controller.view.backgroundColor, .black)
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "简约留白" })
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "双指缩放查看细节" })
XCTAssertEqual(scrollView.minimumZoomScale, 1)
XCTAssertEqual(scrollView.maximumZoomScale, 4)
XCTAssertGreaterThanOrEqual(backButton.bounds.width, 44)
XCTAssertGreaterThanOrEqual(backButton.bounds.height, 44)
}
func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndStaysPresented() async throws {
UIView.setAnimationsEnabled(false)
defer { UIView.setAnimationsEnabled(true) }
let api = TravelAlbumMockAPI()
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 11, name: "清透", previewURL: "")],
atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 21, name: "暖阳", previewURL: "")],
remainingQuota: 8
)
let project = TravelAlbumPreviewProject(
originalMaterialId: 7,
aiRetouchBatchId: 70,
assets: [
TravelAlbumPreviewAsset(
id: "original-7",
kind: .original,
fileURL: "",
coverURL: "",
fileName: "原图.jpg",
fileSize: 0
),
TravelAlbumPreviewAsset(
id: "retouched-7",
kind: .retouched,
fileURL: "",
coverURL: "",
fileName: "精修后.jpg",
fileSize: 0
),
]
)
let controller = TravelAlbumPhotoPreviewViewController(
projects: [project],
totalCount: 1,
startProjectIndex: 0,
albumId: 8,
scenicIdProvider: { 18 },
aiRetouchAPI: api
)
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
window.rootViewController = controller
window.makeKeyAndVisible()
defer { window.isHidden = true }
controller.loadViewIfNeeded()
controller.view.layoutIfNeeded()
let segmentedControl = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewVariantSegmentedControl"
} as? UISegmentedControl
)
segmentedControl.selectedSegmentIndex = 1
segmentedControl.sendActions(for: .valueChanged)
let aiButton = try XCTUnwrap(
controller.view.findSubview { ($0 as? UIButton)?.accessibilityLabel == "AI修图" } as? UIButton
)
aiButton.sendActions(for: .touchUpInside)
await waitUntil { controller.presentedViewController is TravelAlbumAIRetouchTemplateViewController }
let sheet = try XCTUnwrap(controller.presentedViewController as? TravelAlbumAIRetouchTemplateViewController)
sheet.loadViewIfNeeded()
let confirmButton = try XCTUnwrap(
sheet.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchConfirmButton"
} as? UIButton
)
await waitUntil { api.aiRetouchTemplateScenicIds.count == 1 && confirmButton.isEnabled }
sheet.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
sheet.view.layoutIfNeeded()
let visibleLabelTexts = sheet.view.allLabels().compactMap { label in
label.isHidden ? nil : label.text
}
XCTAssertTrue(visibleLabelTexts.contains("原图精修"))
XCTAssertFalse(visibleLabelTexts.contains { $0.contains("氛围感修图") })
let selectionCountLabel = try XCTUnwrap(
sheet.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchSelectionCountLabel"
} as? UILabel
)
let tipsLabel = try XCTUnwrap(
sheet.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTipsLabel"
} as? UILabel
)
let tipsContainer = try XCTUnwrap(
sheet.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTipsContainer"
}
)
XCTAssertEqual(selectionCountLabel.text, "已选择 1 张照片")
XCTAssertTrue(tipsContainer.isHidden)
XCTAssertTrue(tipsLabel.isHidden)
confirmButton.sendActions(for: .touchUpInside)
await waitUntil { api.aiReretouchRequests.count == 1 }
await waitUntil { controller.presentedViewController == nil }
XCTAssertEqual(api.aiReretouchRequests.first?.type, .refined)
XCTAssertNil(controller.presentedViewController)
XCTAssertTrue(window.rootViewController === controller)
}
func testPreviewRefreshPreservesVariantAndFallsBackWhenVariantDisappears() async throws {
let original = TravelAlbumPreviewAsset(
id: "original-7",
kind: .original,
fileURL: "",
coverURL: "",
fileName: "原图.jpg",
fileSize: 0
)
let retouched = TravelAlbumPreviewAsset(
id: "retouched-7",
kind: .retouched,
fileURL: "",
coverURL: "",
fileName: "精修后.jpg",
fileSize: 0
)
var reloadCount = 0
let controller = TravelAlbumPhotoPreviewViewController(
projects: [TravelAlbumPreviewProject(originalMaterialId: 7, assets: [original, retouched])],
totalCount: 1,
startProjectIndex: 0,
reload: { materialId in
XCTAssertEqual(materialId, 7)
reloadCount += 1
let assets = reloadCount == 1 ? [original, retouched] : [original]
return TravelAlbumPreviewProject(originalMaterialId: 7, assets: assets)
}
)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
controller.view.layoutIfNeeded()
let segmentedControl = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewVariantSegmentedControl"
} as? UISegmentedControl
)
segmentedControl.selectedSegmentIndex = 1
segmentedControl.sendActions(for: .valueChanged)
let refreshButton = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewRefreshButton"
} as? UIButton
)
refreshButton.sendActions(for: .touchUpInside)
await waitUntil { reloadCount == 1 && refreshButton.isEnabled }
XCTAssertEqual(segmentedControl.selectedSegmentIndex, 1)
refreshButton.sendActions(for: .touchUpInside)
await waitUntil { reloadCount == 2 && refreshButton.isEnabled }
XCTAssertTrue(segmentedControl.isHidden)
XCTAssertEqual(segmentedControl.numberOfSegments, 0)
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "原图.jpg" })
}
func testPreviewRefreshUsesGlobalLoadingWithoutToast() async throws {
GlobalLoadingManager.shared.hideAll()
defer { GlobalLoadingManager.shared.hideAll() }
let original = TravelAlbumPreviewAsset(
id: "original-7",
kind: .original,
fileURL: "",
coverURL: "",
fileName: "原图.jpg",
fileSize: 0
)
let project = TravelAlbumPreviewProject(originalMaterialId: 7, assets: [original])
let controller = TravelAlbumPhotoPreviewViewController(
projects: [project],
totalCount: 1,
startProjectIndex: 0,
reload: { _ in
try await Task.sleep(nanoseconds: 50_000_000)
return project
}
)
controller.loadViewIfNeeded()
let refreshButton = try XCTUnwrap(
controller.view.findSubview {
$0.accessibilityIdentifier == "travelAlbum.previewRefreshButton"
} as? UIButton
)
refreshButton.sendActions(for: .touchUpInside)
XCTAssertTrue(GlobalLoadingManager.shared.isShowing)
await waitUntil { !GlobalLoadingManager.shared.isShowing && refreshButton.isEnabled }
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
XCTAssertFalse(controller.view.allLabels().contains { $0.text == "刷新成功" })
XCTAssertFalse(controller.view.allLabels().contains { $0.text == "刷新失败" })
}
func testMaterialCellShowsSemanticStatusBadgeWithoutOverlappingSelectionCheck() throws {
let cases: [(TravelAlbumMaterial, String, UInt)] = [
(TravelAlbumMaterial(isPurchased: true), "已购", 0x475569),
(TravelAlbumMaterial(aiRetouchStatus: 1), "待处理", 0xB45309),
(TravelAlbumMaterial(aiRetouchStatus: 2), "修图中", 0x1D4ED8),
(TravelAlbumMaterial(aiRetouchStatus: 3), "AI已修", 0x047857),
(
TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面"),
"AI封面",
0x6D28D9
),
(TravelAlbumMaterial(aiRetouchStatus: 4), "失败", 0xB91C1C),
]
for (material, expectedText, expectedColor) in cases {
let cell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220))
cell.apply(material: material, selectionMode: true, selected: false)
cell.layoutIfNeeded()
let badgeView = try XCTUnwrap(
cell.findSubview {
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadge"
}
)
let badgeLabel = try XCTUnwrap(
cell.findSubview {
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadgeLabel"
} as? UILabel
)
let selectionCheck = try XCTUnwrap(
cell.findSubview {
$0.accessibilityIdentifier == "travelAlbum.materialSelectionCheck"
}
)
XCTAssertFalse(badgeView.isHidden)
XCTAssertEqual(badgeLabel.text, expectedText)
XCTAssertEqual(badgeView.backgroundColor?.travelAlbumTestHexRGB, expectedColor)
XCTAssertLessThan(badgeView.frame.maxX, selectionCheck.frame.minX)
XCTAssertTrue(cell.accessibilityLabel?.contains("状态:\(expectedText)") == true)
}
let hiddenCell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220))
hiddenCell.apply(
material: TravelAlbumMaterial(aiRetouchStatus: 0, aiRetouchStatusName: "已上传"),
selectionMode: false,
selected: false
)
let hiddenBadge = try XCTUnwrap(
hiddenCell.findSubview {
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadge"
}
)
XCTAssertTrue(hiddenBadge.isHidden)
XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("状态:") == true)
}
private func waitUntil(_ condition: @escaping () -> Bool) async {
for _ in 0 ..< 200 {
if condition() { return }
try? await Task.sleep(nanoseconds: 1_000_000)
}
}
private func makePreviewProject(id: Int, fileName: String) -> TravelAlbumPreviewProject {
TravelAlbumPreviewProject(
originalMaterialId: id,
assets: [
TravelAlbumPreviewAsset(
id: "original-\(id)",
kind: .original,
fileURL: "",
coverURL: "",
fileName: fileName,
fileSize: 0
),
]
)
}
}
private extension UIColor {
var travelAlbumTestHexRGB: UInt {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return UInt(round(red * 255)) << 16
| UInt(round(green * 255)) << 8
| UInt(round(blue * 255))
}
}
private extension UIView {
func findSubview(where predicate: (UIView) -> Bool) -> UIView? {
if predicate(self) { return self }
for subview in subviews {
if let match = subview.findSubview(where: predicate) {
return match
}
}
return nil
}
func allAccessibilityLabels() -> [String] {
let current = accessibilityLabel.map { [$0] } ?? []
return current + subviews.flatMap { $0.allAccessibilityLabels() }
}
func allLabels() -> [UILabel] {
let current = (self as? UILabel).map { [$0] } ?? []
return current + subviews.flatMap { $0.allLabels() }
}
}
+282
View File
@@ -8,6 +8,197 @@ import XCTest
/// 旅拍相册模型与展示工具测试。
final class TravelAlbumModelsTests: XCTestCase {
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 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 {
let json = """
{
@@ -50,6 +241,11 @@ final class TravelAlbumModelsTests: XCTestCase {
"file_size": 2048,
"cover_url": "",
"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": "",
"updated_at": ""
}
@@ -59,6 +255,73 @@ final class TravelAlbumModelsTests: XCTestCase {
XCTAssertEqual(material.userEquityTravelId, 7)
XCTAssertEqual(material.fileName, "IMG_0001.JPG")
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() {
@@ -68,6 +331,25 @@ final class TravelAlbumModelsTests: XCTestCase {
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
func testBuildTodayAlbumNameUsesExistingSameDayCount() {
let date = Calendar(identifier: .gregorian).date(from: DateComponents(year: 2026, month: 7, day: 7))!
+196 -3
View File
@@ -80,6 +80,7 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
api.infoResponse = TravelAlbum(id: 2, name: "详情")
api.materialListResponses = [
TravelAlbumListResponse(total: 2, list: []),
TravelAlbumListResponse(total: 1, list: []),
TravelAlbumListResponse(total: 2, list: [TravelAlbumMaterial(id: 1), TravelAlbumMaterial(id: 2)]),
]
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
@@ -88,7 +89,28 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.album?.name, "详情")
XCTAssertEqual(viewModel.allPhotoCount, 2)
XCTAssertEqual(viewModel.purchasedPhotoCount, 1)
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 {
@@ -106,6 +128,41 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
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() {
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
viewModel.toggleSelectionMode()
@@ -116,6 +173,64 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
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 {
let api = TravelAlbumMockAPI()
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
@@ -127,6 +242,26 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
XCTAssertEqual(api.deletedAlbumIds, [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 测试。
@@ -949,23 +1084,43 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
let isPurchased: Int?
}
struct MaterialInfoRequest: Equatable {
let userEquityTravelId: Int
let materialId: Int
}
var availableOrdersResponse: [TravelAlbumAvailableOrder] = []
var listResponse = TravelAlbumListResponse<TravelAlbum>()
var createResponse = TravelAlbumCreateResponse(id: 0)
var infoResponse = TravelAlbum()
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
var materialListFailingCallIndexes: Set<Int> = []
var materialInfoResponse = TravelAlbumMaterial()
var materialInfoError: Error?
var uploadMaterialResponse = TravelAlbumMaterial()
var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [])
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
var aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse()
var createError: Error?
var aiRetouchTemplatesError: Error?
var submitAIRetouchError: Error?
var submitAIRetouchDelayNanoseconds: UInt64 = 0
var submitAIReretouchError: Error?
var submitAIReretouchDelayNanoseconds: UInt64 = 0
var deleteMaterialError: Error?
var deleteMaterialDelayNanoseconds: UInt64 = 0
private(set) var availableOrdersCallCount = 0
private(set) var createRequests: [TravelAlbumCreateRequest] = []
private(set) var materialRequests: [MaterialRequest] = []
private(set) var materialInfoRequests: [MaterialInfoRequest] = []
private(set) var uploadMaterialRequests: [TravelAlbumUploadMaterialRequest] = []
private(set) var materialClientPhotoIdsCallCount = 0
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] = []
func availableOrders() async throws -> [TravelAlbumAvailableOrder] {
availableOrdersCallCount += 1
@@ -993,6 +1148,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
orderBy: Int,
isPurchased: Int?
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial> {
let callIndex = materialRequests.count
materialRequests.append(
MaterialRequest(
userEquityTravelId: userEquityTravelId,
@@ -1002,10 +1158,21 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
isPurchased: isPurchased
)
)
if materialListFailingCallIndexes.contains(callIndex) {
throw APIError.serverCode(500, "素材数量加载失败")
}
if materialListResponses.isEmpty { return TravelAlbumListResponse() }
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 {
uploadMaterialRequests.append(request)
return uploadMaterialResponse
@@ -1020,11 +1187,37 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
deletedAlbumIds.append(id)
}
func deleteMaterial(id: Int) async throws {
deletedMaterialIds.append(id)
func batchDeleteMaterials(ids: [Int]) async throws {
deletedMaterialIDBatches.append(ids)
if deleteMaterialDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: deleteMaterialDelayNanoseconds)
}
if let deleteMaterialError { throw deleteMaterialError }
}
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse {
mpCodeResponse
}
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse {
aiRetouchTemplateScenicIds.append(scenicId)
if let aiRetouchTemplatesError { throw aiRetouchTemplatesError }
return aiRetouchTemplatesResponse
}
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws {
aiRetouchRequests.append(request)
if submitAIRetouchDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: submitAIRetouchDelayNanoseconds)
}
if let submitAIRetouchError { throw submitAIRetouchError }
}
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws {
aiReretouchRequests.append(request)
if submitAIReretouchDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: submitAIReretouchDelayNanoseconds)
}
if let submitAIReretouchError { throw submitAIReretouchError }
}
}