diff --git a/docs/assets/travel-album-ai/01-photo-preview-flow.svg b/docs/assets/travel-album-ai/01-photo-preview-flow.svg
new file mode 100644
index 0000000..c35e548
--- /dev/null
+++ b/docs/assets/travel-album-ai/01-photo-preview-flow.svg
@@ -0,0 +1,47 @@
+
diff --git a/docs/assets/travel-album-ai/02-multi-select-flow.svg b/docs/assets/travel-album-ai/02-multi-select-flow.svg
new file mode 100644
index 0000000..05f233b
--- /dev/null
+++ b/docs/assets/travel-album-ai/02-multi-select-flow.svg
@@ -0,0 +1,44 @@
+
diff --git a/docs/assets/travel-album-ai/02-upload-transfer-mode-flow.svg b/docs/assets/travel-album-ai/02-upload-transfer-mode-flow.svg
new file mode 100644
index 0000000..1fe1ff1
--- /dev/null
+++ b/docs/assets/travel-album-ai/02-upload-transfer-mode-flow.svg
@@ -0,0 +1,31 @@
+
diff --git a/docs/assets/travel-album-ai/03-ai-edit-sheet-flow.svg b/docs/assets/travel-album-ai/03-ai-edit-sheet-flow.svg
new file mode 100644
index 0000000..17850b7
--- /dev/null
+++ b/docs/assets/travel-album-ai/03-ai-edit-sheet-flow.svg
@@ -0,0 +1,56 @@
+
diff --git a/docs/assets/travel-album-ai/03-wired-transfer-preview-flow.svg b/docs/assets/travel-album-ai/03-wired-transfer-preview-flow.svg
new file mode 100644
index 0000000..2141443
--- /dev/null
+++ b/docs/assets/travel-album-ai/03-wired-transfer-preview-flow.svg
@@ -0,0 +1,31 @@
+
diff --git a/docs/assets/travel-album-ai/04-ai-task-state-flow.svg b/docs/assets/travel-album-ai/04-ai-task-state-flow.svg
new file mode 100644
index 0000000..98a0d3a
--- /dev/null
+++ b/docs/assets/travel-album-ai/04-ai-task-state-flow.svg
@@ -0,0 +1,49 @@
+
diff --git a/docs/assets/travel-album-ai/05-cover-template-flow.svg b/docs/assets/travel-album-ai/05-cover-template-flow.svg
new file mode 100644
index 0000000..65d7fec
--- /dev/null
+++ b/docs/assets/travel-album-ai/05-cover-template-flow.svg
@@ -0,0 +1,52 @@
+
diff --git a/docs/assets/travel-album-ai/06-thumbnail-state-flow.svg b/docs/assets/travel-album-ai/06-thumbnail-state-flow.svg
new file mode 100644
index 0000000..338370c
--- /dev/null
+++ b/docs/assets/travel-album-ai/06-thumbnail-state-flow.svg
@@ -0,0 +1,51 @@
+
diff --git a/docs/相册管理与AI修图功能说明.md b/docs/相册管理与AI修图功能说明.md
new file mode 100644
index 0000000..cd53e20
--- /dev/null
+++ b/docs/相册管理与AI修图功能说明.md
@@ -0,0 +1,261 @@
+# 相册管理与 AI 修图功能说明
+
+> 文档日期:2026-07-29
+> 适用范围:新增相册 → 相册任务 → 相册管理 → 照片上传与传输 → 照片预览与 AI 修图
+> 数据说明:相册和照片列表、有线相机连接及照片传输继续使用项目已有真实数据能力;传输模式选择保存在本地。原图精修结果、氛围感结果、封面模板、剩余 9 张额度及状态角标目前为本地可演示数据,尚未接入服务端持久化,App 进程重启后会清空。
+
+## 1. 照片预览、左右滑动与底部操作
+
+### 功能说明
+
+用户在“相册管理”照片网格中点击任意缩略图,进入单张照片预览页。页面只保留一个返回按钮,导航区域展示当前照片文件名和文件大小;照片区域支持左右滑动查看同一相册内的上一张或下一张照片。
+
+预览页统一使用黑色沉浸式自定义头部,不再混用系统导航栏:左侧为圆形返回按钮,中间展示文件名和文件大小,右侧展示“当前张数/总张数”。切换照片时三处信息同步更新。照片预览与原图精修、氛围感、封面模板预览都直接使用同一个全屏模态容器,不进入项目导航栈,因此不会叠加系统白色导航栏或出现两个返回按钮;关闭后回到进入前的相册管理页或修图弹窗。
+
+底部固定展示以下三个操作,不展示打印按钮:
+
+| 操作 | 点击结果 | 操作期间或异常处理 |
+| --- | --- | --- |
+| AI修图 | 打开当前照片的 AI 修图弹窗 | 当前照片处于“修图中”时按钮置灰,避免重复提交 |
+| 删除 | 二次确认后删除当前照片 | 删除成功后自动定位到相邻照片;删除最后一张后返回相册管理 |
+| 刷新 | 重新加载当前照片及最新修图结果 | 刷新失败时不清空当前已显示内容,可再次点击 |
+
+左右滑动时同步刷新文件名、文件大小、原图/修图后状态和底部按钮状态。第一张继续向右滑、最后一张继续向左滑时保持当前照片,不循环跳转。
+
+页面底部图标使用项目内 `TravelAlbumPreviewIcons` SVG 资源,确保 AI 修图、删除、刷新三个入口视觉一致。
+
+### 状态流转图
+
+
+
+### 本功能验收
+
+- [ ] 从任意缩略图进入时只显示一个返回入口。
+- [ ] 文件名和文件大小与当前照片一致。
+- [ ] 导航头部保持黑色沉浸样式,右侧张数随照片切换同步变化。
+- [ ] 左右滑动后照片和文件信息同步变化。
+- [ ] 首尾照片不会越界或循环。
+- [ ] 底部仅显示 AI修图、删除、刷新,不显示打印。
+- [ ] 删除当前照片后能正确定位或退出预览。
+
+## 2. 上传照片与传输模式选择
+
+### 功能说明
+
+相册管理处于普通状态时,用户点击底部“上传照片”,先从底部弹出“选择传输模式”,展示“边拍边传”和“拍后传输”两个选项。点击任一选项后弹窗关闭,并进入当前相册的有线传输页面;点击“取消”或向下关闭弹窗时停留在相册管理,不改变现有照片和选择状态。
+
+| 传输模式 | 弹窗说明 | 下一页初始状态 | 后续行为 |
+| --- | --- | --- | --- |
+| 边拍边传 | 相机拍摄后,照片自动传输并上传到当前相册 | 传输模式显示“边拍边传” | 新传入 App 的照片自动上传 |
+| 拍后传输 | 拍摄完成后,再选择相机照片批量传输 | 传输模式显示“拍后传输” | 新照片先保留,用户后续选择并上传 |
+
+本次弹窗选择优先于本机上一次保存的传输模式,并同步成为新的最近使用模式,确保下一页展示和实际传输行为一致。相册 ID、相册名称、联系电话及照片数据继续沿用相册管理页面的真实数据,不创建新的相册或假照片。
+
+进入有线传输页面后,非批量选择状态下点击任意照片缩略图,预览页会使用当前 Tab 筛选后的照片顺序,并从被点击照片开始展示。用户可左右滑动查看上一张或下一张,顶部页码随照片同步变化;没有可用预览地址的照片不进入预览序列,也不占用页码。第一张继续向右滑、最后一张继续向左滑时均停留在边界,不循环跳转。处于批量上传选择状态时,点击照片仍执行选择或取消选择,不打开预览。
+
+### 状态流转图
+
+
+
+
+
+### 本功能验收
+
+- [ ] 点击“上传照片”后先显示底部传输模式弹窗,不直接进入下一页。
+- [ ] 弹窗同时显示“边拍边传”和“拍后传输”及对应说明。
+- [ ] 选择“边拍边传”后,下一页模式显示为“边拍边传”。
+- [ ] 选择“拍后传输”后,下一页模式显示为“拍后传输”。
+- [ ] 本次选择不会被上一次保存的模式覆盖。
+- [ ] 取消或关闭弹窗后仍停留在相册管理。
+- [ ] 非批量选择状态点击缩略图后,从被点击照片进入全屏预览。
+- [ ] 预览可左右滑动查看当前 Tab 中的上一张和下一张,页码同步变化且首尾不循环。
+- [ ] 批量选择状态点击照片只改变选择状态,不打开预览。
+
+## 3. 相册管理多选与底部操作栏切换
+
+### 功能说明
+
+相册管理页普通状态下,底部显示“上传照片”。用户点击右上角选择按钮后进入多选状态,每张缩略图右上角显示选择圆圈,底部“上传照片”隐藏,替换为“AI修图”和“删除”。
+
+| 多选状态 | 页面表现 | 可用操作 | 下一状态 |
+| --- | --- | --- | --- |
+| 未进入多选 | 不显示选择圆圈,底部显示上传照片 | 点击照片进入预览;点击右上角选择 | 进入多选 |
+| 已进入但未选照片 | 显示未选圆圈,AI修图和删除置灰 | 选择照片;再次点击右上角选择退出 | 已选择或退出 |
+| 已选择照片 | 选中照片显示勾选,AI修图和删除可用 | 发起批量修图或批量删除 | 弹窗、处理中或删除结果 |
+| 选中项含修图中的照片 | 保留当前选择,AI修图置灰 | 等待任务完成;删除仍按现有删除规则处理 | 任务结束后恢复 |
+| 退出多选 | 清空本次选择,恢复上传照片 | 上传或进入照片预览 | 普通状态 |
+
+批量删除必须二次确认;用户取消时不删除任何照片并保留当前选择。批量 AI 修图复用与单张预览相同的预设、任务状态和结果覆盖逻辑。
+
+### 状态流转图
+
+
+
+### 本功能验收
+
+- [ ] 进入多选后上传按钮消失,AI修图和删除出现。
+- [ ] 未选择照片时两个批量按钮不可点击。
+- [ ] 选择至少一张后两个批量按钮恢复可用。
+- [ ] 包含修图中照片时不能再次发起 AI 修图。
+- [ ] 退出多选后选择状态清空并恢复上传按钮。
+
+## 4. AI 修图弹窗、原图精修、氛围感与按张收费模式
+
+### 功能说明
+
+用户从单张预览或相册多选操作栏点击“AI修图”后,以同一个底部弹窗展示 AI 修图配置。标题与弹窗顶部保留足够间距,标题下方展示“已选择 N 张照片”。弹窗内容可纵向滚动,取消和确定按钮固定在底部,照片较多并出现封面模块时也不会遮挡选择项。
+
+“原图精修”是基础修图模块,使用横向滚动单选列表,每个预设同时展示名称和对应效果图:
+
+| 预设 | 当前效果用途 |
+| --- | --- |
+| 还原为原图 | 清除该照片已有修图结果和封面类型 |
+| 写真-简约肖像 | 低饱和、提亮的人像效果 |
+| 写真-清冷古风 | 清冷复古效果 |
+| 旅拍-锦绣 | 提升饱和度和对比度 |
+| 旅拍-远山 | 低饱和、柔和远景效果 |
+| 旅拍-薄雾 | 提亮并降低对比度 |
+| 油画-夏日 | 偏暖夏日效果 |
+| 油画-浓郁 | 高饱和、高对比效果 |
+
+原图精修默认选中“还原为原图”,用户点击其他预设后仅保留一个选中项。没有可用原图时,效果图位置显示稳定占位图,不影响弹窗选择。每张样式效果图右下角都有“预览”按钮,点击后全屏查看当前照片应用该样式的效果,不改变当前选中项。
+
+“氛围感修图”位于原图精修下方,并明确标记为“选填”。该模块与原图精修保持一致,使用横向滚动效果图单选,当前本地演示包含日落暖阳、复古胶片、清透蓝调、森系薄雾、浓郁故事五种样式。默认不选中;点击一种样式后仅保留该项,再次点击当前样式即可取消。每个样式同样提供“预览”按钮。选中后不是把氛围效果叠加并合并到精修图中,而是为每张照片额外生成一张可独立查看的“氛围感”结果。
+
+原图精修、氛围感、封面模板和“查看高清图”统一进入同一个沉浸式大图预览页。大图页不再套用系统导航栏,只显示一个返回按钮、当前样式名称和“双指缩放查看细节”提示;关闭后回到原弹窗或照片页,并保留关闭前的选择状态。
+
+弹窗内的 Tips 会随当前组合实时变化:
+
+| 当前选择 | 确认后的结果 |
+| --- | --- |
+| 仅原图精修 | 每张照片生成 1 个“精修后”结果 |
+| 原图精修 + 氛围感修图 | 每张照片生成“精修后 + 氛围感”2 个独立结果 |
+| 有封面、未选氛围感 | 每张照片生成精修结果,第一张选中照片额外生成封面 |
+| 有封面、已选氛围感 | 每张照片生成精修和氛围感 2 个结果,第一张选中照片再额外生成封面 |
+
+“修图模式”区域只读展示“AI精修 · 按张收费”和“剩余 9 张”,不再显示勾选圆圈,也不能点击选择。当前剩余数量为本地演示数据,不会真实扣减额度;未来接入服务端时由接口返回剩余额度,并以任务创建成功或服务端明确返回的扣减结果为准。
+
+点击“确定”提交当前原图精修、氛围感和封面组合并关闭弹窗;点击“取消”或关闭弹窗时不创建任务、不改变旧修图结果,也不改变额度。
+
+### 状态流转图
+
+
+
+### 本功能验收
+
+- [ ] 标题与弹窗顶部间距自然,不贴近顶部。
+- [ ] 已选照片数量准确。
+- [ ] 原图精修预设同时展示文字和效果图,并可横向滚动、保持单选。
+- [ ] 原图精修、氛围感和封面模板效果图均可通过小按钮全屏预览,预览不改变选择。
+- [ ] 所有大图预览使用同一黑色头部、单一返回入口,并支持双指缩放。
+- [ ] 氛围感修图横向展示多个效果图,默认不选中、保持单选,可再次点击当前样式取消。
+- [ ] 同时选择原图精修和氛围感时,每张照片生成两个独立结果。
+- [ ] Tips 能根据氛围感和封面选择动态说明实际输出结果。
+- [ ] 修图模式只展示按张收费与剩余张数,不提供选择操作。
+- [ ] 取消弹窗不会创建任务或覆盖旧结果。
+
+## 5. AI 修图任务状态、失败重试与结果覆盖
+
+### 功能说明
+
+用户确认修图后,所选照片立即进入“修图中”。任务未结束前,相关 AI 修图入口置灰;即使用户离开照片预览页,本地演示任务仍会继续完成,并通过共享结果状态刷新相册缩略图。
+
+| 任务状态 | 进入条件 | 页面表现 | 可用操作 | 后续状态 |
+| --- | --- | --- | --- | --- |
+| 可发起 | 未修图、已有成功结果或上次任务已结束 | AI修图按钮可用 | 选择预设并确认 | 修图中 |
+| 修图中 | 已提交预设,等待结果 | 按钮置灰;缩略图显示“修图中” | 查看其他照片、离开页面 | 成功或失败 |
+| 成功 | 获取原图并完成本地效果处理 | 分别保存精修、氛围感和封面结果;默认展示“精修后”;按钮恢复 | 在已有结果间切换、再次修图、还原原图 | 新任务或已上传 |
+| 失败 | 原图无法获取或处理失败 | 缩略图显示“修图失败”;按钮恢复 | 再次修图 | 修图中 |
+
+成功后,照片预览页至少出现“原图 / 精修后”切换;本次选择了氛围感时增加“氛围感”,存在封面结果时增加“封面”。各结果互相独立,不把两个效果合成同一张图;有修图结果时还可以点击“查看高清图”。再次修图时,旧结果在新任务处理期间继续保留,新任务成功后整组替换为本次最新组合:本次未选择氛围感时会清除旧氛围感结果;新任务失败时保留旧成功图片,但状态优先显示“修图失败”,提醒用户本次任务未成功。
+
+选择“还原为原图”会同时清除修图结果、失败状态和封面类型,缩略图恢复为“已上传”。删除照片时同步清理该照片的本地修图记录,避免已删除照片的结果残留。
+
+### 状态流转图
+
+
+
+### 本功能验收
+
+- [ ] 修图中不能重复点击 AI修图。
+- [ ] 离开预览页后任务仍能结束并刷新缩略图。
+- [ ] 成功后可切换查看原图、精修后以及本次实际生成的氛围感或封面。
+- [ ] 同选原图精修和氛围感时两个结果均可独立查看。
+- [ ] 再次修图成功后以本次结果组合覆盖旧结果。
+- [ ] 失败后按钮恢复并允许重试。
+- [ ] 还原原图后所有本地修图类型和结果被清除。
+
+## 6. 选中 4 张及以上时的封面模板选择
+
+### 功能说明
+
+从相册管理多选照片并点击“AI修图”时,系统根据选中照片数量决定是否展示“封面风格模板”:
+
+| 选中数量 | 弹窗表现 |
+| --- | --- |
+| 1~3 张 | 展示“原图精修 + 氛围感修图(选填)”两个模块,不展示封面模板 |
+| ≥ 4 张 | 展示“原图精修 + 氛围感修图(选填)+ 封面模板”三个模块,并提高弹窗高度 |
+
+封面模板为横向滚动单选,默认选中“旅行纪念册”。每个模板效果图右下角提供“预览”按钮,点击后全屏查看完整排版,不改变当前模板选择。当前提供:
+
+- 旅行纪念册
+- 简约留白
+- 景区故事
+- 电影胶片
+
+每个模板展示名称和以第一张选中照片生成的排版效果图。用户确认后,所有选中照片先生成原图精修结果;选择了氛围感时,所有照片再各自生成一个氛围感结果;第一张选中照片另外生成独立封面并作为封面承载图。封面不会替代精修或氛围感结果,用户进入预览后可分别切换查看。
+
+当前所有封面模板 `quotaCost = 0`,弹窗显示“赠送 · 不占精修张数”,不会减少剩余修图额度。后续收费时保留同一选择流程,由服务端为模板返回大于 0 的 `quotaCost`,客户端在确认前显示实际消耗,并以服务端成功结果为准扣减;收费能力目前尚未实现。
+
+再次对不少于 4 张照片选择新封面模板并修图时,新封面效果覆盖旧封面效果。封面承载图处理失败时不标记为封面成功,缩略图显示“修图失败”并允许重试。选择“还原为原图”时清除封面效果和封面类型。
+
+### 状态流转图
+
+
+
+### 本功能验收
+
+- [ ] 选中 3 张时不显示封面模板。
+- [ ] 选中 4 张或更多时显示封面模板。
+- [ ] 有封面模块时,弹窗同时完整显示原图精修、氛围感和封面三个风格模块。
+- [ ] 封面模板可横向滚动且始终单选。
+- [ ] 每个封面模板均可独立预览,关闭预览后保留原选择。
+- [ ] 默认选中旅行纪念册。
+- [ ] 当前显示赠送并且不消耗精修额度。
+- [ ] 成功后第一张选中照片成为封面承载图。
+- [ ] 再次生成封面时以新结果覆盖旧结果。
+
+## 7. 相册缩略图结果类型与显示优先级
+
+### 功能说明
+
+相册管理缩略图左上角复用同一状态位,根据照片当前结果显示不同类型,让用户无需进入预览即可区分未修、处理中、已修、封面和失败照片。
+
+| 缩略图状态 | 角标文案与颜色 | 缩略图内容 | 进入条件 | 可用操作 |
+| --- | --- | --- | --- | --- |
+| 普通原图 | 绿色“已上传” | 服务端原图或缩略图 | 没有本地修图结果 | 预览、选择、AI修图、删除 |
+| 处理中 | 蓝色“修图中” | 有旧成功结果时继续显示旧结果,否则显示原图 | AI 任务未结束 | 预览、删除;不能再次修图 |
+| 普通修图结果 | 紫色“AI已修” | 最新精修图片 | 原图精修成功,可同时存在氛围感结果 | 预览原图/精修后/氛围感、再次修图、还原 |
+| 封面结果 | 橙色“AI封面” | 所选封面模板排版图 | 封面承载图生成成功 | 预览、再次修图、重新生成封面、还原 |
+| 修图失败 | 红色“修图失败” | 有旧成功结果时保留旧图,否则显示原图 | 最近一次任务失败 | 再次修图、删除 |
+
+状态显示优先级为:
+
+`修图中 > 修图失败 > AI封面 > AI已修 > 已上传`
+
+该优先级保证处理中或失败状态不会被历史成功结果遮盖。普通修图成功后显示“AI已修”;封面模板成功后,第一张选中照片显示封面排版图和“AI封面”,其他照片继续显示“AI已修”。再次修图成功后缩略图切换为最新结果;还原原图后回到“已上传”;删除照片后对应本地状态一起删除。
+
+当前状态和结果由内存数据源在相册管理与照片预览之间共享,尚不支持 App 重启后的恢复。服务端接入时至少需要返回素材 ID、任务状态、结果类型、结果图地址、原图精修预设 ID、是否生成氛围感、封面模板 ID、失败原因和额度消耗,客户端才能恢复完全一致的缩略图状态。
+
+### 状态流转图
+
+
+
+### 本功能验收
+
+- [ ] 未修图照片显示“已上传”。
+- [ ] 修图中照片显示“修图中”且不能重复提交。
+- [ ] 普通成功结果显示“AI已修”和最新效果图。
+- [ ] 封面成功结果显示“AI封面”和封面排版图。
+- [ ] 最近一次任务失败时优先显示“修图失败”。
+- [ ] 还原、覆盖和删除后缩略图状态同步更新。
diff --git a/suixinkan/Assets.xcassets/TravelAlbumPreviewIcons/Contents.json b/suixinkan/Assets.xcassets/TravelAlbumPreviewIcons/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/suixinkan/Assets.xcassets/TravelAlbumPreviewIcons/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/suixinkan/Assets.xcassets/TravelAlbumPreviewIcons/travel_album_preview_ai_edit.imageset/Contents.json b/suixinkan/Assets.xcassets/TravelAlbumPreviewIcons/travel_album_preview_ai_edit.imageset/Contents.json
new file mode 100644
index 0000000..fd1c244
--- /dev/null
+++ b/suixinkan/Assets.xcassets/TravelAlbumPreviewIcons/travel_album_preview_ai_edit.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "travel_album_preview_ai_edit.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/suixinkan/Assets.xcassets/TravelAlbumPreviewIcons/travel_album_preview_ai_edit.imageset/travel_album_preview_ai_edit.svg b/suixinkan/Assets.xcassets/TravelAlbumPreviewIcons/travel_album_preview_ai_edit.imageset/travel_album_preview_ai_edit.svg
new file mode 100644
index 0000000..fa7a7ae
--- /dev/null
+++ b/suixinkan/Assets.xcassets/TravelAlbumPreviewIcons/travel_album_preview_ai_edit.imageset/travel_album_preview_ai_edit.svg
@@ -0,0 +1,3 @@
+
diff --git a/suixinkan/Assets.xcassets/TravelAlbumPreviewIcons/travel_album_preview_delete.imageset/Contents.json b/suixinkan/Assets.xcassets/TravelAlbumPreviewIcons/travel_album_preview_delete.imageset/Contents.json
new file mode 100644
index 0000000..520954d
--- /dev/null
+++ b/suixinkan/Assets.xcassets/TravelAlbumPreviewIcons/travel_album_preview_delete.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "travel_album_preview_delete.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/suixinkan/Assets.xcassets/TravelAlbumPreviewIcons/travel_album_preview_delete.imageset/travel_album_preview_delete.svg b/suixinkan/Assets.xcassets/TravelAlbumPreviewIcons/travel_album_preview_delete.imageset/travel_album_preview_delete.svg
new file mode 100644
index 0000000..7332ee7
--- /dev/null
+++ b/suixinkan/Assets.xcassets/TravelAlbumPreviewIcons/travel_album_preview_delete.imageset/travel_album_preview_delete.svg
@@ -0,0 +1,3 @@
+
diff --git a/suixinkan/Assets.xcassets/TravelAlbumPreviewIcons/travel_album_preview_refresh.imageset/Contents.json b/suixinkan/Assets.xcassets/TravelAlbumPreviewIcons/travel_album_preview_refresh.imageset/Contents.json
new file mode 100644
index 0000000..8f4e7f1
--- /dev/null
+++ b/suixinkan/Assets.xcassets/TravelAlbumPreviewIcons/travel_album_preview_refresh.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "travel_album_preview_refresh.svg",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/suixinkan/Assets.xcassets/TravelAlbumPreviewIcons/travel_album_preview_refresh.imageset/travel_album_preview_refresh.svg b/suixinkan/Assets.xcassets/TravelAlbumPreviewIcons/travel_album_preview_refresh.imageset/travel_album_preview_refresh.svg
new file mode 100644
index 0000000..f828547
--- /dev/null
+++ b/suixinkan/Assets.xcassets/TravelAlbumPreviewIcons/travel_album_preview_refresh.imageset/travel_album_preview_refresh.svg
@@ -0,0 +1,3 @@
+
diff --git a/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/Contents.json b/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_alpine_lake.imageset/Contents.json b/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_alpine_lake.imageset/Contents.json
new file mode 100644
index 0000000..f68c0e9
--- /dev/null
+++ b/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_alpine_lake.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "purchased_alpine_lake.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_alpine_lake.imageset/purchased_alpine_lake.png b/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_alpine_lake.imageset/purchased_alpine_lake.png
new file mode 100644
index 0000000..739f5d4
Binary files /dev/null and b/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_alpine_lake.imageset/purchased_alpine_lake.png differ
diff --git a/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_forest_waterfall.imageset/Contents.json b/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_forest_waterfall.imageset/Contents.json
new file mode 100644
index 0000000..db99fd5
--- /dev/null
+++ b/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_forest_waterfall.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "purchased_forest_waterfall.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_forest_waterfall.imageset/purchased_forest_waterfall.png b/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_forest_waterfall.imageset/purchased_forest_waterfall.png
new file mode 100644
index 0000000..ac5e1e4
Binary files /dev/null and b/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_forest_waterfall.imageset/purchased_forest_waterfall.png differ
diff --git a/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_grassland_path.imageset/Contents.json b/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_grassland_path.imageset/Contents.json
new file mode 100644
index 0000000..40489b1
--- /dev/null
+++ b/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_grassland_path.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "purchased_grassland_path.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_grassland_path.imageset/purchased_grassland_path.png b/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_grassland_path.imageset/purchased_grassland_path.png
new file mode 100644
index 0000000..2a17b1f
Binary files /dev/null and b/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_grassland_path.imageset/purchased_grassland_path.png differ
diff --git a/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_lakeside_flowers.imageset/Contents.json b/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_lakeside_flowers.imageset/Contents.json
new file mode 100644
index 0000000..92e09e1
--- /dev/null
+++ b/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_lakeside_flowers.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "filename" : "purchased_lakeside_flowers.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_lakeside_flowers.imageset/purchased_lakeside_flowers.png b/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_lakeside_flowers.imageset/purchased_lakeside_flowers.png
new file mode 100644
index 0000000..8c355a8
Binary files /dev/null and b/suixinkan/Assets.xcassets/TravelAlbumPurchasedDemo/purchased_lakeside_flowers.imageset/purchased_lakeside_flowers.png differ
diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift
index a045383..0a97f7d 100644
--- a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift
+++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift
@@ -116,6 +116,8 @@ struct TravelAlbumAvailableOrder: Decodable, Sendable, Equatable, Hashable {
/// 旅拍相册素材实体,对齐 Android `TravelAlbumMaterialEntity`。
struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiable {
+ private static let bundledImageURLPrefix = "asset://"
+
let id: Int
let userEquityTravelId: Int
let status: Int
@@ -175,6 +177,18 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
self.createdAt = createdAt
self.updatedAt = updatedAt
}
+
+ /// 将工程内图片资源转换为素材可识别的本地地址,仅供无接口数据的演示素材使用。
+ static func bundledImageURL(assetName: String) -> String {
+ bundledImageURLPrefix + assetName
+ }
+
+ /// 返回本地演示素材对应的图片资源名;真实网络素材返回 `nil`。
+ var bundledImageAssetName: String? {
+ guard fileUrl.hasPrefix(Self.bundledImageURLPrefix) else { return nil }
+ let assetName = String(fileUrl.dropFirst(Self.bundledImageURLPrefix.count))
+ return assetName.isEmpty ? nil : assetName
+ }
}
/// 旅拍相册创建请求体,对齐 Android `TravelAlbumCreateRequest`。
diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumPhotoPreviewModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumPhotoPreviewModels.swift
new file mode 100644
index 0000000..41f8bfc
--- /dev/null
+++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumPhotoPreviewModels.swift
@@ -0,0 +1,316 @@
+//
+// TravelAlbumPhotoPreviewModels.swift
+// suixinkan
+//
+
+import Foundation
+
+/// 单张照片的 AI 修图状态,控制处理中禁用、结果对比以及再次修图覆盖。
+struct TravelAlbumAIEditResultState: Equatable, Sendable {
+ /// 相册缩略图需要展示的 AI 修图状态。
+ enum ThumbnailStatus: Equatable, Sendable {
+ case none
+ case processing
+ case edited
+ case cover
+ case failed
+ }
+
+ /// 当前预览展示的版本。
+ enum DisplayMode: Equatable, Sendable {
+ case original
+ case edited
+ case atmosphere
+ case cover
+ }
+
+ private(set) var isProcessing = false
+ private(set) var didFailLastAttempt = false
+ private(set) var appliedPresetID: String?
+ private(set) var appliedAtmosphereOptionID: String?
+ private(set) var appliedCoverTemplateID: String?
+ private(set) var displayMode: DisplayMode = .original
+
+ /// 已经存在可查看的修图结果。
+ var hasEditedResult: Bool {
+ appliedPresetID != nil
+ }
+
+ /// 处理期间禁止再次发起 AI 修图。
+ var canStartEditing: Bool {
+ !isProcessing
+ }
+
+ /// 当前结果是否为封面模板生成的封面图。
+ var isCover: Bool {
+ appliedCoverTemplateID != nil
+ }
+
+ /// 当前是否已经生成可独立查看的氛围感修图结果。
+ var hasAtmosphereResult: Bool {
+ appliedAtmosphereOptionID != nil
+ }
+
+ /// 根据修图任务状态生成缩略图角标,处理中和失败优先于历史成功结果。
+ var thumbnailStatus: ThumbnailStatus {
+ if isProcessing {
+ return .processing
+ }
+ if didFailLastAttempt {
+ return .failed
+ }
+ if isCover {
+ return .cover
+ }
+ return hasEditedResult ? .edited : .none
+ }
+
+ /// 开始修图;已有结果保留到新结果完成,避免处理中画面回退。
+ mutating func startProcessing() {
+ guard !isProcessing else { return }
+ isProcessing = true
+ didFailLastAttempt = false
+ }
+
+ /// 完成修图并覆盖旧预设结果,默认展示新的修图后版本。
+ mutating func complete(
+ presetID: String,
+ atmosphereOptionID: String? = nil,
+ coverTemplateID: String? = nil
+ ) {
+ isProcessing = false
+ didFailLastAttempt = false
+ appliedPresetID = presetID
+ appliedAtmosphereOptionID = atmosphereOptionID
+ appliedCoverTemplateID = coverTemplateID
+ displayMode = coverTemplateID == nil ? .edited : .cover
+ }
+
+ /// 处理失败时恢复入口;已有成功结果继续保留。
+ mutating func failProcessing() {
+ isProcessing = false
+ didFailLastAttempt = true
+ }
+
+ /// 选择还原原图后清除历史修图结果和失败状态。
+ mutating func restoreOriginal() {
+ isProcessing = false
+ didFailLastAttempt = false
+ appliedPresetID = nil
+ appliedAtmosphereOptionID = nil
+ appliedCoverTemplateID = nil
+ displayMode = .original
+ }
+
+ /// 在已有结果时切换原图或修图后预览。
+ mutating func selectDisplayMode(_ mode: DisplayMode) {
+ guard hasEditedResult else {
+ displayMode = .original
+ return
+ }
+ if mode == .atmosphere, !hasAtmosphereResult {
+ displayMode = .edited
+ return
+ }
+ if mode == .cover, !isCover {
+ displayMode = .edited
+ return
+ }
+ displayMode = mode
+ }
+}
+
+/// 相册管理底部操作栏状态,用于在上传与多选操作之间切换。
+struct TravelAlbumSelectionToolbarState: Equatable, Sendable {
+ let isSelectionMode: Bool
+ let selectedCount: Int
+
+ /// 普通状态显示上传照片。
+ var showsUpload: Bool {
+ !isSelectionMode
+ }
+
+ /// 多选状态显示 AI 修图和删除。
+ var showsSelectionActions: Bool {
+ isSelectionMode
+ }
+
+ /// 至少选中一张照片后才允许执行批量操作。
+ var selectionActionsEnabled: Bool {
+ isSelectionMode && selectedCount > 0
+ }
+}
+
+/// 相册照片预览状态,维护当前照片位置并处理左右切换。
+struct TravelAlbumPhotoPreviewState: Equatable, Sendable {
+ private(set) var materials: [TravelAlbumMaterial]
+ private(set) var currentIndex: Int
+
+ init(materials: [TravelAlbumMaterial], initialIndex: Int) {
+ self.materials = materials
+ currentIndex = min(max(initialIndex, 0), max(materials.count - 1, 0))
+ }
+
+ /// 当前正在预览的照片。
+ var currentMaterial: TravelAlbumMaterial? {
+ materials.indices.contains(currentIndex) ? materials[currentIndex] : nil
+ }
+
+ /// 切换至下一张;已经是最后一张时保持不变。
+ @discardableResult
+ mutating func moveNext() -> Bool {
+ guard currentIndex + 1 < materials.count else { return false }
+ currentIndex += 1
+ return true
+ }
+
+ /// 切换至上一张;已经是第一张时保持不变。
+ @discardableResult
+ mutating func movePrevious() -> Bool {
+ guard currentIndex > 0 else { return false }
+ currentIndex -= 1
+ return true
+ }
+
+ /// 删除当前照片,并自动定位到相邻照片。
+ @discardableResult
+ mutating func removeCurrent() -> Bool {
+ guard materials.indices.contains(currentIndex) else { return false }
+ materials.remove(at: currentIndex)
+ if currentIndex >= materials.count {
+ currentIndex = max(materials.count - 1, 0)
+ }
+ return !materials.isEmpty
+ }
+}
+
+/// 相册照片 AI 修图预设,当前为本地演示数据,后续可替换为服务端下发的预设列表。
+struct TravelAlbumEditPreset: Hashable, Sendable, Identifiable {
+ /// 图像效果类型,用于在预览页生成对应效果图。
+ enum Effect: String, Hashable, Sendable {
+ case original
+ case portrait
+ case vintage
+ case brocade
+ case distantMountain
+ case mist
+ case summer
+ case rich
+ }
+
+ let id: String
+ let title: String
+ let effect: Effect
+
+ /// AI 修图底部弹窗展示的默认预设,暂无接口时用于完整演示选择流程。
+ static let defaultOptions: [TravelAlbumEditPreset] = [
+ TravelAlbumEditPreset(id: "original", title: "还原为原图", effect: .original),
+ TravelAlbumEditPreset(id: "portrait", title: "写真-简约肖像", effect: .portrait),
+ TravelAlbumEditPreset(id: "vintage", title: "写真-清冷古风", effect: .vintage),
+ TravelAlbumEditPreset(id: "brocade", title: "旅拍-锦绣", effect: .brocade),
+ TravelAlbumEditPreset(id: "distant_mountain", title: "旅拍-远山", effect: .distantMountain),
+ TravelAlbumEditPreset(id: "mist", title: "旅拍-薄雾", effect: .mist),
+ TravelAlbumEditPreset(id: "summer", title: "油画-夏日", effect: .summer),
+ TravelAlbumEditPreset(id: "rich", title: "油画-浓郁", effect: .rich),
+ ]
+}
+
+/// 可选的氛围感修图样式;选中后在原图精修之外额外生成一张独立结果。
+struct TravelAlbumAtmosphereEditOption: Hashable, Sendable, Identifiable {
+ let id: String
+ let title: String
+ let subtitle: String
+ let effect: TravelAlbumEditPreset.Effect
+
+ /// 暂无接口时用于演示横向滚动单选的氛围感样式。
+ static let defaultOptions: [TravelAlbumAtmosphereEditOption] = [
+ TravelAlbumAtmosphereEditOption(
+ id: "warm_sun",
+ title: "日落暖阳",
+ subtitle: "温暖柔和",
+ effect: .summer
+ ),
+ TravelAlbumAtmosphereEditOption(
+ id: "retro_film",
+ title: "复古胶片",
+ subtitle: "怀旧颗粒感",
+ effect: .vintage
+ ),
+ TravelAlbumAtmosphereEditOption(
+ id: "clear_blue",
+ title: "清透蓝调",
+ subtitle: "通透轻盈",
+ effect: .mist
+ ),
+ TravelAlbumAtmosphereEditOption(
+ id: "forest_mist",
+ title: "森系薄雾",
+ subtitle: "低饱和自然感",
+ effect: .distantMountain
+ ),
+ TravelAlbumAtmosphereEditOption(
+ id: "vivid_story",
+ title: "浓郁故事",
+ subtitle: "高饱和电影感",
+ effect: .rich
+ ),
+ ]
+
+ /// 兼容现有结果测试与默认演示数据的首个氛围感样式。
+ static let defaultOption = defaultOptions[0]
+}
+
+/// 多图 AI 修图使用的封面风格模板,当前为本地演示数据,后续可替换为服务端配置。
+struct TravelAlbumCoverTemplate: Hashable, Sendable, Identifiable {
+ /// 封面预览的排版样式,用于生成可辨识的本地效果图。
+ enum PreviewStyle: String, Hashable, Sendable {
+ case travelMemoir
+ case minimal
+ case scenicStory
+ case film
+ }
+
+ /// 触发展示封面模板选择的最少照片数量。
+ static let minimumPhotoCount = 4
+
+ let id: String
+ let title: String
+ let previewStyle: PreviewStyle
+ /// 当前消耗的精修额度;免费阶段为 0,后续收费可由服务端改为正数。
+ let quotaCost: Int
+
+ /// 当前模板是否为赠送且不占用精修张数。
+ var isFreeGift: Bool {
+ quotaCost == 0
+ }
+
+ /// 判断本次多选是否需要展示封面模板模块。
+ static func shouldShow(for selectedPhotoCount: Int) -> Bool {
+ selectedPhotoCount >= minimumPhotoCount
+ }
+
+ /// 暂无接口时用于完整演示单选流程的封面模板。
+ static let defaultOptions: [TravelAlbumCoverTemplate] = [
+ TravelAlbumCoverTemplate(id: "travel_memoir", title: "旅行纪念册", previewStyle: .travelMemoir, quotaCost: 0),
+ TravelAlbumCoverTemplate(id: "minimal", title: "简约留白", previewStyle: .minimal, quotaCost: 0),
+ TravelAlbumCoverTemplate(id: "scenic_story", title: "景区故事", previewStyle: .scenicStory, quotaCost: 0),
+ TravelAlbumCoverTemplate(id: "film", title: "电影胶片", previewStyle: .film, quotaCost: 0),
+ ]
+}
+
+/// AI 修图弹窗最终提交的样式组合,统一承载原图精修、氛围感和封面模板。
+struct TravelAlbumAIEditSelection: Equatable, Sendable {
+ let refinedPreset: TravelAlbumEditPreset
+ let atmosphereOption: TravelAlbumAtmosphereEditOption?
+ let coverTemplate: TravelAlbumCoverTemplate?
+
+ /// 每张普通照片需要生成的修图结果数量,不包含原图。
+ var resultCountPerPhoto: Int {
+ atmosphereOption == nil ? 1 : 2
+ }
+
+ /// 弹窗当前展示并提交的样式类别数量。
+ var selectedStyleCount: Int {
+ 1 + (atmosphereOption == nil ? 0 : 1) + (coverTemplate == nil ? 0 : 1)
+ }
+}
diff --git a/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift b/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift
index de1aa1d..7d8f56c 100644
--- a/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift
+++ b/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift
@@ -52,6 +52,12 @@ struct TravelAlbumOTGPhotoSection: Hashable, Sendable {
let photos: [TravelAlbumOTGPhotoItem]
}
+/// 有线传输照片预览上下文,保存可预览照片顺序和点击照片位置。
+struct TravelAlbumOTGPhotoPreviewContext: Equatable, Sendable {
+ let photos: [TravelAlbumOTGPhotoItem]
+ let initialIndex: Int
+}
+
/// OTG 传输页 Tab。
enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable {
case all
@@ -80,6 +86,16 @@ enum TravelAlbumOTGTransferMode: String, CaseIterable, Sendable {
}
}
+ /// 模式选择弹窗中的业务说明。
+ var detailText: String {
+ switch self {
+ case .liveUpload:
+ return "相机拍摄后,照片自动传输并上传到当前相册"
+ case .postTransfer:
+ return "拍摄完成后,再选择照片批量传输"
+ }
+ }
+
/// 是否在新照片导入 App 后自动上传。
var shouldAutoUploadNewImports: Bool {
self == .liveUpload
@@ -113,6 +129,18 @@ extension TravelAlbumOTGPhotoItem {
}
extension Array where Element == TravelAlbumOTGPhotoItem {
+ /// 按当前列表顺序生成预览上下文,不可预览照片不会占用左右滑动页码。
+ func previewContext(startingWith photoID: String) -> TravelAlbumOTGPhotoPreviewContext? {
+ let previewablePhotos = filter { $0.thumbnailURL != nil }
+ guard let initialIndex = previewablePhotos.firstIndex(where: { $0.id == photoID }) else {
+ return nil
+ }
+ return TravelAlbumOTGPhotoPreviewContext(
+ photos: previewablePhotos,
+ initialIndex: initialIndex
+ )
+ }
+
/// 按 Android OTG 页规则构造半小时照片分段。
func buildPhotoSections() -> [TravelAlbumOTGPhotoSection] {
let pairs = compactMap { photo -> (slotStart: Date, photo: TravelAlbumOTGPhotoItem)? in
diff --git a/suixinkan/Features/TravelAlbum/Services/TravelAlbumAIEditResultStore.swift b/suixinkan/Features/TravelAlbum/Services/TravelAlbumAIEditResultStore.swift
new file mode 100644
index 0000000..668d9f2
--- /dev/null
+++ b/suixinkan/Features/TravelAlbum/Services/TravelAlbumAIEditResultStore.swift
@@ -0,0 +1,308 @@
+//
+// TravelAlbumAIEditResultStore.swift
+// suixinkan
+//
+
+import CoreImage
+import UIKit
+
+/// AI 修图结果的内存数据源,在相册管理与照片预览之间共享本地演示结果。
+@MainActor
+final class TravelAlbumAIEditResultStore {
+ /// 单张照片的修图记录。
+ struct Record {
+ var state: TravelAlbumAIEditResultState
+ var editedImage: UIImage?
+ var atmosphereImage: UIImage?
+ var coverImage: UIImage?
+ }
+
+ static let shared = TravelAlbumAIEditResultStore()
+ static let didChangeNotification = Notification.Name("TravelAlbumAIEditResultStore.didChange")
+ static let materialIDUserInfoKey = "materialID"
+
+ private var recordsByMaterialID: [Int: Record] = [:]
+
+ private init() {}
+
+ /// 获取指定照片的修图状态。
+ func state(for materialID: Int) -> TravelAlbumAIEditResultState {
+ recordsByMaterialID[materialID]?.state ?? TravelAlbumAIEditResultState()
+ }
+
+ /// 获取指定照片最新的修图结果。
+ func editedImage(for materialID: Int) -> UIImage? {
+ recordsByMaterialID[materialID]?.editedImage
+ }
+
+ /// 获取指定照片最新的氛围感修图结果。
+ func atmosphereImage(for materialID: Int) -> UIImage? {
+ recordsByMaterialID[materialID]?.atmosphereImage
+ }
+
+ /// 获取指定照片最新的封面模板结果。
+ func coverImage(for materialID: Int) -> UIImage? {
+ recordsByMaterialID[materialID]?.coverImage
+ }
+
+ /// 将照片标记为修图中,已有结果继续保留到新结果完成。
+ func startProcessing(materialIDs: [Int]) {
+ materialIDs.forEach { materialID in
+ var record = recordsByMaterialID[materialID]
+ ?? Record(
+ state: TravelAlbumAIEditResultState(),
+ editedImage: nil,
+ atmosphereImage: nil,
+ coverImage: nil
+ )
+ record.state.startProcessing()
+ recordsByMaterialID[materialID] = record
+ notifyChange(materialID: materialID)
+ }
+ }
+
+ /// 保存最新结果并覆盖同一照片之前的修图结果。
+ func complete(
+ materialID: Int,
+ presetID: String,
+ atmosphereOptionID: String? = nil,
+ coverTemplateID: String? = nil,
+ editedImage: UIImage,
+ atmosphereImage: UIImage? = nil,
+ coverImage: UIImage? = nil
+ ) {
+ var record = recordsByMaterialID[materialID]
+ ?? Record(
+ state: TravelAlbumAIEditResultState(),
+ editedImage: nil,
+ atmosphereImage: nil,
+ coverImage: nil
+ )
+ record.state.complete(
+ presetID: presetID,
+ atmosphereOptionID: atmosphereOptionID,
+ coverTemplateID: coverTemplateID
+ )
+ record.editedImage = editedImage
+ record.atmosphereImage = atmosphereImage
+ record.coverImage = coverImage
+ recordsByMaterialID[materialID] = record
+ notifyChange(materialID: materialID)
+ }
+
+ /// 修图失败时恢复按钮可用状态,并保留之前成功的结果。
+ func failProcessing(materialID: Int) {
+ guard var record = recordsByMaterialID[materialID] else { return }
+ record.state.failProcessing()
+ recordsByMaterialID[materialID] = record
+ notifyChange(materialID: materialID)
+ }
+
+ /// 还原为原图时清除该照片的本地修图结果。
+ func restoreOriginal(materialID: Int) {
+ guard var record = recordsByMaterialID[materialID] else { return }
+ record.state.restoreOriginal()
+ record.editedImage = nil
+ record.atmosphereImage = nil
+ record.coverImage = nil
+ recordsByMaterialID[materialID] = record
+ notifyChange(materialID: materialID)
+ }
+
+ /// 切换指定照片当前展示的原图或修图后版本。
+ func selectDisplayMode(_ mode: TravelAlbumAIEditResultState.DisplayMode, materialID: Int) {
+ guard var record = recordsByMaterialID[materialID] else { return }
+ record.state.selectDisplayMode(mode)
+ recordsByMaterialID[materialID] = record
+ notifyChange(materialID: materialID)
+ }
+
+ /// 删除照片时同步清理其本地修图结果。
+ func remove(materialID: Int) {
+ recordsByMaterialID.removeValue(forKey: materialID)
+ notifyChange(materialID: materialID)
+ }
+
+ private func notifyChange(materialID: Int) {
+ NotificationCenter.default.post(
+ name: Self.didChangeNotification,
+ object: self,
+ userInfo: [Self.materialIDUserInfoKey: materialID]
+ )
+ }
+}
+
+/// 本地 AI 修图效果生成器;后续接入服务端时可替换为结果图片下载。
+@MainActor
+enum TravelAlbumAIEditImageProcessor {
+ private static let context = CIContext()
+
+ /// 根据预设生成演示用修图图片。
+ static func render(
+ effect: TravelAlbumEditPreset.Effect,
+ source: UIImage
+ ) -> UIImage {
+ guard effect != .original, let ciImage = CIImage(image: source) else { return source }
+ let output: CIImage
+ switch effect {
+ case .original:
+ output = ciImage
+ case .portrait:
+ output = ciImage.applyingFilter("CIColorControls", parameters: [
+ kCIInputSaturationKey: 0.86,
+ kCIInputBrightnessKey: 0.07,
+ kCIInputContrastKey: 1.08,
+ ])
+ case .vintage:
+ output = ciImage.applyingFilter("CISepiaTone", parameters: [kCIInputIntensityKey: 0.45])
+ case .brocade:
+ output = ciImage.applyingFilter("CIColorControls", parameters: [
+ kCIInputSaturationKey: 1.22,
+ kCIInputBrightnessKey: 0.04,
+ kCIInputContrastKey: 1.1,
+ ])
+ case .distantMountain:
+ output = ciImage.applyingFilter("CIColorControls", parameters: [
+ kCIInputSaturationKey: 0.72,
+ kCIInputBrightnessKey: 0.1,
+ kCIInputContrastKey: 0.9,
+ ])
+ case .mist:
+ output = ciImage.applyingFilter("CIColorControls", parameters: [
+ kCIInputSaturationKey: 0.7,
+ kCIInputBrightnessKey: 0.14,
+ kCIInputContrastKey: 0.82,
+ ])
+ case .summer:
+ output = ciImage.applyingFilter("CISepiaTone", parameters: [kCIInputIntensityKey: 0.24])
+ case .rich:
+ output = ciImage.applyingFilter("CIColorControls", parameters: [
+ kCIInputSaturationKey: 1.38,
+ kCIInputBrightnessKey: -0.02,
+ kCIInputContrastKey: 1.2,
+ ])
+ }
+ guard let cgImage = context.createCGImage(output, from: output.extent) else { return source }
+ return UIImage(cgImage: cgImage, scale: source.scale, orientation: source.imageOrientation)
+ }
+}
+
+/// 本地封面模板效果生成器,让封面结果在缩略图和预览页中与普通照片明显区分。
+@MainActor
+enum TravelAlbumCoverTemplateImageProcessor {
+ /// 将照片渲染为指定封面排版;无原图时使用稳定的演示背景。
+ static func render(
+ template: TravelAlbumCoverTemplate,
+ source: UIImage?,
+ targetSize: CGSize = CGSize(width: 1200, height: 900)
+ ) -> UIImage {
+ UIGraphicsImageRenderer(size: targetSize).image { context in
+ let bounds = CGRect(origin: .zero, size: targetSize)
+ UIColor(hex: 0xCAD8E8).setFill()
+ context.cgContext.fill(bounds)
+ if let source {
+ source.draw(in: aspectFillRect(imageSize: source.size, bounds: bounds))
+ }
+
+ let unit = min(targetSize.width / 232, targetSize.height / 208)
+ switch template.previewStyle {
+ case .travelMemoir:
+ UIColor.black.withAlphaComponent(0.24).setFill()
+ context.cgContext.fill(bounds)
+ UIColor.white.setStroke()
+ let oval = UIBezierPath(ovalIn: CGRect(
+ x: bounds.midX - 50 * unit,
+ y: 28 * unit,
+ width: 100 * unit,
+ height: 100 * unit
+ ))
+ oval.lineWidth = 9 * unit
+ oval.stroke()
+ drawCaption(
+ "TRAVEL\nMEMOIR",
+ in: CGRect(x: 30 * unit, y: bounds.height - 62 * unit, width: bounds.width - 60 * unit, height: 46 * unit),
+ alignment: .center,
+ fontSize: 17 * unit
+ )
+ case .minimal:
+ UIColor.white.withAlphaComponent(0.9).setFill()
+ context.cgContext.fill(CGRect(x: bounds.width * 0.55, y: 0, width: bounds.width * 0.45, height: bounds.height))
+ drawCaption(
+ "LESS\nIS MORE",
+ in: CGRect(x: bounds.width * 0.62, y: bounds.midY - 27 * unit, width: bounds.width * 0.3, height: 54 * unit),
+ alignment: .left,
+ color: .black,
+ fontSize: 17 * unit
+ )
+ case .scenicStory:
+ UIColor.black.withAlphaComponent(0.2).setFill()
+ context.cgContext.fill(bounds)
+ UIColor.white.setStroke()
+ let inset = 20 * unit
+ let frame = UIBezierPath(rect: bounds.insetBy(dx: inset, dy: inset))
+ frame.lineWidth = 4 * unit
+ frame.stroke()
+ drawCaption(
+ "SCENIC STORY",
+ in: CGRect(x: 34 * unit, y: bounds.height - 58 * unit, width: bounds.width - 68 * unit, height: 24 * unit),
+ alignment: .center,
+ fontSize: 17 * unit
+ )
+ case .film:
+ let stripHeight = 24 * unit
+ UIColor.black.withAlphaComponent(0.72).setFill()
+ context.cgContext.fill(CGRect(x: 0, y: 0, width: bounds.width, height: stripHeight))
+ context.cgContext.fill(CGRect(x: 0, y: bounds.height - stripHeight, width: bounds.width, height: stripHeight))
+ UIColor.white.withAlphaComponent(0.9).setFill()
+ var x = 10 * unit
+ while x <= bounds.width - 16 * unit {
+ context.cgContext.fill(CGRect(x: x, y: 6 * unit, width: 14 * unit, height: 10 * unit))
+ context.cgContext.fill(CGRect(
+ x: x,
+ y: bounds.height - 16 * unit,
+ width: 14 * unit,
+ height: 10 * unit
+ ))
+ x += 30 * unit
+ }
+ drawCaption(
+ "THE JOURNEY",
+ in: CGRect(x: 28 * unit, y: bounds.height - 60 * unit, width: bounds.width - 56 * unit, height: 24 * unit),
+ alignment: .center,
+ fontSize: 17 * unit
+ )
+ }
+ }
+ }
+
+ private static func aspectFillRect(imageSize: CGSize, bounds: CGRect) -> CGRect {
+ guard imageSize.width > 0, imageSize.height > 0 else { return bounds }
+ let scale = max(bounds.width / imageSize.width, bounds.height / imageSize.height)
+ let drawSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
+ return CGRect(
+ x: bounds.midX - drawSize.width / 2,
+ y: bounds.midY - drawSize.height / 2,
+ width: drawSize.width,
+ height: drawSize.height
+ )
+ }
+
+ private static func drawCaption(
+ _ text: String,
+ in rect: CGRect,
+ alignment: NSTextAlignment,
+ color: UIColor = .white,
+ fontSize: CGFloat
+ ) {
+ let paragraph = NSMutableParagraphStyle()
+ paragraph.alignment = alignment
+ text.draw(
+ in: rect,
+ withAttributes: [
+ .font: UIFont.systemFont(ofSize: fontSize, weight: .bold),
+ .foregroundColor: color,
+ .paragraphStyle: paragraph,
+ ]
+ )
+ }
+}
diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift
index a4e5297..e10bece 100644
--- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift
+++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumDetailViewModel.swift
@@ -36,6 +36,7 @@ final class TravelAlbumDetailViewModel {
private(set) var sortOption: SortOption = .createdDesc
private(set) var isLoading = true
private(set) var isRefreshing = false
+ private(set) var isUsingPurchasedDemoData = false
private(set) var isSelectionMode = false
private(set) var selectedMaterialIds: Set = []
@@ -126,6 +127,7 @@ final class TravelAlbumDetailViewModel {
if reset {
currentPage = 1
canLoadMore = false
+ isUsingPurchasedDemoData = false
} else {
guard canLoadMore, !isLoadingMore else { return }
isLoadingMore = true
@@ -140,8 +142,15 @@ final class TravelAlbumDetailViewModel {
orderBy: sortOption.rawValue,
isPurchased: selectedTab == .purchased ? 1 : nil
)
- materials = reset ? response.list : materials + response.list
- canLoadMore = materials.count < response.total
+ if reset,
+ selectedTab == .purchased,
+ response.total == 0,
+ response.list.isEmpty {
+ applyPurchasedDemoMaterials()
+ } else {
+ materials = reset ? response.list : materials + response.list
+ canLoadMore = materials.count < response.total
+ }
if selectedTab == .all {
allPhotoCount = response.total
}
@@ -154,6 +163,12 @@ final class TravelAlbumDetailViewModel {
currentPage -= 1
}
isLoadingMore = false
+ if reset, selectedTab == .purchased {
+ // 已购接口暂不可用时继续提供稳定的本地演示数据,保证产品流程可演示。
+ applyPurchasedDemoMaterials()
+ notifyStateChange()
+ return
+ }
onShowMessage?(error.localizedDescription)
}
}
@@ -225,6 +240,44 @@ final class TravelAlbumDetailViewModel {
}
}
+ /// 已购照片接口无数据时生成稳定的本地素材;真实接口一旦返回数据即不再使用。
+ private func applyPurchasedDemoMaterials() {
+ let demoItems: [(fileName: String, fileSize: Int, assetName: String, createdAt: String)] = [
+ ("IMG_0724_0001.jpeg", 2_684_928, "purchased_alpine_lake", "2026-07-24 11:25:18"),
+ ("IMG_0724_0002.jpeg", 3_215_360, "purchased_forest_waterfall", "2026-07-24 11:27:46"),
+ ("IMG_0724_0003.jpeg", 2_936_832, "purchased_grassland_path", "2026-07-24 11:31:09"),
+ ("IMG_0724_0004.jpeg", 3_481_600, "purchased_lakeside_flowers", "2026-07-24 11:34:22"),
+ ]
+ var materials = demoItems.enumerated().map { index, item in
+ TravelAlbumMaterial(
+ id: -((albumId * 10) + index + 1),
+ userEquityTravelId: albumId,
+ status: 2,
+ fileName: item.fileName,
+ fileType: 1,
+ fileUrl: TravelAlbumMaterial.bundledImageURL(assetName: item.assetName),
+ fileSize: item.fileSize,
+ coverUrl: TravelAlbumMaterial.bundledImageURL(assetName: item.assetName),
+ isPurchased: true,
+ createdAt: item.createdAt,
+ updatedAt: item.createdAt
+ )
+ }
+ switch sortOption {
+ case .createdAsc:
+ materials.sort { $0.createdAt < $1.createdAt }
+ case .createdDesc:
+ materials.sort { $0.createdAt > $1.createdAt }
+ case .fileNameAsc:
+ materials.sort { $0.fileName.localizedStandardCompare($1.fileName) == .orderedAscending }
+ case .fileNameDesc:
+ materials.sort { $0.fileName.localizedStandardCompare($1.fileName) == .orderedDescending }
+ }
+ self.materials = materials
+ canLoadMore = false
+ isUsingPurchasedDemoData = true
+ }
+
private func notifyStateChange() {
onStateChange?()
}
diff --git a/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift
index 79f1154..46e0740 100644
--- a/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift
+++ b/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift
@@ -94,6 +94,7 @@ final class WiredCameraTransferViewModel {
albumTitle: String,
headerPhone: String,
scenicSpotLabel: String? = nil,
+ initialTransferMode: TravelAlbumOTGTransferMode? = nil,
connectionManager: (any WiredCameraConnectionManaging)? = nil,
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
uploader: (any TravelAlbumOTGUploading)? = nil,
@@ -112,7 +113,11 @@ final class WiredCameraTransferViewModel {
self.api = api ?? NetworkServices.shared.travelAlbumAPI
self.appStore = appStore
self.userDefaults = userDefaults
- self.transferMode = Self.persistedTransferMode(in: userDefaults)
+ // 从相册管理选择模式进入时,以本次选择为准;其他旧入口继续沿用上次记录。
+ self.transferMode = initialTransferMode ?? Self.persistedTransferMode(in: userDefaults)
+ if let initialTransferMode {
+ userDefaults.set(initialTransferMode.rawValue, forKey: Self.transferModeDefaultsKey)
+ }
}
/// 相机状态文案。
diff --git a/suixinkan/UI/Live/LiveAlbumViewControllers.swift b/suixinkan/UI/CloudDrive/Live/LiveAlbumViewControllers.swift
similarity index 100%
rename from suixinkan/UI/Live/LiveAlbumViewControllers.swift
rename to suixinkan/UI/CloudDrive/Live/LiveAlbumViewControllers.swift
diff --git a/suixinkan/UI/Live/LiveDetailViewController.swift b/suixinkan/UI/CloudDrive/Live/LiveDetailViewController.swift
similarity index 100%
rename from suixinkan/UI/Live/LiveDetailViewController.swift
rename to suixinkan/UI/CloudDrive/Live/LiveDetailViewController.swift
diff --git a/suixinkan/UI/Live/LiveManageViewController.swift b/suixinkan/UI/CloudDrive/Live/LiveManageViewController.swift
similarity index 100%
rename from suixinkan/UI/Live/LiveManageViewController.swift
rename to suixinkan/UI/CloudDrive/Live/LiveManageViewController.swift
diff --git a/suixinkan/UI/Live/LiveUIComponents.swift b/suixinkan/UI/CloudDrive/Live/LiveUIComponents.swift
similarity index 100%
rename from suixinkan/UI/Live/LiveUIComponents.swift
rename to suixinkan/UI/CloudDrive/Live/LiveUIComponents.swift
diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift
index 5632320..fd18dba 100644
--- a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift
+++ b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift
@@ -11,6 +11,9 @@ import UIKit
final class TravelAlbumDetailViewController: BaseViewController {
private let viewModel: TravelAlbumDetailViewModel
private let api: any TravelAlbumServing
+ private let aiEditResultStore = TravelAlbumAIEditResultStore.shared
+ private var pendingAIEditMaterialIDs: Set = []
+ private var failedAIEditCount = 0
private let scrollContainer = UIView()
private let infoCard = TravelAlbumInfoCard()
@@ -24,9 +27,9 @@ final class TravelAlbumDetailViewController: BaseViewController {
private var dataSource: UICollectionViewDiffableDataSource!
private let bottomBar = UIView()
private let bottomActionStack = UIStackView()
+ private let aiEditSelectedButton = 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) {
viewModel = TravelAlbumDetailViewModel(albumId: albumId)
@@ -41,6 +44,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
override func setupNavigationBar() {
title = "相册管理"
+ navigationItem.backButtonDisplayMode = .minimal
navigationItem.rightBarButtonItem = UIBarButtonItem(
image: UIImage(systemName: "ellipsis"),
menu: UIMenu(children: [
@@ -78,21 +82,39 @@ final class TravelAlbumDetailViewController: BaseViewController {
withReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumMaterialCell
+ let aiEditState = self?.aiEditResultStore.state(for: material.id)
+ ?? TravelAlbumAIEditResultState()
+ let resultImage = aiEditState.isCover
+ ? (self?.aiEditResultStore.coverImage(for: material.id)
+ ?? self?.aiEditResultStore.editedImage(for: material.id))
+ : self?.aiEditResultStore.editedImage(for: material.id)
cell.apply(
material: material,
selectionMode: self?.viewModel.isSelectionMode == true,
- selected: self?.viewModel.selectedMaterialIds.contains(material.id) == true
+ selected: self?.viewModel.selectedMaterialIds.contains(material.id) == true,
+ aiEditState: aiEditState,
+ editedImage: resultImage
)
return cell
}
bottomBar.backgroundColor = AppColor.pageBackground
- bottomActionStack.axis = .vertical
- bottomActionStack.spacing = 8
- configureBottomButton(deleteSelectedButton, title: "删除选中(0)", color: UIColor(hex: 0xE53935))
+ bottomActionStack.axis = .horizontal
+ bottomActionStack.spacing = 12
+ bottomActionStack.distribution = .fillEqually
+ configureSelectionActionButton(
+ aiEditSelectedButton,
+ title: "AI修图",
+ image: UIImage(named: "travel_album_preview_ai_edit")?.withRenderingMode(.alwaysTemplate)
+ )
+ configureSelectionActionButton(
+ deleteSelectedButton,
+ title: "删除",
+ image: UIImage(named: "travel_album_preview_delete")?.withRenderingMode(.alwaysTemplate)
+ )
configureBottomButton(uploadButton, title: "上传照片", color: AppColor.primary)
+ aiEditSelectedButton.isHidden = true
deleteSelectedButton.isHidden = true
- deleteSelectedButton.alpha = 0
view.addSubview(scrollContainer)
scrollContainer.addSubview(infoCard)
@@ -105,6 +127,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
manageCard.addSubview(collectionView)
view.addSubview(bottomBar)
bottomBar.addSubview(bottomActionStack)
+ bottomActionStack.addArrangedSubview(aiEditSelectedButton)
bottomActionStack.addArrangedSubview(deleteSelectedButton)
bottomActionStack.addArrangedSubview(uploadButton)
}
@@ -118,11 +141,10 @@ final class TravelAlbumDetailViewController: BaseViewController {
make.leading.trailing.equalToSuperview().inset(20)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
- deleteSelectedButton.snp.makeConstraints { make in
- make.height.equalTo(48).priority(.high)
- }
- uploadButton.snp.makeConstraints { make in
- make.height.equalTo(48).priority(.high)
+ [aiEditSelectedButton, deleteSelectedButton, uploadButton].forEach { button in
+ button.snp.makeConstraints { make in
+ make.height.equalTo(58).priority(.high)
+ }
}
scrollContainer.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
@@ -163,8 +185,15 @@ final class TravelAlbumDetailViewController: BaseViewController {
purchasedTabButton.addTarget(self, action: #selector(purchasedTabTapped), for: .touchUpInside)
sortButton.addTarget(self, action: #selector(sortTapped), for: .touchUpInside)
selectButton.addTarget(self, action: #selector(selectTapped), for: .touchUpInside)
+ aiEditSelectedButton.addTarget(self, action: #selector(aiEditSelectedTapped), for: .touchUpInside)
deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside)
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(aiEditResultDidChange(_:)),
+ name: TravelAlbumAIEditResultStore.didChangeNotification,
+ object: aiEditResultStore
+ )
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
@@ -195,8 +224,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
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)
+ updateBottomToolbar()
var snapshot = NSDiffableDataSourceSnapshot()
snapshot.appendSections([0])
@@ -213,6 +241,28 @@ final class TravelAlbumDetailViewController: BaseViewController {
}
}
+ /// 根据选择数量和修图任务刷新底部操作栏。
+ private func updateBottomToolbar() {
+ let toolbarState = TravelAlbumSelectionToolbarState(
+ isSelectionMode: viewModel.isSelectionMode,
+ selectedCount: viewModel.selectedMaterialIds.count
+ )
+ uploadButton.isHidden = !toolbarState.showsUpload
+ aiEditSelectedButton.isHidden = !toolbarState.showsSelectionActions
+ deleteSelectedButton.isHidden = !toolbarState.showsSelectionActions
+ let selectedContainsProcessingPhoto = viewModel.selectedMaterialIds.contains { materialID in
+ aiEditResultStore.state(for: materialID).isProcessing
+ }
+ let canStartAIEditing = toolbarState.selectionActionsEnabled
+ && pendingAIEditMaterialIDs.isEmpty
+ && !selectedContainsProcessingPhoto
+ aiEditSelectedButton.isEnabled = canStartAIEditing
+ deleteSelectedButton.isEnabled = toolbarState.selectionActionsEnabled
+ aiEditSelectedButton.alpha = canStartAIEditing ? 1 : 0.4
+ deleteSelectedButton.alpha = toolbarState.selectionActionsEnabled ? 1 : 0.4
+ bottomBar.backgroundColor = toolbarState.showsSelectionActions ? .white : AppColor.pageBackground
+ }
+
private func configurePillButton(_ button: UIButton) {
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
button.layer.cornerRadius = 16
@@ -242,27 +292,20 @@ final class TravelAlbumDetailViewController: BaseViewController {
button.layer.cornerRadius = 10
}
- 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()
+ /// 配置多选模式底部的图标操作按钮,图标复用照片预览页的 Iconfont 资源。
+ private func configureSelectionActionButton(_ button: UIButton, title: String, image: UIImage?) {
+ var configuration = UIButton.Configuration.plain()
+ configuration.title = title
+ configuration.image = image
+ configuration.imagePlacement = .top
+ configuration.imagePadding = 5
+ configuration.baseForegroundColor = AppColor.textSecondary
+ configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
+ var attributes = attributes
+ attributes.font = .systemFont(ofSize: 13, weight: .medium)
+ return attributes
}
- guard view.window != nil else {
- changes()
- return
- }
-
- view.layoutIfNeeded()
- UIView.animate(
- withDuration: 0.25,
- delay: 0,
- options: [.curveEaseInOut, .beginFromCurrentState],
- animations: changes
- )
+ button.configuration = configuration
}
private func makeLayout() -> UICollectionViewCompositionalLayout {
@@ -319,6 +362,157 @@ final class TravelAlbumDetailViewController: BaseViewController {
viewModel.toggleSelectionMode()
}
+ @objc private func aiEditSelectedTapped() {
+ let selectedMaterials = viewModel.materials.filter { viewModel.selectedMaterialIds.contains($0.id) }
+ guard let firstMaterial = selectedMaterials.first else {
+ showToast("请选择要修图的照片")
+ return
+ }
+ let imageURLString = firstMaterial.coverUrl.isEmpty ? firstMaterial.fileUrl : firstMaterial.coverUrl
+ guard let imageURL = URL(string: imageURLString), !imageURLString.isEmpty else {
+ presentAIEditSheet(sourceImage: nil, selectedMaterials: selectedMaterials)
+ return
+ }
+ KingfisherManager.shared.retrieveImage(with: imageURL) { [weak self] result in
+ guard let self else { return }
+ Task { @MainActor in
+ let sourceImage: UIImage?
+ if case let .success(value) = result {
+ sourceImage = value.image
+ } else {
+ sourceImage = nil
+ }
+ self.presentAIEditSheet(sourceImage: sourceImage, selectedMaterials: selectedMaterials)
+ }
+ }
+ }
+
+ /// 复用单张照片预览页的 AI 修图预设弹窗完成批量选择演示。
+ private func presentAIEditSheet(
+ sourceImage: UIImage?,
+ selectedMaterials: [TravelAlbumMaterial]
+ ) {
+ let controller = TravelAlbumAIEditSheetViewController(
+ sourceImage: sourceImage,
+ selectedPhotoCount: selectedMaterials.count
+ )
+ controller.onConfirm = { [weak self] selection in
+ self?.startBatchAIEditing(
+ materials: selectedMaterials,
+ selection: selection
+ )
+ }
+ present(controller, animated: true)
+ }
+
+ /// 启动批量本地演示修图,并在全部照片完成前禁用 AI 修图入口。
+ private func startBatchAIEditing(
+ materials: [TravelAlbumMaterial],
+ selection: TravelAlbumAIEditSelection
+ ) {
+ guard pendingAIEditMaterialIDs.isEmpty, !materials.isEmpty else { return }
+ let materialIDs = materials.map(\.id)
+ let coverMaterialID = selection.coverTemplate == nil ? nil : materials.first?.id
+ pendingAIEditMaterialIDs = Set(materialIDs)
+ failedAIEditCount = 0
+ aiEditResultStore.startProcessing(materialIDs: materialIDs)
+ applyViewModel()
+
+ materials.forEach { material in
+ let imageURLString = material.fileUrl.isEmpty ? material.coverUrl : material.fileUrl
+ guard let imageURL = URL(string: imageURLString), !imageURLString.isEmpty else {
+ finishAIEditing(
+ materialID: material.id,
+ selection: selection,
+ appliedCoverTemplate: material.id == coverMaterialID ? selection.coverTemplate : nil,
+ sourceImage: nil
+ )
+ return
+ }
+ KingfisherManager.shared.retrieveImage(with: imageURL) { [weak self] result in
+ guard let self else { return }
+ Task { @MainActor in
+ let sourceImage: UIImage?
+ if case let .success(value) = result {
+ sourceImage = value.image
+ } else {
+ sourceImage = nil
+ }
+ self.finishAIEditing(
+ materialID: material.id,
+ selection: selection,
+ appliedCoverTemplate: material.id == coverMaterialID ? selection.coverTemplate : nil,
+ sourceImage: sourceImage
+ )
+ }
+ }
+ }
+ }
+
+ /// 完成单张批量任务;最后一张结束后恢复入口并反馈整体结果。
+ private func finishAIEditing(
+ materialID: Int,
+ selection: TravelAlbumAIEditSelection,
+ appliedCoverTemplate: TravelAlbumCoverTemplate?,
+ sourceImage: UIImage?
+ ) {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
+ guard let self else { return }
+ if let sourceImage {
+ let refinedImage = TravelAlbumAIEditImageProcessor.render(
+ effect: selection.refinedPreset.effect,
+ source: sourceImage
+ )
+ let atmosphereImage = selection.atmosphereOption.map { option in
+ TravelAlbumAIEditImageProcessor.render(effect: option.effect, source: sourceImage)
+ }
+ let coverImage = appliedCoverTemplate.map { template in
+ TravelAlbumCoverTemplateImageProcessor.render(
+ template: template,
+ source: refinedImage
+ )
+ }
+ if selection.refinedPreset.effect == .original,
+ selection.atmosphereOption == nil,
+ appliedCoverTemplate == nil {
+ self.aiEditResultStore.restoreOriginal(materialID: materialID)
+ } else {
+ self.aiEditResultStore.complete(
+ materialID: materialID,
+ presetID: selection.refinedPreset.id,
+ atmosphereOptionID: selection.atmosphereOption?.id,
+ coverTemplateID: appliedCoverTemplate?.id,
+ editedImage: refinedImage,
+ atmosphereImage: atmosphereImage,
+ coverImage: coverImage
+ )
+ }
+ } else {
+ self.aiEditResultStore.failProcessing(materialID: materialID)
+ self.failedAIEditCount += 1
+ }
+ self.pendingAIEditMaterialIDs.remove(materialID)
+ self.applyViewModel()
+ if self.pendingAIEditMaterialIDs.isEmpty {
+ let successMessage: String
+ if let coverTemplate = selection.coverTemplate,
+ selection.atmosphereOption != nil {
+ successMessage = "AI修图完成,每张生成 2 个结果,并生成「\(coverTemplate.title)」封面"
+ } else if let coverTemplate = selection.coverTemplate {
+ successMessage = "AI修图完成,已生成「\(coverTemplate.title)」封面"
+ } else if selection.atmosphereOption != nil {
+ successMessage = "AI修图完成,每张已生成 2 个结果"
+ } else {
+ successMessage = "AI修图完成"
+ }
+ let message = self.failedAIEditCount == 0
+ ? successMessage
+ : "有 \(self.failedAIEditCount) 张照片修图失败,请重试"
+ self.showToast(message)
+ }
+ }
+ }
+
@objc private func deleteSelectedTapped() {
let count = viewModel.selectedMaterialIds.count
let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert)
@@ -331,22 +525,60 @@ final class TravelAlbumDetailViewController: BaseViewController {
}
@objc private func uploadTapped() {
+ let controller = TravelAlbumTransferModeSheetViewController()
+ controller.onModeSelected = { [weak self] mode in
+ self?.openWiredTransfer(mode: mode)
+ }
+ present(controller, animated: true)
+ }
+
+ /// 根据本次选择进入对应传输模式,避免被历史缓存模式覆盖。
+ private func openWiredTransfer(mode: TravelAlbumOTGTransferMode) {
let album = viewModel.album
let controller = WiredCameraTransferViewController(
viewModel: WiredCameraTransferViewModel(
albumId: album?.id ?? viewModel.albumId,
albumTitle: album?.name ?? "",
- headerPhone: album?.displayPhone ?? ""
+ headerPhone: album?.displayPhone ?? "",
+ initialTransferMode: mode
)
)
navigationController?.pushViewController(controller, animated: true)
}
+
+ /// 仅刷新状态发生变化的缩略图,及时呈现修图结果和任务角标。
+ @objc private func aiEditResultDidChange(_ notification: Notification) {
+ guard let materialID = notification.userInfo?[TravelAlbumAIEditResultStore.materialIDUserInfoKey] as? Int,
+ let material = dataSource.snapshot().itemIdentifiers.first(where: { $0.id == materialID }) else {
+ return
+ }
+ var snapshot = dataSource.snapshot()
+ snapshot.reconfigureItems([material])
+ dataSource.apply(snapshot, animatingDifferences: true)
+ updateBottomToolbar()
+ }
}
extension TravelAlbumDetailViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let material = dataSource.itemIdentifier(for: indexPath) else { return }
- viewModel.toggleMaterialSelection(material)
+ if viewModel.isSelectionMode {
+ viewModel.toggleMaterialSelection(material)
+ } else {
+ // 非多选状态进入预览,避免影响原有批量删除流程。
+ let initialIndex = viewModel.materials.firstIndex(where: { $0.id == material.id }) ?? 0
+ let controller = TravelAlbumPhotoPreviewViewController(
+ materials: viewModel.materials,
+ initialIndex: initialIndex,
+ api: api
+ )
+ controller.onDeleted = { [weak self] in
+ guard let self else { return }
+ Task { await self.viewModel.refreshAll(api: self.api) }
+ }
+ // 照片与修图样式、封面模板统一走全屏预览,避免系统导航栏重复出现。
+ present(controller, animated: true)
+ }
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
@@ -499,19 +731,70 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
fatalError("init(coder:) has not been implemented")
}
- func apply(material: TravelAlbumMaterial, selectionMode: Bool, selected: Bool) {
- let urlString = material.coverUrl.isEmpty ? material.fileUrl : material.coverUrl
- if let url = URL(string: urlString), !urlString.isEmpty {
- imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo"))
+ override func prepareForReuse() {
+ super.prepareForReuse()
+ imageView.kf.cancelDownloadTask()
+ imageView.image = nil
+ }
+
+ func apply(
+ material: TravelAlbumMaterial,
+ selectionMode: Bool,
+ selected: Bool,
+ aiEditState: TravelAlbumAIEditResultState,
+ editedImage: UIImage?
+ ) {
+ imageView.backgroundColor = .clear
+ imageView.tintColor = nil
+ if aiEditState.hasEditedResult, let editedImage {
+ imageView.kf.cancelDownloadTask()
+ imageView.image = editedImage
+ } else if let assetName = material.bundledImageAssetName,
+ let bundledImage = UIImage(named: assetName) {
+ imageView.kf.cancelDownloadTask()
+ imageView.image = bundledImage
} else {
- imageView.image = UIImage(systemName: "photo")
- imageView.tintColor = AppColor.primary
- imageView.backgroundColor = UIColor(hex: 0xEAF2FF)
+ let urlString = material.coverUrl.isEmpty ? material.fileUrl : material.coverUrl
+ if let url = URL(string: urlString), !urlString.isEmpty {
+ imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo"))
+ } else {
+ imageView.image = UIImage(systemName: "photo")
+ imageView.tintColor = AppColor.primary
+ imageView.backgroundColor = UIColor(hex: 0xEAF2FF)
+ }
}
+ applyAIEditStatus(aiEditState.thumbnailStatus, isPurchased: material.isPurchased)
checkImageView.isHidden = !selectionMode
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
checkImageView.tintColor = selected ? AppColor.primary : .white
nameLabel.text = material.fileName
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
}
+
+ /// 复用左上角状态位,让用户无需进入预览即可识别照片是否完成 AI 修图。
+ private func applyAIEditStatus(
+ _ status: TravelAlbumAIEditResultState.ThumbnailStatus,
+ isPurchased: Bool
+ ) {
+ let appearance: (title: String, color: UIColor, width: CGFloat)
+ switch status {
+ case .none:
+ appearance = isPurchased
+ ? ("已购", AppColor.primary, 48)
+ : ("已上传", UIColor(hex: 0x34C759), 48)
+ case .processing:
+ appearance = ("修图中", AppColor.primary, 48)
+ case .edited:
+ appearance = ("AI已修", UIColor(hex: 0x6750A4), 48)
+ case .cover:
+ appearance = ("AI封面", UIColor(hex: 0xF59E0B), 56)
+ case .failed:
+ appearance = ("修图失败", UIColor(hex: 0xE53935), 56)
+ }
+ statusLabel.text = appearance.title
+ statusLabel.backgroundColor = appearance.color
+ statusLabel.snp.updateConstraints { make in
+ make.width.equalTo(appearance.width)
+ }
+ }
}
diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift
new file mode 100644
index 0000000..8ab27da
--- /dev/null
+++ b/suixinkan/UI/TravelAlbum/TravelAlbumPhotoPreviewViewController.swift
@@ -0,0 +1,1351 @@
+//
+// TravelAlbumPhotoPreviewViewController.swift
+// suixinkan
+//
+
+import CoreImage
+import Kingfisher
+import SnapKit
+import UIKit
+
+/// 相册管理中的单张照片预览页,提供 AI 修图、删除和刷新操作。
+final class TravelAlbumPhotoPreviewViewController: UIViewController {
+ /// 删除成功后通知相册管理页刷新当前素材列表。
+ var onDeleted: (() -> Void)?
+
+ private var previewState: TravelAlbumPhotoPreviewState
+ private let api: any TravelAlbumServing
+ private let imageView = UIImageView()
+ private let bottomBar = UIView()
+ private let bottomContentStack = UIStackView()
+ private let comparisonBar = UIStackView()
+ private let versionControl = UISegmentedControl(items: ["原图", "精修后"])
+ private var visibleDisplayModes: [TravelAlbumAIEditResultState.DisplayMode] = [.original, .edited]
+ private let highResolutionButton = UIButton(type: .system)
+ private let actionStack = UIStackView()
+ private let aiEditButton = TravelAlbumPreviewActionButton(
+ title: "AI修图",
+ icon: UIImage(named: "travel_album_preview_ai_edit") ?? UIImage(systemName: "wand.and.stars")
+ )
+ private let aiEditResultStore = TravelAlbumAIEditResultStore.shared
+ private let previewHeaderView = TravelAlbumPreviewHeaderView()
+ private var originalImage: UIImage?
+ private var originalImagesByMaterialID: [Int: UIImage] = [:]
+
+ init(materials: [TravelAlbumMaterial], initialIndex: Int, api: any TravelAlbumServing) {
+ precondition(!materials.isEmpty, "照片预览至少需要一张素材")
+ previewState = TravelAlbumPhotoPreviewState(materials: materials, initialIndex: initialIndex)
+ self.api = api
+ super.init(nibName: nil, bundle: nil)
+ // 与修图样式、封面模板预览保持同一种全屏容器,避免系统导航栏叠加。
+ modalPresentationStyle = .fullScreen
+ modalTransitionStyle = .crossDissolve
+ }
+
+ /// 兼容单张照片入口,并统一复用连续预览实现。
+ convenience init(material: TravelAlbumMaterial, api: any TravelAlbumServing) {
+ self.init(materials: [material], initialIndex: 0, api: api)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ view.backgroundColor = .black
+ setupNavigationBar()
+ setupUI()
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(aiEditResultDidChange(_:)),
+ name: TravelAlbumAIEditResultStore.didChangeNotification,
+ object: aiEditResultStore
+ )
+ updateAIEditControls()
+ loadImage()
+ }
+
+ override var preferredStatusBarStyle: UIStatusBarStyle {
+ .lightContent
+ }
+
+ /// 使用沉浸式自定义头部展示返回入口、文件信息和当前照片位置。
+ private func setupNavigationBar() {
+ previewHeaderView.onBack = { [weak self] in
+ self?.backTapped()
+ }
+ view.addSubview(previewHeaderView)
+ previewHeaderView.snp.makeConstraints { make in
+ make.top.equalTo(view.safeAreaLayoutGuide)
+ make.leading.trailing.equalToSuperview()
+ make.height.equalTo(68)
+ }
+ updateNavigationTitle()
+ }
+
+ private func setupUI() {
+ imageView.contentMode = .scaleAspectFit
+ imageView.clipsToBounds = true
+ imageView.image = UIImage(systemName: "photo")
+ imageView.tintColor = UIColor.white.withAlphaComponent(0.5)
+ imageView.isUserInteractionEnabled = true
+ let previousGesture = UISwipeGestureRecognizer(target: self, action: #selector(photoSwiped(_:)))
+ previousGesture.direction = .right
+ let nextGesture = UISwipeGestureRecognizer(target: self, action: #selector(photoSwiped(_:)))
+ nextGesture.direction = .left
+ imageView.addGestureRecognizer(previousGesture)
+ imageView.addGestureRecognizer(nextGesture)
+
+ bottomBar.backgroundColor = .black
+ bottomBar.layer.borderColor = UIColor.white.withAlphaComponent(0.12).cgColor
+ bottomBar.layer.borderWidth = 1 / UIScreen.main.scale
+ bottomContentStack.axis = .vertical
+ bottomContentStack.spacing = 10
+ comparisonBar.axis = .horizontal
+ comparisonBar.alignment = .center
+ comparisonBar.distribution = .equalSpacing
+ comparisonBar.isHidden = true
+
+ versionControl.selectedSegmentIndex = 1
+ versionControl.selectedSegmentTintColor = UIColor.white.withAlphaComponent(0.2)
+ versionControl.setTitleTextAttributes([.foregroundColor: UIColor.white.withAlphaComponent(0.7)], for: .normal)
+ versionControl.setTitleTextAttributes([.foregroundColor: UIColor.white], for: .selected)
+ versionControl.addTarget(self, action: #selector(versionChanged), for: .valueChanged)
+
+ highResolutionButton.setTitle("查看高清图", for: .normal)
+ highResolutionButton.setTitleColor(.white, for: .normal)
+ highResolutionButton.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
+ highResolutionButton.backgroundColor = UIColor.white.withAlphaComponent(0.18)
+ highResolutionButton.layer.cornerRadius = 16
+ highResolutionButton.addTarget(self, action: #selector(highResolutionTapped), for: .touchUpInside)
+
+ actionStack.axis = .horizontal
+ actionStack.distribution = .fillEqually
+
+ aiEditButton.addTarget(self, action: #selector(aiEditTapped), for: .touchUpInside)
+ actionStack.addArrangedSubview(aiEditButton)
+ [
+ ("删除", "travel_album_preview_delete", "trash", #selector(deleteTapped)),
+ ("刷新", "travel_album_preview_refresh", "arrow.clockwise", #selector(refreshTapped)),
+ ].forEach { title, assetName, fallbackIcon, action in
+ let button = TravelAlbumPreviewActionButton(
+ title: title,
+ icon: UIImage(named: assetName) ?? UIImage(systemName: fallbackIcon)
+ )
+ button.addTarget(self, action: action, for: .touchUpInside)
+ actionStack.addArrangedSubview(button)
+ }
+
+ view.addSubview(imageView)
+ view.addSubview(bottomBar)
+ bottomBar.addSubview(bottomContentStack)
+ bottomContentStack.addArrangedSubview(comparisonBar)
+ bottomContentStack.addArrangedSubview(actionStack)
+ comparisonBar.addArrangedSubview(versionControl)
+ comparisonBar.addArrangedSubview(highResolutionButton)
+ bottomBar.snp.makeConstraints { make in
+ make.leading.trailing.bottom.equalToSuperview()
+ }
+ bottomContentStack.snp.makeConstraints { make in
+ make.top.equalToSuperview().offset(12)
+ make.leading.trailing.equalToSuperview().inset(20)
+ make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-10)
+ }
+ comparisonBar.snp.makeConstraints { make in
+ make.height.equalTo(32).priority(.high)
+ }
+ versionControl.snp.makeConstraints { make in
+ make.width.equalTo(124)
+ make.height.equalTo(32)
+ }
+ highResolutionButton.snp.makeConstraints { make in
+ make.width.equalTo(112)
+ make.height.equalTo(32)
+ }
+ actionStack.snp.makeConstraints { make in
+ make.height.equalTo(58)
+ }
+ imageView.snp.makeConstraints { make in
+ make.top.equalTo(previewHeaderView.snp.bottom)
+ make.leading.trailing.equalToSuperview()
+ make.bottom.equalTo(bottomBar.snp.top).offset(-20)
+ }
+ }
+
+ /// 返回相册管理页;照片预览统一使用全屏模态展示。
+ @objc private func backTapped() {
+ closePreview()
+ }
+
+ /// 关闭当前预览;保留导航栈兼容分支,避免旧入口无法返回。
+ private func closePreview() {
+ if let navigationController,
+ navigationController.viewControllers.first !== self {
+ navigationController.popViewController(animated: true)
+ } else {
+ dismiss(animated: true)
+ }
+ }
+
+ /// 加载当前素材的原图,刷新时始终绕过 Kingfisher 内存缓存。
+ private func loadImage(forceRefresh: Bool = false) {
+ guard let material = previewState.currentMaterial else { return }
+ if let assetName = material.bundledImageAssetName,
+ let bundledImage = UIImage(named: assetName) {
+ originalImagesByMaterialID[material.id] = bundledImage
+ originalImage = bundledImage
+ renderCurrentPhoto()
+ return
+ }
+ let imageURL = material.fileUrl.isEmpty ? material.coverUrl : material.fileUrl
+ guard let url = URL(string: imageURL), !imageURL.isEmpty else { return }
+ let options: KingfisherOptionsInfo = forceRefresh ? [.forceRefresh] : []
+ let materialID = material.id
+ KingfisherManager.shared.retrieveImage(with: url, options: options) { [weak self] result in
+ guard let self, case let .success(value) = result else { return }
+ Task { @MainActor in
+ self.originalImagesByMaterialID[materialID] = value.image
+ guard self.previewState.currentMaterial?.id == materialID else { return }
+ self.originalImage = value.image
+ self.renderCurrentPhoto()
+ }
+ }
+ }
+
+ /// 响应左右滑动并切换至相邻照片。
+ @objc private func photoSwiped(_ gesture: UISwipeGestureRecognizer) {
+ let didMove: Bool
+ switch gesture.direction {
+ case .left:
+ didMove = previewState.moveNext()
+ case .right:
+ didMove = previewState.movePrevious()
+ default:
+ didMove = false
+ }
+ guard didMove else { return }
+ displayCurrentPhoto()
+ }
+
+ /// 更新当前照片的标题、原图和预览内容。
+ private func displayCurrentPhoto() {
+ guard let material = previewState.currentMaterial else { return }
+ originalImage = originalImagesByMaterialID[material.id]
+ updateNavigationTitle()
+ renderCurrentPhoto()
+ updateAIEditControls()
+ if originalImage == nil {
+ loadImage()
+ }
+ }
+
+ /// 根据当前照片同步导航栏文件名与大小。
+ private func updateNavigationTitle() {
+ guard let material = previewState.currentMaterial else { return }
+ previewHeaderView.configure(
+ title: material.fileName.isEmpty ? "照片预览" : material.fileName,
+ subtitle: TravelAlbumDisplayFormatter.fileSizeText(material.fileSize),
+ trailingText: "\(previewState.currentIndex + 1)/\(previewState.materials.count)"
+ )
+ }
+
+ @objc private func aiEditTapped() {
+ guard let material = previewState.currentMaterial else { return }
+ let state = aiEditResultStore.state(for: material.id)
+ guard state.canStartEditing else { return }
+ guard let originalImage else {
+ showToast("照片加载中,请稍后再试")
+ return
+ }
+ let controller = TravelAlbumAIEditSheetViewController(sourceImage: originalImage, selectedPhotoCount: 1)
+ controller.onConfirm = { [weak self] selection in
+ guard let self else { return }
+ self.startAIEditing(
+ materialID: material.id,
+ sourceImage: originalImage,
+ selection: selection
+ )
+ }
+ present(controller, animated: true)
+ }
+
+ /// 启动本地演示修图任务;处理期间禁用入口,完成后用新结果覆盖旧结果。
+ private func startAIEditing(
+ materialID: Int,
+ sourceImage: UIImage,
+ selection: TravelAlbumAIEditSelection
+ ) {
+ let state = aiEditResultStore.state(for: materialID)
+ guard state.canStartEditing else { return }
+ let existingCoverTemplate = state.appliedCoverTemplateID.flatMap { templateID in
+ TravelAlbumCoverTemplate.defaultOptions.first(where: { $0.id == templateID })
+ }
+ aiEditResultStore.startProcessing(materialIDs: [materialID])
+ updateAIEditControls()
+
+ let refinedImage = TravelAlbumAIEditImageProcessor.render(
+ effect: selection.refinedPreset.effect,
+ source: sourceImage
+ )
+ let atmosphereImage = selection.atmosphereOption.map { option in
+ TravelAlbumAIEditImageProcessor.render(effect: option.effect, source: sourceImage)
+ }
+ let coverImage = existingCoverTemplate.map { template in
+ TravelAlbumCoverTemplateImageProcessor.render(template: template, source: refinedImage)
+ }
+ let resultStore = aiEditResultStore
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
+ // 即使用户在处理中离开预览页,也要完成任务并让相册缩略图恢复可操作状态。
+ if selection.refinedPreset.effect == .original,
+ selection.atmosphereOption == nil,
+ existingCoverTemplate == nil {
+ resultStore.restoreOriginal(materialID: materialID)
+ } else {
+ resultStore.complete(
+ materialID: materialID,
+ presetID: selection.refinedPreset.id,
+ atmosphereOptionID: selection.atmosphereOption?.id,
+ coverTemplateID: existingCoverTemplate?.id,
+ editedImage: refinedImage,
+ atmosphereImage: atmosphereImage,
+ coverImage: coverImage
+ )
+ }
+ guard let self else { return }
+ if self.previewState.currentMaterial?.id == materialID {
+ let message: String
+ if selection.refinedPreset.effect == .original,
+ selection.atmosphereOption == nil,
+ existingCoverTemplate == nil {
+ message = "已还原为原图"
+ } else if selection.atmosphereOption != nil {
+ message = "AI修图完成,已生成 2 个结果"
+ } else {
+ message = "AI修图完成"
+ }
+ self.showToast(message)
+ }
+ }
+ }
+
+ /// 根据当前照片的对比选项展示原图或最新修图结果。
+ private func renderCurrentPhoto() {
+ guard let material = previewState.currentMaterial else { return }
+ let state = aiEditResultStore.state(for: material.id)
+ let displayImage: UIImage?
+ switch state.displayMode {
+ case .original:
+ displayImage = originalImagesByMaterialID[material.id]
+ case .edited:
+ displayImage = aiEditResultStore.editedImage(for: material.id) ?? originalImagesByMaterialID[material.id]
+ case .atmosphere:
+ displayImage = aiEditResultStore.atmosphereImage(for: material.id)
+ ?? aiEditResultStore.editedImage(for: material.id)
+ ?? originalImagesByMaterialID[material.id]
+ case .cover:
+ displayImage = aiEditResultStore.coverImage(for: material.id)
+ ?? aiEditResultStore.editedImage(for: material.id)
+ ?? originalImagesByMaterialID[material.id]
+ }
+ UIView.transition(with: imageView, duration: 0.2, options: .transitionCrossDissolve) {
+ self.imageView.image = displayImage ?? UIImage(systemName: "photo")
+ self.imageView.tintColor = UIColor.white.withAlphaComponent(0.5)
+ }
+ }
+
+ /// 刷新修图对比栏和 AI 按钮的可用状态。
+ private func updateAIEditControls() {
+ guard let material = previewState.currentMaterial else { return }
+ let state = aiEditResultStore.state(for: material.id)
+ comparisonBar.isHidden = !state.hasEditedResult
+ updateVersionControl(for: state)
+ aiEditButton.isEnabled = state.canStartEditing
+ aiEditButton.alpha = state.canStartEditing ? 1 : 0.35
+ UIView.animate(withDuration: 0.2) {
+ self.view.layoutIfNeeded()
+ }
+ }
+
+ @objc private func versionChanged() {
+ guard let material = previewState.currentMaterial,
+ visibleDisplayModes.indices.contains(versionControl.selectedSegmentIndex) else { return }
+ aiEditResultStore.selectDisplayMode(
+ visibleDisplayModes[versionControl.selectedSegmentIndex],
+ materialID: material.id
+ )
+ renderCurrentPhoto()
+ }
+
+ /// 根据当前实际结果动态展示原图、精修后、氛围感和封面切换项。
+ private func updateVersionControl(for state: TravelAlbumAIEditResultState) {
+ var modes: [TravelAlbumAIEditResultState.DisplayMode] = [.original, .edited]
+ if state.hasAtmosphereResult {
+ modes.append(.atmosphere)
+ }
+ if state.isCover {
+ modes.append(.cover)
+ }
+ if visibleDisplayModes != modes {
+ visibleDisplayModes = modes
+ versionControl.removeAllSegments()
+ modes.enumerated().forEach { index, mode in
+ let title: String
+ switch mode {
+ case .original: title = "原图"
+ case .edited: title = "精修后"
+ case .atmosphere: title = "氛围感"
+ case .cover: title = "封面"
+ }
+ versionControl.insertSegment(withTitle: title, at: index, animated: false)
+ }
+ let width: CGFloat
+ switch modes.count {
+ case 4: width = 220
+ case 3: width = 180
+ default: width = 124
+ }
+ versionControl.snp.updateConstraints { make in
+ make.width.equalTo(width)
+ }
+ }
+ versionControl.selectedSegmentIndex = visibleDisplayModes.firstIndex(of: state.displayMode) ?? 1
+ }
+
+ @objc private func aiEditResultDidChange(_ notification: Notification) {
+ guard let materialID = notification.userInfo?[TravelAlbumAIEditResultStore.materialIDUserInfoKey] as? Int,
+ previewState.currentMaterial?.id == materialID else { return }
+ renderCurrentPhoto()
+ updateAIEditControls()
+ }
+
+ @objc private func highResolutionTapped() {
+ guard let image = imageView.image else { return }
+ let controller = TravelAlbumImagePreviewViewController(image: image, title: "高清图")
+ controller.modalPresentationStyle = .fullScreen
+ controller.modalTransitionStyle = .crossDissolve
+ present(controller, animated: true)
+ }
+
+ @objc private func deleteTapped() {
+ guard let material = previewState.currentMaterial else { return }
+ guard !material.isPurchased else {
+ showToast("已购照片不可删除")
+ return
+ }
+ let alert = UIAlertController(title: "删除照片", message: "删除后无法恢复,确定删除这张照片吗?", preferredStyle: .alert)
+ alert.addAction(UIAlertAction(title: "取消", style: .cancel))
+ alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
+ guard let self else { return }
+ Task {
+ do {
+ try await self.api.deleteMaterial(id: material.id)
+ await MainActor.run {
+ self.originalImagesByMaterialID.removeValue(forKey: material.id)
+ self.aiEditResultStore.remove(materialID: material.id)
+ self.onDeleted?()
+ if self.previewState.removeCurrent() {
+ self.displayCurrentPhoto()
+ } else {
+ self.closePreview()
+ }
+ }
+ } catch {
+ await MainActor.run {
+ self.showToast(error.localizedDescription.isEmpty ? "删除失败" : error.localizedDescription)
+ }
+ }
+ }
+ })
+ present(alert, animated: true)
+ }
+
+ @objc private func refreshTapped() {
+ guard let material = previewState.currentMaterial else { return }
+ imageView.image = UIImage(systemName: "arrow.triangle.2.circlepath")
+ imageView.tintColor = UIColor.white.withAlphaComponent(0.5)
+ originalImage = nil
+ originalImagesByMaterialID.removeValue(forKey: material.id)
+ loadImage(forceRefresh: true)
+ }
+
+ private func showToast(_ message: String) {
+ let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
+ present(alert, animated: true)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak alert] in
+ alert?.dismiss(animated: true)
+ }
+ }
+}
+
+/// 全屏预览页底部的图文操作按钮。
+private final class TravelAlbumPreviewActionButton: UIButton {
+ init(title: String, icon: UIImage?) {
+ super.init(frame: .zero)
+ var configuration = UIButton.Configuration.plain()
+ configuration.image = icon
+ configuration.title = title
+ configuration.imagePlacement = .top
+ configuration.imagePadding = 6
+ configuration.baseForegroundColor = .white
+ configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
+ var attributes = attributes
+ attributes.font = .systemFont(ofSize: 13)
+ return attributes
+ }
+ self.configuration = configuration
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+}
+
+/// 相册预览统一头部,提供单一返回入口、两行标题和可选的右侧状态信息。
+private final class TravelAlbumPreviewHeaderView: UIView {
+ var onBack: (() -> Void)?
+
+ private let backButton = UIButton(type: .system)
+ private let titleLabel = UILabel()
+ private let subtitleLabel = UILabel()
+ private let titleStack = UIStackView()
+ private let trailingLabel = UILabel()
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ setupUI()
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ /// 刷新头部标题、辅助说明和右侧状态。
+ func configure(title: String, subtitle: String?, trailingText: String?) {
+ titleLabel.text = title
+ subtitleLabel.text = subtitle
+ subtitleLabel.isHidden = subtitle?.isEmpty != false
+ trailingLabel.text = trailingText
+ trailingLabel.isHidden = trailingText?.isEmpty != false
+ }
+
+ private func setupUI() {
+ backgroundColor = .black
+
+ var backConfiguration = UIButton.Configuration.plain()
+ backConfiguration.image = UIImage(
+ systemName: "chevron.left",
+ withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold)
+ )
+ backConfiguration.baseForegroundColor = .white
+ backConfiguration.contentInsets = .zero
+ backButton.configuration = backConfiguration
+ backButton.backgroundColor = UIColor.white.withAlphaComponent(0.10)
+ backButton.layer.cornerRadius = 20
+ backButton.accessibilityLabel = "返回"
+ backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
+
+ titleLabel.textColor = .white
+ titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
+ titleLabel.textAlignment = .center
+ titleLabel.lineBreakMode = .byTruncatingMiddle
+
+ subtitleLabel.textColor = UIColor.white.withAlphaComponent(0.55)
+ subtitleLabel.font = .systemFont(ofSize: 12, weight: .regular)
+ subtitleLabel.textAlignment = .center
+
+ titleStack.axis = .vertical
+ titleStack.alignment = .fill
+ titleStack.spacing = 3
+ titleStack.addArrangedSubview(titleLabel)
+ titleStack.addArrangedSubview(subtitleLabel)
+
+ trailingLabel.textColor = UIColor.white.withAlphaComponent(0.78)
+ trailingLabel.font = .monospacedDigitSystemFont(ofSize: 12, weight: .medium)
+ trailingLabel.textAlignment = .center
+ trailingLabel.backgroundColor = UIColor.white.withAlphaComponent(0.10)
+ trailingLabel.layer.cornerRadius = 14
+ trailingLabel.clipsToBounds = true
+
+ addSubview(backButton)
+ addSubview(titleStack)
+ addSubview(trailingLabel)
+ backButton.snp.makeConstraints { make in
+ make.leading.equalToSuperview().offset(16)
+ make.centerY.equalToSuperview()
+ make.width.height.equalTo(40)
+ }
+ trailingLabel.snp.makeConstraints { make in
+ make.trailing.equalToSuperview().offset(-16)
+ make.centerY.equalToSuperview()
+ make.width.greaterThanOrEqualTo(44)
+ make.height.equalTo(28)
+ }
+ titleStack.snp.makeConstraints { make in
+ make.center.equalToSuperview()
+ make.leading.greaterThanOrEqualTo(backButton.snp.trailing).offset(12)
+ make.trailing.lessThanOrEqualTo(trailingLabel.snp.leading).offset(-12)
+ make.width.lessThanOrEqualTo(230)
+ }
+ }
+
+ @objc private func backTapped() {
+ onBack?()
+ }
+}
+
+/// 图片统一大图预览页,支持双指缩放并沿用相册预览的沉浸式头部。
+private final class TravelAlbumImagePreviewViewController: UIViewController, UIScrollViewDelegate {
+ private let image: UIImage
+ private let previewTitle: String
+ private let previewHeaderView = TravelAlbumPreviewHeaderView()
+ private let scrollView = UIScrollView()
+ private let imageView = UIImageView()
+
+ init(image: UIImage, title: String = "高清图") {
+ self.image = image
+ previewTitle = title
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ view.backgroundColor = .black
+ previewHeaderView.configure(
+ title: previewTitle,
+ subtitle: "双指缩放查看细节",
+ trailingText: nil
+ )
+ previewHeaderView.onBack = { [weak self] in
+ self?.dismiss(animated: true)
+ }
+
+ scrollView.delegate = self
+ scrollView.minimumZoomScale = 1
+ scrollView.maximumZoomScale = 4
+ scrollView.showsHorizontalScrollIndicator = false
+ scrollView.showsVerticalScrollIndicator = false
+ imageView.image = image
+ imageView.contentMode = .scaleAspectFit
+
+ view.addSubview(previewHeaderView)
+ view.addSubview(scrollView)
+ scrollView.addSubview(imageView)
+ previewHeaderView.snp.makeConstraints { make in
+ make.top.equalTo(view.safeAreaLayoutGuide)
+ make.leading.trailing.equalToSuperview()
+ make.height.equalTo(68)
+ }
+ scrollView.snp.makeConstraints { make in
+ make.top.equalTo(previewHeaderView.snp.bottom)
+ make.leading.trailing.bottom.equalToSuperview()
+ }
+ imageView.snp.makeConstraints { make in
+ make.edges.equalTo(scrollView.contentLayoutGuide)
+ make.width.height.equalTo(scrollView.frameLayoutGuide)
+ }
+ }
+
+ func viewForZooming(in scrollView: UIScrollView) -> UIView? {
+ imageView
+ }
+
+ override var preferredStatusBarStyle: UIStatusBarStyle {
+ .lightContent
+ }
+}
+
+/// AI 修图底部弹窗,统一选择原图精修、可选氛围感修图和封面模板。
+final class TravelAlbumAIEditSheetViewController: UIViewController {
+ var onConfirm: ((TravelAlbumAIEditSelection) -> Void)?
+
+ private let sourceImage: UIImage?
+ private let selectedPhotoCount: Int
+ private let presets = TravelAlbumEditPreset.defaultOptions
+ private let atmosphereOptions = TravelAlbumAtmosphereEditOption.defaultOptions
+ private let coverTemplates = TravelAlbumCoverTemplate.defaultOptions
+ private let showsCoverTemplates: Bool
+ private var selectedPresetID = TravelAlbumEditPreset.defaultOptions[0].id
+ private var selectedAtmosphereOptionID: String?
+ private var selectedCoverTemplateID: String?
+ private let scrollView = UIScrollView()
+ private let contentView = UIView()
+ private let tipsContainerView = UIView()
+ private let tipsLabel = UILabel()
+ private let quotaInfoView = UIView()
+ private var collectionView: UICollectionView!
+ private var dataSource: UICollectionViewDiffableDataSource!
+ private var atmosphereCollectionView: UICollectionView!
+ private var atmosphereDataSource: UICollectionViewDiffableDataSource!
+ private var coverCollectionView: UICollectionView!
+ private var coverDataSource: UICollectionViewDiffableDataSource!
+
+ init(sourceImage: UIImage?, selectedPhotoCount: Int) {
+ self.sourceImage = sourceImage
+ self.selectedPhotoCount = max(selectedPhotoCount, 1)
+ showsCoverTemplates = TravelAlbumCoverTemplate.shouldShow(for: selectedPhotoCount)
+ selectedCoverTemplateID = showsCoverTemplates ? TravelAlbumCoverTemplate.defaultOptions.first?.id : nil
+ super.init(nibName: nil, bundle: nil)
+ modalPresentationStyle = .pageSheet
+ if let sheetPresentationController {
+ let contentIdentifier = UISheetPresentationController.Detent.Identifier("aiEditContent")
+ let desiredHeight: CGFloat = showsCoverTemplates ? 920 : 770
+ let contentDetent = UISheetPresentationController.Detent.custom(identifier: contentIdentifier) { context in
+ return min(desiredHeight, context.maximumDetentValue)
+ }
+ sheetPresentationController.detents = [contentDetent, .large()]
+ sheetPresentationController.selectedDetentIdentifier = contentIdentifier
+ sheetPresentationController.prefersGrabberVisible = true
+ }
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ view.backgroundColor = AppColor.pageBackground
+ setupUI()
+ applySnapshot()
+ }
+
+ private func setupUI() {
+ let titleLabel = UILabel()
+ titleLabel.text = "AI修图"
+ titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
+ titleLabel.textColor = AppColor.textPrimary
+ titleLabel.textAlignment = .center
+
+ let countLabel = UILabel()
+ countLabel.text = "已选择 \(selectedPhotoCount) 张照片"
+ countLabel.font = .systemFont(ofSize: 15)
+ countLabel.textColor = AppColor.textSecondary
+ countLabel.textAlignment = .center
+
+ tipsContainerView.backgroundColor = UIColor(hex: 0xEAF2FF)
+ tipsContainerView.layer.cornerRadius = 10
+ tipsLabel.font = .systemFont(ofSize: 13)
+ tipsLabel.textColor = UIColor(hex: 0x43617F)
+ tipsLabel.numberOfLines = 0
+ tipsContainerView.addSubview(tipsLabel)
+ tipsLabel.snp.makeConstraints { make in
+ make.edges.equalToSuperview().inset(12)
+ }
+
+ let presetTitleLabel = UILabel()
+ presetTitleLabel.text = "原图精修"
+ presetTitleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
+ presetTitleLabel.textColor = AppColor.textPrimary
+
+ let layout = UICollectionViewFlowLayout()
+ layout.scrollDirection = .horizontal
+ layout.minimumLineSpacing = 12
+ layout.itemSize = CGSize(width: 116, height: 142)
+ collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
+ collectionView.backgroundColor = .clear
+ collectionView.showsHorizontalScrollIndicator = false
+ collectionView.delegate = self
+ collectionView.register(TravelAlbumEditPresetCell.self, forCellWithReuseIdentifier: TravelAlbumEditPresetCell.reuseIdentifier)
+ dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, preset in
+ let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TravelAlbumEditPresetCell.reuseIdentifier, for: indexPath) as! TravelAlbumEditPresetCell
+ cell.apply(preset: preset, sourceImage: self?.sourceImage, selected: self?.selectedPresetID == preset.id)
+ cell.onPreview = { [weak self] in
+ guard let self else { return }
+ let previewImage = self.sourceImage.map {
+ TravelAlbumAIEditImageProcessor.render(effect: preset.effect, source: $0)
+ }
+ self.presentStylePreview(title: preset.title, image: previewImage)
+ }
+ return cell
+ }
+
+ let atmosphereHeaderView = UIView()
+ let atmosphereTitleLabel = UILabel()
+ atmosphereTitleLabel.text = "氛围感修图"
+ atmosphereTitleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
+ atmosphereTitleLabel.textColor = AppColor.textPrimary
+ let optionalLabel = UILabel()
+ optionalLabel.text = "选填"
+ optionalLabel.font = .systemFont(ofSize: 12, weight: .medium)
+ optionalLabel.textColor = AppColor.primary
+ optionalLabel.backgroundColor = UIColor(hex: 0xEAF2FF)
+ optionalLabel.textAlignment = .center
+ optionalLabel.layer.cornerRadius = 13
+ optionalLabel.clipsToBounds = true
+ atmosphereHeaderView.addSubview(atmosphereTitleLabel)
+ atmosphereHeaderView.addSubview(optionalLabel)
+ atmosphereTitleLabel.snp.makeConstraints { make in
+ make.leading.centerY.equalToSuperview()
+ }
+ optionalLabel.snp.makeConstraints { make in
+ make.leading.equalTo(atmosphereTitleLabel.snp.trailing).offset(8)
+ make.centerY.equalToSuperview()
+ make.width.equalTo(48)
+ make.height.equalTo(26)
+ }
+
+ let atmosphereLayout = UICollectionViewFlowLayout()
+ atmosphereLayout.scrollDirection = .horizontal
+ atmosphereLayout.minimumLineSpacing = 12
+ atmosphereLayout.itemSize = CGSize(width: 116, height: 142)
+ atmosphereCollectionView = UICollectionView(frame: .zero, collectionViewLayout: atmosphereLayout)
+ atmosphereCollectionView.backgroundColor = .clear
+ atmosphereCollectionView.showsHorizontalScrollIndicator = false
+ atmosphereCollectionView.delegate = self
+ atmosphereCollectionView.register(
+ TravelAlbumEditPresetCell.self,
+ forCellWithReuseIdentifier: TravelAlbumEditPresetCell.reuseIdentifier
+ )
+ atmosphereDataSource = UICollectionViewDiffableDataSource(
+ collectionView: atmosphereCollectionView
+ ) { [weak self] collectionView, indexPath, option in
+ let cell = collectionView.dequeueReusableCell(
+ withReuseIdentifier: TravelAlbumEditPresetCell.reuseIdentifier,
+ for: indexPath
+ ) as! TravelAlbumEditPresetCell
+ cell.apply(
+ atmosphereOption: option,
+ sourceImage: self?.sourceImage,
+ selected: self?.selectedAtmosphereOptionID == option.id
+ )
+ cell.onPreview = { [weak self] in
+ guard let self else { return }
+ let previewImage = self.sourceImage.map {
+ TravelAlbumAIEditImageProcessor.render(effect: option.effect, source: $0)
+ }
+ self.presentStylePreview(title: option.title, image: previewImage)
+ }
+ return cell
+ }
+ updateAtmosphereSelection()
+
+ let coverHeaderView = UIView()
+ coverHeaderView.isHidden = !showsCoverTemplates
+ let coverTitleLabel = UILabel()
+ coverTitleLabel.text = "封面风格模板"
+ coverTitleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
+ coverTitleLabel.textColor = AppColor.textPrimary
+ let freeGiftLabel = UILabel()
+ freeGiftLabel.text = "赠送 · 不占精修张数"
+ freeGiftLabel.font = .systemFont(ofSize: 12, weight: .medium)
+ freeGiftLabel.textColor = UIColor(hex: 0xF29A38)
+ freeGiftLabel.backgroundColor = UIColor(hex: 0xFFF3DE)
+ freeGiftLabel.textAlignment = .center
+ freeGiftLabel.layer.cornerRadius = 13
+ freeGiftLabel.clipsToBounds = true
+ coverHeaderView.addSubview(coverTitleLabel)
+ coverHeaderView.addSubview(freeGiftLabel)
+ coverTitleLabel.snp.makeConstraints { make in
+ make.leading.centerY.equalToSuperview()
+ }
+ freeGiftLabel.snp.makeConstraints { make in
+ make.trailing.centerY.equalToSuperview()
+ make.height.equalTo(26)
+ make.width.equalTo(148)
+ }
+
+ let coverLayout = UICollectionViewFlowLayout()
+ coverLayout.scrollDirection = .horizontal
+ coverLayout.minimumLineSpacing = 12
+ coverLayout.itemSize = CGSize(width: 116, height: 142)
+ coverCollectionView = UICollectionView(frame: .zero, collectionViewLayout: coverLayout)
+ coverCollectionView.backgroundColor = .clear
+ coverCollectionView.showsHorizontalScrollIndicator = false
+ coverCollectionView.isHidden = !showsCoverTemplates
+ coverCollectionView.delegate = self
+ coverCollectionView.register(
+ TravelAlbumCoverTemplateCell.self,
+ forCellWithReuseIdentifier: TravelAlbumCoverTemplateCell.reuseIdentifier
+ )
+ coverDataSource = UICollectionViewDiffableDataSource(
+ collectionView: coverCollectionView
+ ) { [weak self] collectionView, indexPath, template in
+ let cell = collectionView.dequeueReusableCell(
+ withReuseIdentifier: TravelAlbumCoverTemplateCell.reuseIdentifier,
+ for: indexPath
+ ) as! TravelAlbumCoverTemplateCell
+ cell.apply(
+ template: template,
+ sourceImage: self?.sourceImage,
+ selected: self?.selectedCoverTemplateID == template.id
+ )
+ cell.onPreview = { [weak self] in
+ guard let self else { return }
+ let previewImage = TravelAlbumCoverTemplateImageProcessor.render(
+ template: template,
+ source: self.sourceImage
+ )
+ self.presentStylePreview(title: template.title, image: previewImage)
+ }
+ return cell
+ }
+
+ let modeTitleLabel = UILabel()
+ modeTitleLabel.text = "修图模式"
+ modeTitleLabel.font = .systemFont(ofSize: 15)
+ modeTitleLabel.textColor = AppColor.textSecondary
+
+ quotaInfoView.backgroundColor = .white
+ quotaInfoView.layer.cornerRadius = 12
+ quotaInfoView.accessibilityLabel = "AI精修按张收费,剩余9张"
+ quotaInfoView.accessibilityTraits = .staticText
+ let modeLabel = UILabel()
+ modeLabel.text = "AI精修 · 按张收费"
+ modeLabel.font = .systemFont(ofSize: 16, weight: .medium)
+ modeLabel.textColor = AppColor.textPrimary
+ let quotaLabel = UILabel()
+ quotaLabel.text = "剩余 9 张"
+ quotaLabel.font = .systemFont(ofSize: 14, weight: .medium)
+ quotaLabel.textColor = AppColor.primary
+ quotaLabel.backgroundColor = UIColor(hex: 0xEAF2FF)
+ quotaLabel.textAlignment = .center
+ quotaLabel.layer.cornerRadius = 15
+ quotaLabel.clipsToBounds = true
+ quotaInfoView.addSubview(modeLabel)
+ quotaInfoView.addSubview(quotaLabel)
+ modeLabel.snp.makeConstraints { make in
+ make.leading.equalToSuperview().offset(16)
+ make.centerY.equalToSuperview()
+ }
+ quotaLabel.snp.makeConstraints { make in
+ make.trailing.equalToSuperview().offset(-16)
+ make.centerY.equalToSuperview()
+ make.width.equalTo(82)
+ make.height.equalTo(30)
+ }
+
+ let cancelButton = makeBottomButton(title: "取消", isPrimary: false)
+ cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
+ let confirmButton = makeBottomButton(title: "确定", isPrimary: true)
+ confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
+ let buttonStack = UIStackView(arrangedSubviews: [cancelButton, confirmButton])
+ buttonStack.axis = .horizontal
+ buttonStack.spacing = 12
+ buttonStack.distribution = .fillEqually
+
+ view.addSubview(scrollView)
+ view.addSubview(buttonStack)
+ scrollView.addSubview(contentView)
+ [
+ titleLabel,
+ countLabel,
+ tipsContainerView,
+ presetTitleLabel,
+ collectionView,
+ atmosphereHeaderView,
+ atmosphereCollectionView,
+ coverHeaderView,
+ coverCollectionView,
+ modeTitleLabel,
+ quotaInfoView,
+ ].forEach(contentView.addSubview)
+ scrollView.showsVerticalScrollIndicator = false
+ scrollView.alwaysBounceVertical = showsCoverTemplates
+ scrollView.snp.makeConstraints { make in
+ make.top.leading.trailing.equalToSuperview()
+ make.bottom.equalTo(buttonStack.snp.top).offset(-12)
+ }
+ contentView.snp.makeConstraints { make in
+ make.edges.equalTo(scrollView.contentLayoutGuide)
+ make.width.equalTo(scrollView.frameLayoutGuide)
+ }
+ buttonStack.snp.makeConstraints { make in
+ make.leading.trailing.equalToSuperview().inset(20)
+ make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
+ make.height.equalTo(48)
+ }
+ titleLabel.snp.makeConstraints { make in
+ make.top.equalToSuperview().offset(28)
+ make.leading.trailing.equalToSuperview().inset(24)
+ }
+ countLabel.snp.makeConstraints { make in
+ make.top.equalTo(titleLabel.snp.bottom).offset(12)
+ make.leading.trailing.equalTo(titleLabel)
+ }
+ tipsContainerView.snp.makeConstraints { make in
+ make.top.equalTo(countLabel.snp.bottom).offset(14)
+ make.leading.trailing.equalToSuperview().inset(20)
+ }
+ presetTitleLabel.snp.makeConstraints { make in
+ make.top.equalTo(tipsContainerView.snp.bottom).offset(20)
+ make.leading.equalToSuperview().offset(20)
+ }
+ collectionView.snp.makeConstraints { make in
+ make.top.equalTo(presetTitleLabel.snp.bottom).offset(12)
+ make.leading.trailing.equalToSuperview().inset(20)
+ make.height.equalTo(142)
+ }
+ atmosphereHeaderView.snp.makeConstraints { make in
+ make.top.equalTo(collectionView.snp.bottom).offset(20)
+ make.leading.trailing.equalToSuperview().inset(20)
+ make.height.equalTo(26)
+ }
+ atmosphereCollectionView.snp.makeConstraints { make in
+ make.top.equalTo(atmosphereHeaderView.snp.bottom).offset(12)
+ make.leading.trailing.equalToSuperview().inset(20)
+ make.height.equalTo(142)
+ }
+ coverHeaderView.snp.makeConstraints { make in
+ make.top.equalTo(atmosphereCollectionView.snp.bottom).offset(showsCoverTemplates ? 20 : 0)
+ make.leading.trailing.equalToSuperview().inset(20)
+ make.height.equalTo(showsCoverTemplates ? 26 : 0)
+ }
+ coverCollectionView.snp.makeConstraints { make in
+ make.top.equalTo(coverHeaderView.snp.bottom).offset(showsCoverTemplates ? 12 : 0)
+ make.leading.trailing.equalToSuperview().inset(20)
+ make.height.equalTo(showsCoverTemplates ? 142 : 0)
+ }
+ modeTitleLabel.snp.makeConstraints { make in
+ make.top.equalTo(coverCollectionView.snp.bottom).offset(20)
+ make.leading.equalToSuperview().offset(20)
+ }
+ quotaInfoView.snp.makeConstraints { make in
+ make.top.equalTo(modeTitleLabel.snp.bottom).offset(12)
+ make.leading.trailing.equalToSuperview().inset(20)
+ make.height.equalTo(62)
+ make.bottom.equalToSuperview().offset(-20)
+ }
+ }
+
+ private func makeBottomButton(title: String, isPrimary: Bool) -> UIButton {
+ let button = UIButton(type: .system)
+ button.setTitle(title, for: .normal)
+ button.setTitleColor(isPrimary ? .white : AppColor.textPrimary, for: .normal)
+ button.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
+ button.backgroundColor = isPrimary ? AppColor.primary : .white
+ button.layer.cornerRadius = 10
+ button.layer.borderWidth = isPrimary ? 0 : 1
+ button.layer.borderColor = UIColor(hex: 0xD5D5D5).cgColor
+ return button
+ }
+
+ private func applySnapshot() {
+ var snapshot = NSDiffableDataSourceSnapshot()
+ snapshot.appendSections([0])
+ snapshot.appendItems(presets)
+ dataSource.apply(snapshot, animatingDifferences: false)
+
+ var atmosphereSnapshot = NSDiffableDataSourceSnapshot()
+ atmosphereSnapshot.appendSections([0])
+ atmosphereSnapshot.appendItems(atmosphereOptions)
+ atmosphereDataSource.apply(atmosphereSnapshot, animatingDifferences: false)
+
+ guard showsCoverTemplates else { return }
+ var coverSnapshot = NSDiffableDataSourceSnapshot()
+ coverSnapshot.appendSections([0])
+ coverSnapshot.appendItems(coverTemplates)
+ coverDataSource.apply(coverSnapshot, animatingDifferences: false)
+ }
+
+ @objc private func cancelTapped() {
+ dismiss(animated: true)
+ }
+
+ /// 刷新氛围感修图的可选状态,并用 Tips 明确不同组合会生成几个结果。
+ private func updateAtmosphereSelection() {
+ let isAtmosphereSelected = selectedAtmosphereOptionID != nil
+ if showsCoverTemplates {
+ tipsLabel.text = isAtmosphereSelected
+ ? "Tips:本次显示 3 类风格。每张照片生成「精修后 + 氛围感」2 个结果,第一张照片另生成所选封面结果。"
+ : "Tips:氛围感修图为选填,可横向选择一种样式;选中后每张照片会额外生成 1 个独立结果,第一张照片仍另生成封面。"
+ } else {
+ tipsLabel.text = isAtmosphereSelected
+ ? "Tips:已同时选择原图精修和氛围感修图,每张照片会生成「精修后 + 氛围感」2 个独立结果。"
+ : "Tips:原图精修、氛围感修图为两类独立模板;同时选择这两种模板时,同一张原图将并行生成两份不同的修图成品。"
+ }
+ }
+
+ /// 展示当前样式的大图预览;原图尚未加载时给出明确提示,不改变当前选择。
+ private func presentStylePreview(title: String, image: UIImage?) {
+ guard let image else {
+ let alert = UIAlertController(
+ title: "暂时无法预览",
+ message: "当前照片仍在加载,请稍后再试。",
+ preferredStyle: .alert
+ )
+ alert.addAction(UIAlertAction(title: "知道了", style: .default))
+ present(alert, animated: true)
+ return
+ }
+ let controller = TravelAlbumImagePreviewViewController(image: image, title: title)
+ controller.modalPresentationStyle = .fullScreen
+ controller.modalTransitionStyle = .crossDissolve
+ present(controller, animated: true)
+ }
+
+ @objc private func confirmTapped() {
+ guard let preset = presets.first(where: { $0.id == selectedPresetID }) else { return }
+ let coverTemplate = selectedCoverTemplateID.flatMap { selectedID in
+ coverTemplates.first(where: { $0.id == selectedID })
+ }
+ let atmosphereOption = selectedAtmosphereOptionID.flatMap { selectedID in
+ atmosphereOptions.first(where: { $0.id == selectedID })
+ }
+ let selection = TravelAlbumAIEditSelection(
+ refinedPreset: preset,
+ atmosphereOption: atmosphereOption,
+ coverTemplate: coverTemplate
+ )
+ onConfirm?(selection)
+ dismiss(animated: true)
+ }
+}
+
+extension TravelAlbumAIEditSheetViewController: UICollectionViewDelegate {
+ func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
+ if collectionView === coverCollectionView {
+ guard let template = coverDataSource.itemIdentifier(for: indexPath) else { return }
+ selectedCoverTemplateID = template.id
+ collectionView.visibleCells.compactMap { $0 as? TravelAlbumCoverTemplateCell }.forEach { cell in
+ guard let visiblePath = collectionView.indexPath(for: cell),
+ let visibleTemplate = coverDataSource.itemIdentifier(for: visiblePath) else { return }
+ cell.setSelected(visibleTemplate.id == selectedCoverTemplateID)
+ }
+ return
+ }
+
+ if collectionView === atmosphereCollectionView {
+ guard let option = atmosphereDataSource.itemIdentifier(for: indexPath) else { return }
+ // 氛围感为非必选项,再次点击当前样式即可取消本次氛围感结果。
+ selectedAtmosphereOptionID = selectedAtmosphereOptionID == option.id ? nil : option.id
+ collectionView.visibleCells.compactMap { $0 as? TravelAlbumEditPresetCell }.forEach { cell in
+ guard let visiblePath = collectionView.indexPath(for: cell),
+ let visibleOption = atmosphereDataSource.itemIdentifier(for: visiblePath) else { return }
+ cell.setSelected(visibleOption.id == selectedAtmosphereOptionID)
+ }
+ updateAtmosphereSelection()
+ return
+ }
+
+ guard let preset = dataSource.itemIdentifier(for: indexPath) else { return }
+ selectedPresetID = preset.id
+ collectionView.visibleCells.compactMap { $0 as? TravelAlbumEditPresetCell }.forEach { cell in
+ guard let visiblePath = collectionView.indexPath(for: cell), let visiblePreset = dataSource.itemIdentifier(for: visiblePath) else { return }
+ cell.setSelected(visiblePreset.id == selectedPresetID)
+ }
+ }
+}
+
+/// 带效果缩略图的修图预设单元格。
+private final class TravelAlbumEditPresetCell: UICollectionViewCell {
+ static let reuseIdentifier = "TravelAlbumEditPresetCell"
+
+ var onPreview: (() -> Void)?
+
+ private let imageView = UIImageView()
+ private let titleLabel = UILabel()
+ private let selectionView = UIImageView(image: UIImage(systemName: "checkmark.circle.fill"))
+ private let previewButton = UIButton(type: .system)
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ contentView.backgroundColor = .white
+ contentView.layer.cornerRadius = 10
+ contentView.clipsToBounds = true
+ imageView.contentMode = .scaleAspectFill
+ imageView.clipsToBounds = true
+ imageView.backgroundColor = UIColor(hex: 0xEAF2FF)
+ titleLabel.font = .systemFont(ofSize: 12, weight: .medium)
+ titleLabel.textColor = AppColor.textPrimary
+ titleLabel.textAlignment = .center
+ titleLabel.numberOfLines = 2
+ selectionView.tintColor = AppColor.primary
+ selectionView.backgroundColor = .white
+ selectionView.layer.cornerRadius = 10
+ selectionView.clipsToBounds = true
+ previewButton.setTitle("预览", for: .normal)
+ previewButton.setTitleColor(.white, for: .normal)
+ previewButton.titleLabel?.font = .systemFont(ofSize: 11, weight: .medium)
+ previewButton.backgroundColor = UIColor.black.withAlphaComponent(0.58)
+ previewButton.layer.cornerRadius = 12
+ previewButton.addTarget(self, action: #selector(previewTapped), for: .touchUpInside)
+ contentView.addSubview(imageView)
+ contentView.addSubview(titleLabel)
+ contentView.addSubview(selectionView)
+ contentView.addSubview(previewButton)
+ imageView.snp.makeConstraints { make in
+ make.top.leading.trailing.equalToSuperview()
+ make.height.equalTo(96)
+ }
+ titleLabel.snp.makeConstraints { make in
+ make.top.equalTo(imageView.snp.bottom).offset(6)
+ make.leading.trailing.equalToSuperview().inset(5)
+ make.bottom.lessThanOrEqualToSuperview().offset(-5)
+ }
+ selectionView.snp.makeConstraints { make in
+ make.top.trailing.equalToSuperview().inset(6)
+ make.size.equalTo(20)
+ }
+ previewButton.snp.makeConstraints { make in
+ make.trailing.bottom.equalTo(imageView).inset(6)
+ make.width.equalTo(46)
+ make.height.equalTo(24)
+ }
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ func apply(preset: TravelAlbumEditPreset, sourceImage: UIImage?, selected: Bool) {
+ apply(
+ title: preset.title,
+ effect: preset.effect,
+ sourceImage: sourceImage,
+ selected: selected
+ )
+ }
+
+ /// 更新氛围感样式的效果图、名称和单选状态。
+ func apply(
+ atmosphereOption: TravelAlbumAtmosphereEditOption,
+ sourceImage: UIImage?,
+ selected: Bool
+ ) {
+ apply(
+ title: atmosphereOption.title,
+ effect: atmosphereOption.effect,
+ sourceImage: sourceImage,
+ selected: selected
+ )
+ accessibilityHint = "再次点击已选样式可取消,点击预览按钮查看大图"
+ }
+
+ private func apply(
+ title: String,
+ effect: TravelAlbumEditPreset.Effect,
+ sourceImage: UIImage?,
+ selected: Bool
+ ) {
+ titleLabel.text = title
+ imageView.image = sourceImage.map {
+ TravelAlbumAIEditImageProcessor.render(effect: effect, source: $0)
+ } ?? UIImage(systemName: "photo")
+ setSelected(selected)
+ accessibilityLabel = title
+ accessibilityValue = selected ? "已选中" : "未选中"
+ }
+
+ func setSelected(_ selected: Bool) {
+ contentView.layer.borderWidth = selected ? 2 : 0
+ contentView.layer.borderColor = selected ? AppColor.primary.cgColor : UIColor.clear.cgColor
+ selectionView.isHidden = !selected
+ accessibilityValue = selected ? "已选中" : "未选中"
+ }
+
+ override func prepareForReuse() {
+ super.prepareForReuse()
+ onPreview = nil
+ accessibilityHint = nil
+ }
+
+ @objc private func previewTapped() {
+ onPreview?()
+ }
+}
+
+/// 带本地排版效果图的封面模板单元格。
+private final class TravelAlbumCoverTemplateCell: UICollectionViewCell {
+ static let reuseIdentifier = "TravelAlbumCoverTemplateCell"
+
+ var onPreview: (() -> Void)?
+
+ private let imageView = UIImageView()
+ private let titleLabel = UILabel()
+ private let selectionView = UIImageView(image: UIImage(systemName: "checkmark.circle.fill"))
+ private let previewButton = UIButton(type: .system)
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ contentView.backgroundColor = .white
+ contentView.layer.cornerRadius = 10
+ contentView.clipsToBounds = true
+
+ imageView.contentMode = .scaleAspectFill
+ imageView.clipsToBounds = true
+ imageView.backgroundColor = UIColor(hex: 0xEAF2FF)
+ titleLabel.font = .systemFont(ofSize: 12, weight: .medium)
+ titleLabel.textColor = AppColor.textPrimary
+ titleLabel.textAlignment = .center
+ selectionView.tintColor = AppColor.primary
+ selectionView.backgroundColor = .white
+ selectionView.layer.cornerRadius = 10
+ selectionView.clipsToBounds = true
+ previewButton.setTitle("预览", for: .normal)
+ previewButton.setTitleColor(.white, for: .normal)
+ previewButton.titleLabel?.font = .systemFont(ofSize: 11, weight: .medium)
+ previewButton.backgroundColor = UIColor.black.withAlphaComponent(0.58)
+ previewButton.layer.cornerRadius = 12
+ previewButton.addTarget(self, action: #selector(previewTapped), for: .touchUpInside)
+
+ contentView.addSubview(imageView)
+ contentView.addSubview(titleLabel)
+ contentView.addSubview(selectionView)
+ contentView.addSubview(previewButton)
+ imageView.snp.makeConstraints { make in
+ make.top.leading.trailing.equalToSuperview()
+ make.height.equalTo(104)
+ }
+ titleLabel.snp.makeConstraints { make in
+ make.top.equalTo(imageView.snp.bottom).offset(6)
+ make.leading.trailing.equalToSuperview().inset(5)
+ make.bottom.lessThanOrEqualToSuperview().offset(-5)
+ }
+ selectionView.snp.makeConstraints { make in
+ make.top.trailing.equalToSuperview().inset(6)
+ make.size.equalTo(20)
+ }
+ previewButton.snp.makeConstraints { make in
+ make.trailing.bottom.equalTo(imageView).inset(6)
+ make.width.equalTo(46)
+ make.height.equalTo(24)
+ }
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ /// 更新模板效果图与单选状态。
+ func apply(template: TravelAlbumCoverTemplate, sourceImage: UIImage?, selected: Bool) {
+ titleLabel.text = template.title
+ imageView.image = TravelAlbumCoverTemplateImageProcessor.render(
+ template: template,
+ source: sourceImage,
+ targetSize: CGSize(width: 232, height: 208)
+ )
+ setSelected(selected)
+ accessibilityLabel = "\(template.title)封面模板"
+ accessibilityValue = selected ? "已选中" : "未选中"
+ accessibilityHint = "点击预览按钮查看完整模板样式"
+ }
+
+ /// 刷新当前模板的选中描边和勾选标识。
+ func setSelected(_ selected: Bool) {
+ contentView.layer.borderWidth = selected ? 2 : 0
+ contentView.layer.borderColor = selected ? AppColor.primary.cgColor : UIColor.clear.cgColor
+ selectionView.isHidden = !selected
+ accessibilityValue = selected ? "已选中" : "未选中"
+ }
+
+ override func prepareForReuse() {
+ super.prepareForReuse()
+ onPreview = nil
+ }
+
+ @objc private func previewTapped() {
+ onPreview?()
+ }
+}
diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumTransferModeSheetViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumTransferModeSheetViewController.swift
new file mode 100644
index 0000000..0163b8d
--- /dev/null
+++ b/suixinkan/UI/TravelAlbum/TravelAlbumTransferModeSheetViewController.swift
@@ -0,0 +1,171 @@
+//
+// TravelAlbumTransferModeSheetViewController.swift
+// suixinkan
+//
+
+import SnapKit
+import UIKit
+
+/// 相册管理上传入口的传输模式选择弹窗。
+final class TravelAlbumTransferModeSheetViewController: UIViewController {
+ /// 用户选择传输模式后的回调;弹窗关闭后触发。
+ var onModeSelected: ((TravelAlbumOTGTransferMode) -> Void)?
+
+ private let titleLabel = UILabel()
+ private let subtitleLabel = UILabel()
+ private let optionsStack = UIStackView()
+ private let cancelButton = UIButton(type: .system)
+
+ override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
+ super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
+ modalPresentationStyle = .pageSheet
+ if let sheetPresentationController {
+ let identifier = UISheetPresentationController.Detent.Identifier("transferMode")
+ sheetPresentationController.detents = [
+ .custom(identifier: identifier) { context in
+ min(356, context.maximumDetentValue)
+ },
+ ]
+ sheetPresentationController.selectedDetentIdentifier = identifier
+ sheetPresentationController.prefersGrabberVisible = true
+ sheetPresentationController.preferredCornerRadius = 22
+ }
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ setupUI()
+ setupConstraints()
+ }
+
+ private func setupUI() {
+ view.backgroundColor = .white
+
+ titleLabel.text = "选择传输模式"
+ titleLabel.font = .systemFont(ofSize: 19, weight: .semibold)
+ titleLabel.textColor = AppColor.textPrimary
+ titleLabel.textAlignment = .center
+
+ subtitleLabel.text = "选择后进入对应的照片传输页面"
+ subtitleLabel.font = .systemFont(ofSize: 13)
+ subtitleLabel.textColor = AppColor.textSecondary
+ subtitleLabel.textAlignment = .center
+
+ optionsStack.axis = .vertical
+ optionsStack.spacing = 12
+ TravelAlbumOTGTransferMode.allCases.enumerated().forEach { index, mode in
+ optionsStack.addArrangedSubview(makeOptionView(mode: mode, index: index))
+ }
+
+ cancelButton.setTitle("取消", for: .normal)
+ cancelButton.setTitleColor(AppColor.textSecondary, for: .normal)
+ cancelButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
+ cancelButton.backgroundColor = UIColor(hex: 0xF4F5F7)
+ cancelButton.layer.cornerRadius = 12
+ cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
+
+ view.addSubview(titleLabel)
+ view.addSubview(subtitleLabel)
+ view.addSubview(optionsStack)
+ view.addSubview(cancelButton)
+ }
+
+ private func setupConstraints() {
+ titleLabel.snp.makeConstraints { make in
+ make.top.equalTo(view.safeAreaLayoutGuide).offset(14)
+ make.leading.trailing.equalToSuperview().inset(20)
+ }
+ subtitleLabel.snp.makeConstraints { make in
+ make.top.equalTo(titleLabel.snp.bottom).offset(7)
+ make.leading.trailing.equalToSuperview().inset(20)
+ }
+ optionsStack.snp.makeConstraints { make in
+ make.top.equalTo(subtitleLabel.snp.bottom).offset(18)
+ make.leading.trailing.equalToSuperview().inset(16)
+ }
+ cancelButton.snp.makeConstraints { make in
+ make.top.equalTo(optionsStack.snp.bottom).offset(14)
+ make.leading.trailing.equalTo(optionsStack)
+ make.height.equalTo(48)
+ make.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).offset(-10)
+ }
+ }
+
+ /// 创建无需二次确认的模式选项,点击后直接进入下一页。
+ private func makeOptionView(mode: TravelAlbumOTGTransferMode, index: Int) -> UIView {
+ let container = UIView()
+ container.backgroundColor = UIColor(hex: 0xF7F9FC)
+ container.layer.cornerRadius = 12
+ container.layer.borderWidth = 1
+ container.layer.borderColor = UIColor(hex: 0xE6ECF5).cgColor
+
+ let titleLabel = UILabel()
+ titleLabel.text = mode.title
+ titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
+ titleLabel.textColor = AppColor.textPrimary
+
+ let detailLabel = UILabel()
+ detailLabel.text = mode.detailText
+ detailLabel.font = .systemFont(ofSize: 12)
+ detailLabel.textColor = AppColor.textSecondary
+ detailLabel.numberOfLines = 2
+
+ let enterLabel = UILabel()
+ enterLabel.text = "进入"
+ enterLabel.font = .systemFont(ofSize: 13, weight: .medium)
+ enterLabel.textColor = AppColor.primary
+ enterLabel.textAlignment = .right
+
+ let button = UIButton(type: .custom)
+ button.tag = index
+ button.accessibilityLabel = "\(mode.title),\(mode.detailText)"
+ button.addTarget(self, action: #selector(modeTapped(_:)), for: .touchUpInside)
+
+ container.addSubview(titleLabel)
+ container.addSubview(detailLabel)
+ container.addSubview(enterLabel)
+ container.addSubview(button)
+ titleLabel.snp.makeConstraints { make in
+ make.top.equalToSuperview().offset(13)
+ make.leading.equalToSuperview().offset(16)
+ make.trailing.lessThanOrEqualTo(enterLabel.snp.leading).offset(-12)
+ }
+ detailLabel.snp.makeConstraints { make in
+ make.top.equalTo(titleLabel.snp.bottom).offset(5)
+ make.leading.equalTo(titleLabel)
+ make.trailing.lessThanOrEqualTo(enterLabel.snp.leading).offset(-12)
+ make.bottom.lessThanOrEqualToSuperview().offset(-12)
+ }
+ enterLabel.snp.makeConstraints { make in
+ make.trailing.equalToSuperview().offset(-16)
+ make.centerY.equalToSuperview()
+ make.width.equalTo(34)
+ }
+ button.snp.makeConstraints { make in
+ make.edges.equalToSuperview()
+ }
+ container.snp.makeConstraints { make in
+ make.height.equalTo(78)
+ }
+ return container
+ }
+
+ @objc private func modeTapped(_ sender: UIButton) {
+ guard TravelAlbumOTGTransferMode.allCases.indices.contains(sender.tag) else { return }
+ let mode = TravelAlbumOTGTransferMode.allCases[sender.tag]
+ // 先保留回调,避免弹窗关闭释放后无法进入对应模式页面。
+ let completion = onModeSelected
+ dismiss(animated: true) {
+ completion?(mode)
+ }
+ }
+
+ @objc private func cancelTapped() {
+ dismiss(animated: true)
+ }
+}
diff --git a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift
index e6c2f68..33f25e5 100644
--- a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift
+++ b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift
@@ -788,14 +788,23 @@ final class WiredCameraTransferViewController: BaseViewController {
}
private func presentPhotoPreview(_ item: TravelAlbumOTGPhotoItem) {
- guard let url = item.thumbnailURL else {
+ // 使用当前筛选列表的实际展示顺序,让预览页可连续查看上一张和下一张。
+ let displayedPhotos = dataSource.snapshot().itemIdentifiers
+ guard let context = displayedPhotos.previewContext(startingWith: item.id) else {
showToast("暂无可预览图片")
return
}
- let previewItem: MediaPreviewItem = url.isFileURL
- ? .localImage(url: url)
- : MediaPreviewItem(source: .remoteImage(url))
- MediaPreviewViewController.present(from: self, items: [previewItem], startIndex: 0)
+ let previewItems = context.photos.compactMap { photo -> MediaPreviewItem? in
+ guard let url = photo.thumbnailURL else { return nil }
+ return url.isFileURL
+ ? .localImage(url: url)
+ : MediaPreviewItem(source: .remoteImage(url))
+ }
+ MediaPreviewViewController.present(
+ from: self,
+ items: previewItems,
+ startIndex: context.initialIndex
+ )
}
}
diff --git a/suixinkan/UI/TravelAlbum/WiredTransferSettingChipButton.swift b/suixinkan/UI/TravelAlbum/WiredTransferSettingChipButton.swift
index d4b40d4..bcacb45 100644
--- a/suixinkan/UI/TravelAlbum/WiredTransferSettingChipButton.swift
+++ b/suixinkan/UI/TravelAlbum/WiredTransferSettingChipButton.swift
@@ -21,6 +21,11 @@ final class WiredTransferSettingChipButton: UIButton {
/// 当前展示的标题文案。
private(set) var titleText: String = ""
+ /// 当前值的展示颜色,供状态测试确认不可交互时仍保持清晰。
+ var displayedTitleColor: UIColor? {
+ titleLabelView.textColor
+ }
+
/// 创建设置 chip 按钮。
/// - Parameter showsChevron: 是否展示下拉箭头,默认不展示。
init(showsChevron: Bool = false) {
@@ -49,13 +54,14 @@ final class WiredTransferSettingChipButton: UIButton {
layer.borderColor = AppColor.border.cgColor
titleLabelView.font = .systemFont(ofSize: 11)
- titleLabelView.textColor = AppColor.textTertiary
+ // 当前设置值即使不可点击也代表已经生效,使用主文字色避免呈现为禁用状态。
+ titleLabelView.textColor = AppColor.textPrimary
titleLabelView.lineBreakMode = .byTruncatingTail
titleLabelView.isUserInteractionEnabled = false
chevronView.image = UIImage(systemName: "chevron.down")?
.withConfiguration(UIImage.SymbolConfiguration(pointSize: 13, weight: .semibold))
- chevronView.tintColor = AppColor.textTertiary
+ chevronView.tintColor = AppColor.textSecondary
chevronView.contentMode = .scaleAspectFit
chevronView.isUserInteractionEnabled = false
diff --git a/suixinkanTests/TravelAlbumViewModelTests.swift b/suixinkanTests/TravelAlbumViewModelTests.swift
index 80dd3f4..919acc4 100644
--- a/suixinkanTests/TravelAlbumViewModelTests.swift
+++ b/suixinkanTests/TravelAlbumViewModelTests.swift
@@ -4,6 +4,7 @@
//
import XCTest
+import UIKit
@testable import suixinkan
@MainActor
@@ -75,6 +76,208 @@ final class TravelAlbumEntryViewModelTests: XCTestCase {
@MainActor
/// 旅拍相册详情 ViewModel 测试。
final class TravelAlbumDetailViewModelTests: XCTestCase {
+ func testAIEditResultDisablesDuringProcessingAndOverwritesPreviousResult() {
+ var state = TravelAlbumAIEditResultState()
+
+ XCTAssertTrue(state.canStartEditing)
+ XCTAssertFalse(state.hasEditedResult)
+ XCTAssertEqual(state.thumbnailStatus, .none)
+
+ state.startProcessing()
+ XCTAssertFalse(state.canStartEditing)
+ XCTAssertEqual(state.thumbnailStatus, .processing)
+ state.complete(presetID: "portrait")
+ XCTAssertTrue(state.canStartEditing)
+ XCTAssertEqual(state.appliedPresetID, "portrait")
+ XCTAssertEqual(state.displayMode, .edited)
+ XCTAssertEqual(state.thumbnailStatus, .edited)
+
+ state.selectDisplayMode(.original)
+ XCTAssertEqual(state.displayMode, .original)
+ state.startProcessing()
+ XCTAssertEqual(state.appliedPresetID, "portrait")
+ state.complete(presetID: "mist")
+ XCTAssertEqual(state.appliedPresetID, "mist")
+ XCTAssertEqual(state.displayMode, .edited)
+ }
+
+ func testAIEditFailureShowsFailureStatusAndKeepsPreviousResult() {
+ var state = TravelAlbumAIEditResultState()
+ state.complete(presetID: "portrait")
+
+ state.startProcessing()
+ state.failProcessing()
+
+ XCTAssertTrue(state.canStartEditing)
+ XCTAssertTrue(state.hasEditedResult)
+ XCTAssertEqual(state.appliedPresetID, "portrait")
+ XCTAssertEqual(state.thumbnailStatus, .failed)
+
+ state.startProcessing()
+ XCTAssertEqual(state.thumbnailStatus, .processing)
+ }
+
+ func testRestoreOriginalClearsEditedThumbnailStatus() {
+ var state = TravelAlbumAIEditResultState()
+ state.complete(presetID: "portrait")
+
+ state.startProcessing()
+ state.restoreOriginal()
+
+ XCTAssertTrue(state.canStartEditing)
+ XCTAssertFalse(state.hasEditedResult)
+ XCTAssertEqual(state.displayMode, .original)
+ XCTAssertEqual(state.thumbnailStatus, .none)
+ }
+
+ func testCoverResultUsesDistinctThumbnailStatusAndCanBeOverwritten() {
+ var state = TravelAlbumAIEditResultState()
+
+ state.complete(presetID: "portrait", coverTemplateID: "travel_memoir")
+ XCTAssertTrue(state.isCover)
+ XCTAssertEqual(state.appliedCoverTemplateID, "travel_memoir")
+ XCTAssertEqual(state.displayMode, .cover)
+ XCTAssertEqual(state.thumbnailStatus, .cover)
+
+ state.complete(presetID: "mist")
+ XCTAssertFalse(state.isCover)
+ XCTAssertNil(state.appliedCoverTemplateID)
+ XCTAssertEqual(state.thumbnailStatus, .edited)
+ }
+
+ func testAtmosphereResultCanBeSelectedAndIsClearedByNextSingleResult() {
+ var state = TravelAlbumAIEditResultState()
+
+ state.complete(presetID: "portrait", atmosphereOptionID: "atmosphere")
+ XCTAssertTrue(state.hasAtmosphereResult)
+ state.selectDisplayMode(.atmosphere)
+ XCTAssertEqual(state.displayMode, .atmosphere)
+
+ state.complete(presetID: "mist")
+ XCTAssertFalse(state.hasAtmosphereResult)
+ XCTAssertNil(state.appliedAtmosphereOptionID)
+ state.selectDisplayMode(.atmosphere)
+ XCTAssertEqual(state.displayMode, .edited)
+ }
+
+ func testAIEditResultStoreSharesAndOverwritesLatestImage() {
+ let materialID = 990_071
+ let store = TravelAlbumAIEditResultStore.shared
+ store.remove(materialID: materialID)
+
+ store.startProcessing(materialIDs: [materialID])
+ XCTAssertFalse(store.state(for: materialID).canStartEditing)
+
+ let firstImage = UIImage()
+ store.complete(materialID: materialID, presetID: "portrait", editedImage: firstImage)
+ XCTAssertTrue(store.editedImage(for: materialID) === firstImage)
+
+ let replacementImage = UIImage()
+ let atmosphereImage = UIImage()
+ let coverImage = UIImage()
+ store.startProcessing(materialIDs: [materialID])
+ store.complete(
+ materialID: materialID,
+ presetID: "mist",
+ atmosphereOptionID: "atmosphere",
+ coverTemplateID: "travel_memoir",
+ editedImage: replacementImage,
+ atmosphereImage: atmosphereImage,
+ coverImage: coverImage
+ )
+ XCTAssertEqual(store.state(for: materialID).appliedPresetID, "mist")
+ XCTAssertTrue(store.editedImage(for: materialID) === replacementImage)
+ XCTAssertTrue(store.atmosphereImage(for: materialID) === atmosphereImage)
+ XCTAssertTrue(store.coverImage(for: materialID) === coverImage)
+ store.remove(materialID: materialID)
+ }
+
+ func testSelectionToolbarReplacesUploadWithAIEditAndDelete() {
+ let normalState = TravelAlbumSelectionToolbarState(isSelectionMode: false, selectedCount: 0)
+ XCTAssertTrue(normalState.showsUpload)
+ XCTAssertFalse(normalState.showsSelectionActions)
+
+ let emptySelectionState = TravelAlbumSelectionToolbarState(isSelectionMode: true, selectedCount: 0)
+ XCTAssertFalse(emptySelectionState.showsUpload)
+ XCTAssertTrue(emptySelectionState.showsSelectionActions)
+ XCTAssertFalse(emptySelectionState.selectionActionsEnabled)
+
+ let selectedState = TravelAlbumSelectionToolbarState(isSelectionMode: true, selectedCount: 2)
+ XCTAssertTrue(selectedState.showsSelectionActions)
+ XCTAssertTrue(selectedState.selectionActionsEnabled)
+ }
+
+ func testPhotoPreviewCanSwipeAndKeepsValidIndexAfterDelete() {
+ let materials = [
+ TravelAlbumMaterial(id: 1, fileName: "A.JPG"),
+ TravelAlbumMaterial(id: 2, fileName: "B.JPG"),
+ TravelAlbumMaterial(id: 3, fileName: "C.JPG"),
+ ]
+ var state = TravelAlbumPhotoPreviewState(materials: materials, initialIndex: 1)
+
+ XCTAssertEqual(state.currentMaterial?.id, 2)
+ XCTAssertTrue(state.moveNext())
+ XCTAssertEqual(state.currentMaterial?.id, 3)
+ XCTAssertFalse(state.moveNext())
+ XCTAssertTrue(state.removeCurrent())
+ XCTAssertEqual(state.currentMaterial?.id, 2)
+ XCTAssertTrue(state.movePrevious())
+ XCTAssertEqual(state.currentMaterial?.id, 1)
+ }
+
+ func testAIEditPresetsContainScrollableChoices() {
+ let presets = TravelAlbumEditPreset.defaultOptions
+
+ XCTAssertEqual(presets.count, 8)
+ XCTAssertEqual(Set(presets.map(\.id)).count, presets.count)
+ XCTAssertEqual(presets.first?.effect, .original)
+ XCTAssertTrue(presets.contains { $0.effect == .mist })
+ }
+
+ func testAtmosphereOptionsContainScrollableSingleChoiceStyles() {
+ let options = TravelAlbumAtmosphereEditOption.defaultOptions
+
+ XCTAssertGreaterThanOrEqual(options.count, 4)
+ XCTAssertEqual(Set(options.map(\.id)).count, options.count)
+ XCTAssertTrue(options.allSatisfy { $0.effect != .original })
+ XCTAssertEqual(TravelAlbumAtmosphereEditOption.defaultOption, options.first)
+ }
+
+ func testAIEditSelectionCountsOptionalAtmosphereAndCoverStyles() {
+ let refinedPreset = TravelAlbumEditPreset.defaultOptions[1]
+ let baseSelection = TravelAlbumAIEditSelection(
+ refinedPreset: refinedPreset,
+ atmosphereOption: nil,
+ coverTemplate: nil
+ )
+ XCTAssertEqual(baseSelection.resultCountPerPhoto, 1)
+ XCTAssertEqual(baseSelection.selectedStyleCount, 1)
+
+ let fullSelection = TravelAlbumAIEditSelection(
+ refinedPreset: refinedPreset,
+ atmosphereOption: .defaultOption,
+ coverTemplate: TravelAlbumCoverTemplate.defaultOptions[0]
+ )
+ XCTAssertEqual(fullSelection.resultCountPerPhoto, 2)
+ XCTAssertEqual(fullSelection.selectedStyleCount, 3)
+ }
+
+ func testCoverTemplatesAppearFromFourSelectedPhotos() {
+ XCTAssertFalse(TravelAlbumCoverTemplate.shouldShow(for: 3))
+ XCTAssertTrue(TravelAlbumCoverTemplate.shouldShow(for: 4))
+ XCTAssertTrue(TravelAlbumCoverTemplate.shouldShow(for: 8))
+ }
+
+ func testCoverTemplatesAreFreeAndReadyForFutureQuotaPricing() {
+ let templates = TravelAlbumCoverTemplate.defaultOptions
+
+ XCTAssertEqual(templates.count, 4)
+ XCTAssertEqual(Set(templates.map(\.id)).count, templates.count)
+ XCTAssertEqual(templates.first?.id, "travel_memoir")
+ XCTAssertTrue(templates.allSatisfy(\.isFreeGift))
+ XCTAssertTrue(templates.allSatisfy { $0.quotaCost == 0 })
+ }
+
func testRefreshLoadsInfoAndMaterials() async {
let api = TravelAlbumMockAPI()
api.infoResponse = TravelAlbum(id: 2, name: "详情")
@@ -106,6 +309,53 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
XCTAssertEqual(api.materialRequests.last?.orderBy, 4)
}
+ func testPurchasedTabUsesLocalDemoMaterialsWhenServerIsEmpty() async {
+ let api = TravelAlbumMockAPI()
+ api.materialListResponses = [TravelAlbumListResponse(total: 0, list: [])]
+ let viewModel = TravelAlbumDetailViewModel(albumId: 2)
+
+ await viewModel.selectTab(.purchased, api: api)
+
+ XCTAssertTrue(viewModel.isUsingPurchasedDemoData)
+ XCTAssertEqual(viewModel.materials.count, 4)
+ XCTAssertTrue(viewModel.materials.allSatisfy(\.isPurchased))
+ XCTAssertTrue(viewModel.materials.allSatisfy { $0.id < 0 })
+ XCTAssertTrue(viewModel.materials.allSatisfy { $0.bundledImageAssetName != nil })
+ }
+
+ func testPurchasedTabKeepsRealServerMaterialsWhenAvailable() async {
+ let api = TravelAlbumMockAPI()
+ let serverMaterial = TravelAlbumMaterial(
+ id: 88,
+ fileName: "SERVER_0088.JPG",
+ fileUrl: "https://cdn.example.com/server-88.jpg",
+ isPurchased: true
+ )
+ api.materialListResponses = [
+ TravelAlbumListResponse(total: 1, list: [serverMaterial]),
+ ]
+ let viewModel = TravelAlbumDetailViewModel(albumId: 2)
+
+ await viewModel.selectTab(.purchased, api: api)
+
+ XCTAssertFalse(viewModel.isUsingPurchasedDemoData)
+ XCTAssertEqual(viewModel.materials, [serverMaterial])
+ }
+
+ func testPurchasedDemoMaterialsFollowCurrentSortOption() async {
+ let api = TravelAlbumMockAPI()
+ api.materialListResponses = [
+ TravelAlbumListResponse(total: 0, list: []),
+ TravelAlbumListResponse(total: 0, list: []),
+ ]
+ let viewModel = TravelAlbumDetailViewModel(albumId: 2)
+
+ await viewModel.selectTab(.purchased, api: api)
+ await viewModel.setSortOption(.fileNameAsc, api: api)
+
+ XCTAssertEqual(viewModel.materials.map(\.fileName), viewModel.materials.map(\.fileName).sorted())
+ }
+
func testOnlyUnpurchasedMaterialCanBeSelected() {
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
viewModel.toggleSelectionMode()
@@ -170,6 +420,29 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertEqual(secondViewModel.transferModeOption, "拍后传输")
}
+ func testInitialTransferModeOverridesPersistedModeAndBecomesLatestSelection() {
+ let defaults = makeOTGTestDefaults()
+ defaults.set(TravelAlbumOTGTransferMode.liveUpload.rawValue, forKey: "travelAlbum.otg.transferMode")
+ let firstViewModel = WiredCameraTransferViewModel(
+ albumId: 9,
+ albumTitle: "相册",
+ headerPhone: "13800138000",
+ initialTransferMode: .postTransfer,
+ userDefaults: defaults
+ )
+
+ XCTAssertEqual(firstViewModel.transferMode, .postTransfer)
+ XCTAssertEqual(firstViewModel.transferModeOption, "拍后传输")
+
+ let secondViewModel = WiredCameraTransferViewModel(
+ albumId: 18,
+ albumTitle: "另一个相册",
+ headerPhone: "13800138000",
+ userDefaults: defaults
+ )
+ XCTAssertEqual(secondViewModel.transferMode, .postTransfer)
+ }
+
func testConnectedStatusIncludesCameraModelName() {
let context = makeOTGTestContext()
let viewModel = makeWiredViewModel(
@@ -210,6 +483,31 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertEqual(groups.first?.slots.last?.photoCount, 2)
}
+ func testPhotoPreviewContextPreservesListOrderAndStartsAtTappedPhoto() {
+ let photos = [
+ makeOTGPhotoItem(
+ id: "a",
+ capturedAt: "2026-07-08 12:30:00",
+ thumbnailURL: URL(fileURLWithPath: "/tmp/a.jpg")
+ ),
+ makeOTGPhotoItem(
+ id: "unavailable",
+ capturedAt: "2026-07-08 12:20:00"
+ ),
+ makeOTGPhotoItem(
+ id: "c",
+ capturedAt: "2026-07-08 12:10:00",
+ thumbnailURL: URL(string: "https://cdn.example.com/c.jpg")
+ ),
+ ]
+
+ let context = photos.previewContext(startingWith: "c")
+
+ XCTAssertEqual(context?.photos.map(\.id), ["a", "c"])
+ XCTAssertEqual(context?.initialIndex, 1)
+ XCTAssertNil(photos.previewContext(startingWith: "unavailable"))
+ }
+
func testSelectedTimeSlotFallsBackWhenTabChanges() throws {
let context = makeOTGTestContext()
context.store.save([
@@ -741,13 +1039,14 @@ private extension TravelAlbumOTGPhotoRecord {
private func makeOTGPhotoItem(
id: String,
capturedAt: String,
- status: TravelAlbumOTGUploadStatus = .pending
+ status: TravelAlbumOTGUploadStatus = .pending,
+ thumbnailURL: URL? = nil
) -> TravelAlbumOTGPhotoItem {
TravelAlbumOTGPhotoItem(
id: id,
sourceId: id,
fileName: "\(id).JPG",
- thumbnailURL: nil,
+ thumbnailURL: thumbnailURL,
capturedAt: capturedAt,
fileSizeText: "1 KB",
fileSizeBytes: 1024,
diff --git a/suixinkanTests/WiredTransferSettingChipButtonTests.swift b/suixinkanTests/WiredTransferSettingChipButtonTests.swift
index e4ed1c0..8715aa4 100644
--- a/suixinkanTests/WiredTransferSettingChipButtonTests.swift
+++ b/suixinkanTests/WiredTransferSettingChipButtonTests.swift
@@ -15,6 +15,15 @@ final class WiredTransferSettingChipButtonTests: XCTestCase {
XCTAssertEqual(button.accessibilityLabel, "AI修图")
}
+ @MainActor
+ func testAppliedValueKeepsPrimaryTextColorWhenInteractionIsDisabled() {
+ let button = WiredTransferSettingChipButton()
+ button.apply(title: "JPG")
+ button.isUserInteractionEnabled = false
+
+ XCTAssertEqual(button.displayedTitleColor, AppColor.textPrimary)
+ }
+
@MainActor
func testShowsChevronDefaultsToFalse() {
let button = WiredTransferSettingChipButton()