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 @@ + + + + + + + + + 照片预览与左右切换 + + 相册管理缩略图 + 点击任意照片 + + + 单一返回按钮 + 文件信息 + 文件名 / 文件大小 / 当前照片 + 底部:AI修图、删除、刷新 + + + 左右滑动 + 切换上一张 / 下一张 + 首尾不循环 + + + 同步刷新信息 + 名称、大小、结果状态 + + 删除当前照片 + 删除后定位到相邻照片 + 最后一张删除后返回相册管理 + + 刷新当前照片 + 重新加载原图或最新修图结果 + 加载失败保留当前可见内容 + + 预览底部操作 + 保留 AI修图、删除、刷新 + 不展示打印按钮 + 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 @@ + + + + + + + + + 相册管理多选操作栏 + + 普通浏览状态 + 底部显示“上传照片” + + + 点击右上角选择 + 缩略图出现单选圆圈 + + + 进入多选状态 + 上传按钮隐藏 + 显示 AI修图、删除 + + + 选择至少 1 张 + 两个操作按钮恢复可用 + + 点击 AI修图 + 进入批量 AI 修图弹窗 + 包含处理中照片时按钮置灰 + + 点击删除 + 二次确认后删除选中素材 + 取消确认则保持原选择 + + + 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 @@ + + + + + + + + + AI 修图弹窗:原图精修、氛围感与结果组合 + + 打开 AI修图 + 显示已选照片数量 + 内容区域可纵向滚动 + + + 原图精修 + 预设文字 + 对应效果图 + 横向滚动,始终单选 + 生成基础“精修后”结果 + + + 氛围感修图(选填) + 横向滚动,多样式单选 + 默认不选,重复点击可取消 + 额外生成独立氛围感结果 + + + 只读额度 + 显示剩余 9 张 + 不提供勾选操作 + + + 点击确定 + 提交样式组合 + 开始处理 + + 点击取消 / 下拉关闭 + 不启动任务 + 不改变原图、旧结果或额度状态 + + 效果图预览 + 各样式卡片显示“预览”按钮 + 全屏查看,不改变当前选中项 + + 结果独立保存 + 原图 / 精修后 / 氛围感分别查看 + 再次确认后以新组合覆盖旧结果 + 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 @@ + + + + + + + + + AI 修图任务生命周期与覆盖规则 + + 可发起 + 未修图或已有旧结果 + AI修图按钮可点击 + + + 修图中 + 按钮置灰,禁止重复提交 + 离开页面后任务继续完成 + + + + 成功 + 分别保存精修与氛围感结果 + 默认展示“精修后” + + 失败 + 显示“修图失败” + 恢复 AI修图按钮,可重试 + + + 再次修图 + 新组合完成后 + 整组覆盖旧结果 + + + + 还原为原图 + 清除精修、氛围感、封面及失败状态 + 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 @@ + + + + + + + + + 多图三类风格选择与封面生成 + + 统计选中照片数 + 进入批量 AI 修图前判断 + 阈值以当前需求为准 + + + 数量是否 ≥ 4 张? + 否:显示精修 + 氛围感 + 是:再显示封面模板 + 弹窗自动使用更高高度 + + + 显示三个风格模块 + 原图精修:单选基础预设 + 氛围感:横向单选,可取消 + 封面:横向单选,可全屏预览 + + + 确认并生成 + 每张保存独立结果 + 第一张另作封面 + + 当前免费阶段 + 封面 quotaCost = 0,不减少额度 + 弹窗显示“赠送 · 不占精修张数” + + 未来收费阶段 + 服务端将 quotaCost 配置为正数 + 确认前展示实际消耗,成功后再扣减 + + 重复选择封面模板 + 新封面效果覆盖旧封面效果 + 失败时不把结果标记为已生成 + 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 @@ + + + + + + + + + 相册缩略图结果类型与状态优先级 + + + 已上传 + 普通原图 + 显示服务端缩略图 + + + + 修图中 + 处理中 + AI入口同步置灰 + + + + AI已修 + 普通修图结果 + 缩略图展示最新结果 + + + + AI封面 + 封面结果 + 显示封面模板排版图 + + + + 修图失败 + 失败结果 + 可再次修图 + + 状态显示优先级 + 修图中 > 修图失败 > AI封面 > AI已修 > 已上传。高优先级状态先展示,避免处理中或失败被旧成功结果遮盖。 + 成功:缩略图切换为最新修图图或封面图;再次成功:覆盖旧结果;还原原图:回到“已上传”;删除素材:同步清除本地结果。 + 当前 AI 修图、封面结果及角标为本地演示数据,App 进程重启后会清空;服务端接入后应返回结果类型、状态、结果图地址和模板 ID。 + 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 修图、删除、刷新三个入口视觉一致。 + +### 状态流转图 + +![照片预览与左右切换流程](./assets/travel-album-ai/01-photo-preview-flow.svg) + +### 本功能验收 + +- [ ] 从任意缩略图进入时只显示一个返回入口。 +- [ ] 文件名和文件大小与当前照片一致。 +- [ ] 导航头部保持黑色沉浸样式,右侧张数随照片切换同步变化。 +- [ ] 左右滑动后照片和文件信息同步变化。 +- [ ] 首尾照片不会越界或循环。 +- [ ] 底部仅显示 AI修图、删除、刷新,不显示打印。 +- [ ] 删除当前照片后能正确定位或退出预览。 + +## 2. 上传照片与传输模式选择 + +### 功能说明 + +相册管理处于普通状态时,用户点击底部“上传照片”,先从底部弹出“选择传输模式”,展示“边拍边传”和“拍后传输”两个选项。点击任一选项后弹窗关闭,并进入当前相册的有线传输页面;点击“取消”或向下关闭弹窗时停留在相册管理,不改变现有照片和选择状态。 + +| 传输模式 | 弹窗说明 | 下一页初始状态 | 后续行为 | +| --- | --- | --- | --- | +| 边拍边传 | 相机拍摄后,照片自动传输并上传到当前相册 | 传输模式显示“边拍边传” | 新传入 App 的照片自动上传 | +| 拍后传输 | 拍摄完成后,再选择相机照片批量传输 | 传输模式显示“拍后传输” | 新照片先保留,用户后续选择并上传 | + +本次弹窗选择优先于本机上一次保存的传输模式,并同步成为新的最近使用模式,确保下一页展示和实际传输行为一致。相册 ID、相册名称、联系电话及照片数据继续沿用相册管理页面的真实数据,不创建新的相册或假照片。 + +进入有线传输页面后,非批量选择状态下点击任意照片缩略图,预览页会使用当前 Tab 筛选后的照片顺序,并从被点击照片开始展示。用户可左右滑动查看上一张或下一张,顶部页码随照片同步变化;没有可用预览地址的照片不进入预览序列,也不占用页码。第一张继续向右滑、最后一张继续向左滑时均停留在边界,不循环跳转。处于批量上传选择状态时,点击照片仍执行选择或取消选择,不打开预览。 + +### 状态流转图 + +![上传照片与传输模式选择流程](./assets/travel-album-ai/02-upload-transfer-mode-flow.svg) + +![有线传输缩略图预览流程](./assets/travel-album-ai/03-wired-transfer-preview-flow.svg) + +### 本功能验收 + +- [ ] 点击“上传照片”后先显示底部传输模式弹窗,不直接进入下一页。 +- [ ] 弹窗同时显示“边拍边传”和“拍后传输”及对应说明。 +- [ ] 选择“边拍边传”后,下一页模式显示为“边拍边传”。 +- [ ] 选择“拍后传输”后,下一页模式显示为“拍后传输”。 +- [ ] 本次选择不会被上一次保存的模式覆盖。 +- [ ] 取消或关闭弹窗后仍停留在相册管理。 +- [ ] 非批量选择状态点击缩略图后,从被点击照片进入全屏预览。 +- [ ] 预览可左右滑动查看当前 Tab 中的上一张和下一张,页码同步变化且首尾不循环。 +- [ ] 批量选择状态点击照片只改变选择状态,不打开预览。 + +## 3. 相册管理多选与底部操作栏切换 + +### 功能说明 + +相册管理页普通状态下,底部显示“上传照片”。用户点击右上角选择按钮后进入多选状态,每张缩略图右上角显示选择圆圈,底部“上传照片”隐藏,替换为“AI修图”和“删除”。 + +| 多选状态 | 页面表现 | 可用操作 | 下一状态 | +| --- | --- | --- | --- | +| 未进入多选 | 不显示选择圆圈,底部显示上传照片 | 点击照片进入预览;点击右上角选择 | 进入多选 | +| 已进入但未选照片 | 显示未选圆圈,AI修图和删除置灰 | 选择照片;再次点击右上角选择退出 | 已选择或退出 | +| 已选择照片 | 选中照片显示勾选,AI修图和删除可用 | 发起批量修图或批量删除 | 弹窗、处理中或删除结果 | +| 选中项含修图中的照片 | 保留当前选择,AI修图置灰 | 等待任务完成;删除仍按现有删除规则处理 | 任务结束后恢复 | +| 退出多选 | 清空本次选择,恢复上传照片 | 上传或进入照片预览 | 普通状态 | + +批量删除必须二次确认;用户取消时不删除任何照片并保留当前选择。批量 AI 修图复用与单张预览相同的预设、任务状态和结果覆盖逻辑。 + +### 状态流转图 + +![相册管理多选操作栏流程](./assets/travel-album-ai/02-multi-select-flow.svg) + +### 本功能验收 + +- [ ] 进入多选后上传按钮消失,AI修图和删除出现。 +- [ ] 未选择照片时两个批量按钮不可点击。 +- [ ] 选择至少一张后两个批量按钮恢复可用。 +- [ ] 包含修图中照片时不能再次发起 AI 修图。 +- [ ] 退出多选后选择状态清空并恢复上传按钮。 + +## 4. AI 修图弹窗、原图精修、氛围感与按张收费模式 + +### 功能说明 + +用户从单张预览或相册多选操作栏点击“AI修图”后,以同一个底部弹窗展示 AI 修图配置。标题与弹窗顶部保留足够间距,标题下方展示“已选择 N 张照片”。弹窗内容可纵向滚动,取消和确定按钮固定在底部,照片较多并出现封面模块时也不会遮挡选择项。 + +“原图精修”是基础修图模块,使用横向滚动单选列表,每个预设同时展示名称和对应效果图: + +| 预设 | 当前效果用途 | +| --- | --- | +| 还原为原图 | 清除该照片已有修图结果和封面类型 | +| 写真-简约肖像 | 低饱和、提亮的人像效果 | +| 写真-清冷古风 | 清冷复古效果 | +| 旅拍-锦绣 | 提升饱和度和对比度 | +| 旅拍-远山 | 低饱和、柔和远景效果 | +| 旅拍-薄雾 | 提亮并降低对比度 | +| 油画-夏日 | 偏暖夏日效果 | +| 油画-浓郁 | 高饱和、高对比效果 | + +原图精修默认选中“还原为原图”,用户点击其他预设后仅保留一个选中项。没有可用原图时,效果图位置显示稳定占位图,不影响弹窗选择。每张样式效果图右下角都有“预览”按钮,点击后全屏查看当前照片应用该样式的效果,不改变当前选中项。 + +“氛围感修图”位于原图精修下方,并明确标记为“选填”。该模块与原图精修保持一致,使用横向滚动效果图单选,当前本地演示包含日落暖阳、复古胶片、清透蓝调、森系薄雾、浓郁故事五种样式。默认不选中;点击一种样式后仅保留该项,再次点击当前样式即可取消。每个样式同样提供“预览”按钮。选中后不是把氛围效果叠加并合并到精修图中,而是为每张照片额外生成一张可独立查看的“氛围感”结果。 + +原图精修、氛围感、封面模板和“查看高清图”统一进入同一个沉浸式大图预览页。大图页不再套用系统导航栏,只显示一个返回按钮、当前样式名称和“双指缩放查看细节”提示;关闭后回到原弹窗或照片页,并保留关闭前的选择状态。 + +弹窗内的 Tips 会随当前组合实时变化: + +| 当前选择 | 确认后的结果 | +| --- | --- | +| 仅原图精修 | 每张照片生成 1 个“精修后”结果 | +| 原图精修 + 氛围感修图 | 每张照片生成“精修后 + 氛围感”2 个独立结果 | +| 有封面、未选氛围感 | 每张照片生成精修结果,第一张选中照片额外生成封面 | +| 有封面、已选氛围感 | 每张照片生成精修和氛围感 2 个结果,第一张选中照片再额外生成封面 | + +“修图模式”区域只读展示“AI精修 · 按张收费”和“剩余 9 张”,不再显示勾选圆圈,也不能点击选择。当前剩余数量为本地演示数据,不会真实扣减额度;未来接入服务端时由接口返回剩余额度,并以任务创建成功或服务端明确返回的扣减结果为准。 + +点击“确定”提交当前原图精修、氛围感和封面组合并关闭弹窗;点击“取消”或关闭弹窗时不创建任务、不改变旧修图结果,也不改变额度。 + +### 状态流转图 + +![AI 修图弹窗流程](./assets/travel-album-ai/03-ai-edit-sheet-flow.svg) + +### 本功能验收 + +- [ ] 标题与弹窗顶部间距自然,不贴近顶部。 +- [ ] 已选照片数量准确。 +- [ ] 原图精修预设同时展示文字和效果图,并可横向滚动、保持单选。 +- [ ] 原图精修、氛围感和封面模板效果图均可通过小按钮全屏预览,预览不改变选择。 +- [ ] 所有大图预览使用同一黑色头部、单一返回入口,并支持双指缩放。 +- [ ] 氛围感修图横向展示多个效果图,默认不选中、保持单选,可再次点击当前样式取消。 +- [ ] 同时选择原图精修和氛围感时,每张照片生成两个独立结果。 +- [ ] Tips 能根据氛围感和封面选择动态说明实际输出结果。 +- [ ] 修图模式只展示按张收费与剩余张数,不提供选择操作。 +- [ ] 取消弹窗不会创建任务或覆盖旧结果。 + +## 5. AI 修图任务状态、失败重试与结果覆盖 + +### 功能说明 + +用户确认修图后,所选照片立即进入“修图中”。任务未结束前,相关 AI 修图入口置灰;即使用户离开照片预览页,本地演示任务仍会继续完成,并通过共享结果状态刷新相册缩略图。 + +| 任务状态 | 进入条件 | 页面表现 | 可用操作 | 后续状态 | +| --- | --- | --- | --- | --- | +| 可发起 | 未修图、已有成功结果或上次任务已结束 | AI修图按钮可用 | 选择预设并确认 | 修图中 | +| 修图中 | 已提交预设,等待结果 | 按钮置灰;缩略图显示“修图中” | 查看其他照片、离开页面 | 成功或失败 | +| 成功 | 获取原图并完成本地效果处理 | 分别保存精修、氛围感和封面结果;默认展示“精修后”;按钮恢复 | 在已有结果间切换、再次修图、还原原图 | 新任务或已上传 | +| 失败 | 原图无法获取或处理失败 | 缩略图显示“修图失败”;按钮恢复 | 再次修图 | 修图中 | + +成功后,照片预览页至少出现“原图 / 精修后”切换;本次选择了氛围感时增加“氛围感”,存在封面结果时增加“封面”。各结果互相独立,不把两个效果合成同一张图;有修图结果时还可以点击“查看高清图”。再次修图时,旧结果在新任务处理期间继续保留,新任务成功后整组替换为本次最新组合:本次未选择氛围感时会清除旧氛围感结果;新任务失败时保留旧成功图片,但状态优先显示“修图失败”,提醒用户本次任务未成功。 + +选择“还原为原图”会同时清除修图结果、失败状态和封面类型,缩略图恢复为“已上传”。删除照片时同步清理该照片的本地修图记录,避免已删除照片的结果残留。 + +### 状态流转图 + +![AI 修图任务状态流转](./assets/travel-album-ai/04-ai-task-state-flow.svg) + +### 本功能验收 + +- [ ] 修图中不能重复点击 AI修图。 +- [ ] 离开预览页后任务仍能结束并刷新缩略图。 +- [ ] 成功后可切换查看原图、精修后以及本次实际生成的氛围感或封面。 +- [ ] 同选原图精修和氛围感时两个结果均可独立查看。 +- [ ] 再次修图成功后以本次结果组合覆盖旧结果。 +- [ ] 失败后按钮恢复并允许重试。 +- [ ] 还原原图后所有本地修图类型和结果被清除。 + +## 6. 选中 4 张及以上时的封面模板选择 + +### 功能说明 + +从相册管理多选照片并点击“AI修图”时,系统根据选中照片数量决定是否展示“封面风格模板”: + +| 选中数量 | 弹窗表现 | +| --- | --- | +| 1~3 张 | 展示“原图精修 + 氛围感修图(选填)”两个模块,不展示封面模板 | +| ≥ 4 张 | 展示“原图精修 + 氛围感修图(选填)+ 封面模板”三个模块,并提高弹窗高度 | + +封面模板为横向滚动单选,默认选中“旅行纪念册”。每个模板效果图右下角提供“预览”按钮,点击后全屏查看完整排版,不改变当前模板选择。当前提供: + +- 旅行纪念册 +- 简约留白 +- 景区故事 +- 电影胶片 + +每个模板展示名称和以第一张选中照片生成的排版效果图。用户确认后,所有选中照片先生成原图精修结果;选择了氛围感时,所有照片再各自生成一个氛围感结果;第一张选中照片另外生成独立封面并作为封面承载图。封面不会替代精修或氛围感结果,用户进入预览后可分别切换查看。 + +当前所有封面模板 `quotaCost = 0`,弹窗显示“赠送 · 不占精修张数”,不会减少剩余修图额度。后续收费时保留同一选择流程,由服务端为模板返回大于 0 的 `quotaCost`,客户端在确认前显示实际消耗,并以服务端成功结果为准扣减;收费能力目前尚未实现。 + +再次对不少于 4 张照片选择新封面模板并修图时,新封面效果覆盖旧封面效果。封面承载图处理失败时不标记为封面成功,缩略图显示“修图失败”并允许重试。选择“还原为原图”时清除封面效果和封面类型。 + +### 状态流转图 + +![多图封面模板选择流程](./assets/travel-album-ai/05-cover-template-flow.svg) + +### 本功能验收 + +- [ ] 选中 3 张时不显示封面模板。 +- [ ] 选中 4 张或更多时显示封面模板。 +- [ ] 有封面模块时,弹窗同时完整显示原图精修、氛围感和封面三个风格模块。 +- [ ] 封面模板可横向滚动且始终单选。 +- [ ] 每个封面模板均可独立预览,关闭预览后保留原选择。 +- [ ] 默认选中旅行纪念册。 +- [ ] 当前显示赠送并且不消耗精修额度。 +- [ ] 成功后第一张选中照片成为封面承载图。 +- [ ] 再次生成封面时以新结果覆盖旧结果。 + +## 7. 相册缩略图结果类型与显示优先级 + +### 功能说明 + +相册管理缩略图左上角复用同一状态位,根据照片当前结果显示不同类型,让用户无需进入预览即可区分未修、处理中、已修、封面和失败照片。 + +| 缩略图状态 | 角标文案与颜色 | 缩略图内容 | 进入条件 | 可用操作 | +| --- | --- | --- | --- | --- | +| 普通原图 | 绿色“已上传” | 服务端原图或缩略图 | 没有本地修图结果 | 预览、选择、AI修图、删除 | +| 处理中 | 蓝色“修图中” | 有旧成功结果时继续显示旧结果,否则显示原图 | AI 任务未结束 | 预览、删除;不能再次修图 | +| 普通修图结果 | 紫色“AI已修” | 最新精修图片 | 原图精修成功,可同时存在氛围感结果 | 预览原图/精修后/氛围感、再次修图、还原 | +| 封面结果 | 橙色“AI封面” | 所选封面模板排版图 | 封面承载图生成成功 | 预览、再次修图、重新生成封面、还原 | +| 修图失败 | 红色“修图失败” | 有旧成功结果时保留旧图,否则显示原图 | 最近一次任务失败 | 再次修图、删除 | + +状态显示优先级为: + +`修图中 > 修图失败 > AI封面 > AI已修 > 已上传` + +该优先级保证处理中或失败状态不会被历史成功结果遮盖。普通修图成功后显示“AI已修”;封面模板成功后,第一张选中照片显示封面排版图和“AI封面”,其他照片继续显示“AI已修”。再次修图成功后缩略图切换为最新结果;还原原图后回到“已上传”;删除照片后对应本地状态一起删除。 + +当前状态和结果由内存数据源在相册管理与照片预览之间共享,尚不支持 App 重启后的恢复。服务端接入时至少需要返回素材 ID、任务状态、结果类型、结果图地址、原图精修预设 ID、是否生成氛围感、封面模板 ID、失败原因和额度消耗,客户端才能恢复完全一致的缩略图状态。 + +### 状态流转图 + +![相册缩略图结果状态](./assets/travel-album-ai/06-thumbnail-state-flow.svg) + +### 本功能验收 + +- [ ] 未修图照片显示“已上传”。 +- [ ] 修图中照片显示“修图中”且不能重复提交。 +- [ ] 普通成功结果显示“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()