docs(tasks): add T-566~T-569 cover gallery tasks (archive, gallery, regen, nav)

封面历史对比挑选功能拆四任务:T-566 数据层(重置归档改名、
update_generated_cover 状态语义、候选枚举排序、维持批次软删);
T-567 画廊查看+单选保存+原图弹窗;T-568 单任务重生+并发守卫
(含 fresh task 防跳过);T-569 上下条切换+未保存三态拦截。
含 Codex 评审修正与 title/cover committed 处理不对称的设计说明。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chengma
2026-07-09 11:13:21 +08:00
co-authored by Claude Opus 4.8
parent a57254da9e
commit fa27134f7c
4 changed files with 316 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
---
id: T-566
title: 封面历史归档数据层:重置改名保留旧图 + update_generated_cover + 目录枚举
phase: 7
deps: [T-564]
status: TODO
created: 2026-07-09
---
## 问题 / 背景
运营需要对同一商品多次 AI 生图的结果做**对比挑选**(哪张更适合商品)。当前重置/重生封面时旧图被直接覆盖或删除(`reset_generated(delete_file=True)` 走 `os.remove`),历史生成图无法保留,也没有把 DB 封面指针改到某张历史图的手段。
设计定案(grill 评审收口):
- **不把多条路径塞进 `tasks.new_cover_path`**(一列多值反模式:所有读方按单路径消费、分隔符无安全选择、DB 与磁盘两个真相源必漂移)。`new_cover_path` 永远只存**当前生效的一张**。
- **目录即数据库**:历史候选图只存在文件系统,靠文件名约定 `{task_id}_{item_id}_new_<时间戳>.jpg` 索引,弹窗打开时现场扫目录枚举。
- 归档钩子放在**重置**(运营主动、非时间敏感),不放在生图写入热路径(避免在已扣点的成功路径上引入 Windows 文件锁/TOCTOU 新失败点)。
本任务是数据层前置;GUI 画廊弹窗见 T-567。
## 方案(改哪个文件、改成什么)
### `app/db.py` — `reset_generated` 归档改名(替代删除)
- `reset_generated(..., reset_cover=True)` 时,若 `new_cover_path` 指向的文件存在:
- 改名为 `{原名去扩展}_{YYYYMMDDHHMMSS}{ext}`,如 `42_51513423876_new.jpg` → `42_51513423876_new_20260709094800.jpg`;
- **撞名兜底**:目标名已存在(同秒重置两次)则追加 `_2`/`_3`;
- **顺序**:先 `os.rename` 成功、再清 DB 指针;改名抛 `PermissionError`(Windows 文件被预览/资源管理器占用)→ **整体中止本次重置**,抛 `DbError`(提示"请先关闭正在查看的封面图片再重置"),不留中间态(先清指针后改名失败会导致下次生成覆盖旧图、归档丢失);
- 旧文件不存在 → 无事发生,照常清指针(幂等,不报错)。
- `delete_file=True` 的旧语义处理:归档取代删除(或保留 `delete_file` 为真删、新增 `archive_previous` 参数,默认对封面重置开启——具体参数形状实现时定,验收看行为)。
- 仅重置标题(`reset_cover=False`)不触碰任何图片文件(既有行为不回归)。
### `app/db.py` — 新增 `update_generated_cover(task_id, new_cover_path)`
- 校验任务存在、目标文件存在,目标路径必须是本地文件路径;供 T-567「保存」调用,把 `tasks.new_cover_path` 指向运营选中的那张(不复制、不改名文件)。
- 状态语义必须明确:
- 写入后设置 `stage='generated'`、`status='pending'`、`last_error=NULL`、`updated_at=<now>`,表示本地已有可用于③更新蝦皮的新封面;
- 不增加 `generate_attempts` / `apply_attempts`,因为这不是一次新的 AI 调用或线上提交;
- 不改 `committed`、`applied_at`、`apply_attempts`,保留“曾经提交过线上”的历史事实;
- 不改 `image_task_id` / `image_task_key`,它们只记录/续查 cmhub 异步生图任务,封面指针切换不应误清审计信息;真正重置封面仍由 `reset_generated(..., reset_cover=True)` 清空。
- 对已 `committed=1` 或 `stage='applied'` 的任务,DB 层允许写入并按上面规则退回 `generated/pending`,由 GUI 在保存前弹“本地换封面不会回滚蝦皮,重复更新会再次提交线上”确认。
- **有意的不对称,勿"顺手对齐"**:`update_generated_title`(`db.py:819`)对 committed/applied 任务在 DB 层直接拒绝,而本函数允许(退回 + GUI 警示)。这是设计决定:换封面重挑是运营核心场景、走「重置更新状态」同款警示口径(`apply.py:311/334`);改标题重提交没有此需求。后续不要参照 title 的守卫给本函数加拒绝逻辑,否则 committed 记录重挑封面功能会被改死。
### 批次软删除边界:不做归档图片清理
- 不修改 `delete_batch()` 的软删除语义,不删除 `<image_root>/<batch_id>/` 目录。
- 理由:T-206 已定“删除批次”为软删除,只隐藏本地批次,不物理删除任务与图片;把本任务改成删目录会造成用户误删历史 AI 图和旧封面备份。
- 归档图片清理如后续需要,另做独立“清理已软删除批次图片”工具,必须有明确二次确认和删除清单预览。
### `app/image_paths.py` — 新增画廊枚举函数
- `list_task_cover_candidates(image_root, task, account=None)`:扫 `<image_root>/<batch_id>/<slug>/` 下匹配 `{task_id}_{item_id}_new*.jpg` 的文件,返回绝对路径列表;只包含新封面候选,不含 `_old` 旧封面。
- 排序规则必须稳定、可测试:
- 最新优先;
- 规范名 `{task_id}_{item_id}_new.jpg` 视为“当前最新输出槽”,排在同任务候选最前;
- 归档名 `{task_id}_{item_id}_new_YYYYMMDDHHMMSS.jpg` 按时间戳倒序;
- 同秒撞名后缀 `_2` / `_3` 按数字倒序;
- 无法解析时间戳的匹配文件排在最后,并按文件名升序兜底。
- GUI 默认选中由 DB 当前 `new_cover_path` 决定,不依赖排序;排序只影响画廊展示顺序。
- 只读、不建表、不加列——文件系统是唯一真相源。
## 验收要点
- 重置封面且旧图存在 → 旧图改名为带 `YYYYMMDDHHMMSS` 后缀、原规范名腾出、DB 指针已清;同秒二次重置 → 追加 `_2` 不覆盖。
- 改名 `PermissionError` → 本次重置整体失败、DB 指针未清、原文件原名保留(断言不留中间态)。
- 旧图不存在 → 重置照常成功(幂等)。
- 仅重置标题 → 不触碰图片文件。
- `update_generated_cover` 写入后 `stage=generated/status=pending/last_error=NULL`;不增加 attempts,不改 committed/applied_at/apply_attempts/image_task_*;目标文件不存在 → 报错。
- `delete_batch` 仍只做软删除,不删除 `<batch_id>/` 图片目录;归档图片继续保留。
- `list_task_cover_candidates` 返回全部 `_new*` 候选、不含 `_old`,排序符合“规范名优先、归档时间倒序、撞名数字倒序、异常文件名兜底”的规则。
- 验证命令(unittest,不引入 pytest):
- `py -3.10 -m unittest tests.test_db tests.test_image_paths`
- `python -m ruff check app tests main.py`
- `py -3.10 -m unittest discover -s tests`
- `git diff --check`
## 边界(不改什么)
- 不改 `tasks` 表 schema(不加列、不建子表);`new_cover_path` 仍为单路径。
- 不改生图写入链(`_save_jpeg`/`persist_cover_success`/规范名 `..._new.jpg`);归档只钩在重置。
- 不改 ③更新/Excel 回写对 `new_cover_path` 的消费方式。
- 不做 GUI(画廊弹窗在 T-567)。
- 不改 CDP/Shopee 交互、AI 请求链路。
## 执行记录
(做完在这里写:改了什么文件、跑了什么验证命令及结果、遇到的阻塞、关键决策。)
+84
View File
@@ -0,0 +1,84 @@
---
id: T-567
title: ②封面画廊基础:候选图查看、单选保存、原图弹窗
phase: 7
deps: [T-566]
status: TODO
created: 2026-07-09
---
## 问题 / 背景
运营需要在软件内对比同一商品多次 AI 生图的候选,并把其中一张设为当前生效封面。当前②列表双击打开的「封面对照」弹窗只显示旧封面 + 当前新封面各一张,无法看到历史候选,也不能保存选中的历史图。
T-566 已提供数据层能力:重置封面时归档旧图、`db.update_generated_cover()` 切换当前封面指针、`image_paths.list_task_cover_candidates()` 枚举候选图。本任务只做 GUI 第一阶段:画廊查看 + 单选保存 + 原图弹窗。
## 方案(改哪个文件、改成什么)
### `app/gui/tabs/generate.py` — 重构封面对照弹窗
- 将现有 `show_task_images` 改为封面画廊弹窗(可拆成独立 `QDialog` 类,如 `CoverGalleryDialog`)。
- 布局:
- 左侧保留「旧封面」参照,推荐缩略图约 `300x300`;
- 右侧为候选画廊:横向 `QScrollArea`,每个候选项包含缩略图、`QRadioButton`、时间/当前标记;
- 底部按钮第一阶段只保留「保存」「取消」;不做「重新生成」「上一条/下一条」。
- 尺寸:
- 默认 `resize(min(960x580, 屏幕 availableGeometry 的 90%))`;
- 不用 `setFixedSize()`,拉伸增量给画廊区域;
- 小屏下窗口标题栏必须完整可见。
### 候选加载与默认选中
- 打开弹窗时调用 T-566 `list_task_cover_candidates(image_root, task, account)` 现场扫目录。
- 默认选中 = DB 当前 `task.new_cover_path` 指向的文件;不是无条件选最新。
- 如果 DB 当前路径不存在但候选列表不空:
- 默认不自动改库;
- 画廊显示候选但不预选,状态 label 提示“当前生效封面文件不存在,请选择一张后保存”。
- 如果无候选图:
- 右侧显示空状态“暂无生成封面图片”;
- 「保存」置灰。
- 缩略图加载必须用 `QImage` 读入内存后转 `QPixmap`,不长期持有文件句柄,避免后续重置/重生时 Windows 文件锁影响归档改名。
### 保存
- 选中项与 DB 当前 `new_cover_path` 相同:直接关闭。
- 选中项不同:调用 `db.update_generated_cover(task_id, selected_path)`;刷新②列表该行;关闭弹窗。
- 若该任务 `committed=1` 或 `stage='applied'`,保存前必须弹确认:
- 文案说明“本地换封面不会回滚蝦皮,重复更新会再次提交线上”;
- 用户取消则不写库、不关闭。
- 保存失败(文件不存在、DB 错误等)应弹中文错误并保留弹窗,不吞异常。
### 原图弹窗
- 双击任一候选缩略图打开原图弹窗:
- `QDialog + QScrollArea` 展示原始尺寸图片;
- 窗口最大不超过屏幕 availableGeometry 的 90%,超出可滚动;
- 标题显示图片文件名和原始分辨率,如 `1024x1024`。
## 验收要点
- 双击②任务列表仍能打开封面查看入口;旧封面仍作为参照显示。
- 画廊列出该任务全部 `_new*` 候选,不显示 `_old`。
- 默认选中 DB 当前 `new_cover_path`;DB 当前路径缺失时不自动改库。
- 选择另一张并保存会调用 `db.update_generated_cover()`,②列表新封面路径刷新;未变更直接关闭。
- 已提交线上任务保存前弹二次确认;取消不写库。
- 双击缩略图可打开原图弹窗,超出屏幕可滚动,标题含分辨率。
- 缩略图加载不持文件句柄;Windows 下打开画廊后仍可重命名候选文件。
- 验证命令(unittest,不引入 pytest):
- `py -3.10 -m unittest tests.test_gui tests.test_db tests.test_image_paths`
- `python -m ruff check app tests main.py`
- `py -3.10 -m compileall app main.py`
- `py -3.10 -m unittest discover -s tests`
- `git diff --check`
## 边界(不改什么)
- 不做单任务重新生成(T-568)。
- 不做上一条/下一条切换和未保存三态拦截(T-569)。
- 不改 T-566 的数据层契约。
- 不改 `GenerateWorker`、`generate_batch`、cmhub 请求链路。
- 不改 ③更新/Excel 回写/CDP/Shopee。
## 执行记录
(做完在这里写:改了什么文件、跑了什么验证命令及结果、遇到的阻塞、关键决策。)
+76
View File
@@ -0,0 +1,76 @@
---
id: T-568
title: ②封面画廊单任务重新生成与并发守卫
phase: 7
deps: [T-567]
status: TODO
created: 2026-07-09
---
## 问题 / 背景
T-567 只提供候选封面查看与保存。运营在对比时仍需要对当前商品单独再生成一张新封面,并在画廊中立即看到新候选。该能力涉及 AI 点数、DB 归档、任务状态和后台 worker 并发,必须单独拆出,避免 T-567 弹窗基础功能过大。
## 方案(改哪个文件、改成什么)
### 重新生成按钮
- 在 T-567 的封面画廊弹窗底部新增「重新生成」按钮。
- 点击后弹确认框:
- 文案说明将重新生成当前商品封面,可能消耗 cmhub 点数;
- 用户取消则无副作用。
- 用户确认后:
1. 调用 `db.reset_generated(task_id, reset_title=False, reset_cover=True)`,由 T-566 归档当前 `new_cover_path` 并清空封面指针;
2. reset 成功后必须重新 `db.get_task(task_id)` 获取最新任务对象,不能复用弹窗打开时的旧 task(旧对象可能还带旧 `new_cover_path`,导致 `generate_batch()` 判断无需补封面);
3. 启动单任务 `GenerateWorker([fresh_task])`,沿用当前②页面的提示词、cmhub配置、生成参数和“生成封面图片”语义,但本任务强制只补当前任务缺失封面,不重生标题;
4. 弹窗左下状态 label 显示“开始重新生成商品 <item_id> 的封面图片”。
### 完成/失败刷新
- 单任务生成完成:
- 刷新②列表该行;
- 重新调用 `list_task_cover_candidates()` 刷新画廊;
- 新生成的规范名 `_new.jpg` 应成为 DB 当前 `new_cover_path`,画廊默认选中它;
- 状态 label 显示“封面重新生成完成”。
- 失败:
- 保留已归档旧图;
- 列表按既有生成失败口径显示;
- 状态 label 显示失败摘要;
- 画廊继续显示历史候选,允许用户选择历史图保存。
- 若 `reset_generated()` 因 Windows 文件占用导致归档改名失败:
- 中止重新生成;
- 不启动 `GenerateWorker`;
- 弹中文提示“请先关闭正在查看的封面图片再重试”。
### 并发守卫
- ②批量生成运行中时,画廊「重新生成」按钮禁用,并显示 tooltip。
- 画廊单任务重新生成运行中时,②主界面「开始生成」按钮也应禁用或阻断,避免两个 `GenerateWorker` 同时写同一 DB/图片目录。
- 同一画廊弹窗运行中再次点击「重新生成」无效。
- 本任务不新增 cmhub cancel 接口;关闭弹窗时若单任务仍在运行,只允许隐藏/关闭视图,不强杀服务端任务,遵守 T-564 续查语义。
## 验收要点
- 重新生成确认取消 → 不归档、不启动 worker、不改 DB。
- 确认后先归档旧图,再用 fresh task 启动单任务生成;不会因旧 task 对象残留 `new_cover_path` 而跳过封面生成。
- 成功后画廊出现新图并默认选中;②列表刷新为新封面路径。
- 失败后历史候选仍可选择保存。
- 归档改名失败时不清 DB 指针、不启动 worker。
- 批量生成和单任务重生互斥,不允许两个 GenerateWorker 并行。
- 验证命令:
- `py -3.10 -m unittest tests.test_gui tests.test_db`
- `python -m ruff check app tests main.py`
- `py -3.10 -m compileall app main.py`
- `py -3.10 -m unittest discover -s tests`
- `git diff --check`
## 边界(不改什么)
- 不改 T-566 数据层契约。
- 不改 cmhub submit/poll/download 逻辑。
- 不做上一条/下一条切换和未保存拦截(T-569)。
- 不改 ③更新/Excel/CDP/Shopee。
## 执行记录
(做完在这里写:改了什么文件、跑了什么验证命令及结果、遇到的阻塞、关键决策。)
+69
View File
@@ -0,0 +1,69 @@
---
id: T-569
title: ②封面画廊上下条切换与未保存选择拦截
phase: 7
deps: [T-567]
status: TODO
created: 2026-07-09
---
## 问题 / 背景
T-567 的封面画廊只处理当前单条任务。运营批量检查时需要在弹窗内连续查看上一条/下一条商品,而不是关闭弹窗后回到列表再双击。切换记录时,如果当前画廊已经勾选了另一张候选但尚未保存,必须避免静默丢失选择。
## 方案(改哪个文件、改成什么)
### 上一条/下一条按钮
- 在 T-567 封面画廊弹窗底部增加「上一条」「下一条」。
- 目标任务来自②当前表格模型的相邻可见行,必须尊重当前筛选结果和排序。
- 第一条时「上一条」置灰;最后一条时「下一条」置灰。
- 切换记录时只刷新弹窗内容区和内部当前 task,不重建整个窗口;窗口大小和位置保持不变。
### 键盘切换
- 支持 `↑` / `↓` 键切换上一条/下一条,和按钮走同一个切换函数。
- 画廊内 `QRadioButton` 设置 `ClickFocus` 或 `NoFocus`,避免方向键在 radio 组内改变候选选择而不是切换记录。
- 输入框/按钮等获得焦点时如不适合消费方向键,应保持 Qt 默认行为;不为键盘切换破坏可访问性。
### 未保存选择拦截
- 判断条件:当前 radio 选中路径 != DB 当前 `task.new_cover_path`。
- 在按钮切换、方向键切换、关闭弹窗前都必须进入同一套拦截流程。
- 弹三态确认框,按钮必须是中文:
- 「保存」:调用 `db.update_generated_cover()`;如任务已提交线上,先弹 T-567 同款“本地换封面不会回滚蝦皮,重复更新会再次提交线上”确认;保存成功后继续切换/关闭。
- 「不保存」:丢弃当前勾选,继续切换/关闭。
- 「取消」:留在当前任务,不切换、不关闭。
- 保存失败时保留当前任务和弹窗,显示中文错误。
### 与 T-568 的关系
- 如果 T-568 已完成且当前任务正在单任务重生:
- 可允许切换到其它任务,但切回时必须刷新最新 DB/候选;
- 当前任务运行中时画廊显示“生成中”状态,不显示旧 `new_cover_path` 为当前有效图误导用户。
- 如果 T-568 尚未完成,本任务只需保证 T-567 基础画廊切换逻辑正确。
## 验收要点
- 上一条/下一条按钮按当前②可见列表相邻行切换;边界按钮置灰。
- `↑` / `↓` 与按钮共用切换逻辑;方向键不会改变 radio 选中。
- 切换记录不改变弹窗尺寸和屏幕位置。
- 当前有未保存选择时,按钮切换、键盘切换、关闭弹窗都会弹中文三态确认。
- 「保存 / 不保存 / 取消」三种路径行为正确;保存失败不切换。
- 已提交线上任务保存前仍弹二次警示。
- 验证命令:
- `py -3.10 -m unittest tests.test_gui`
- `python -m ruff check app tests main.py`
- `py -3.10 -m compileall app main.py`
- `py -3.10 -m unittest discover -s tests`
- `git diff --check`
## 边界(不改什么)
- 不改 T-566 数据层契约。
- 不改 T-568 单任务重新生成逻辑。
- 不改 cmhub/AI 请求、DB schema、Excel、③更新、CDP/Shopee。
## 执行记录
(做完在这里写:改了什么文件、跑了什么验证命令及结果、遇到的阻塞、关键决策。)