diff --git a/app/ai.py b/app/ai.py index 4198004..81af8ce 100644 --- a/app/ai.py +++ b/app/ai.py @@ -349,6 +349,15 @@ def _has_generated_cover(task): return bool(str(getattr(task, "new_cover_path", "") or "").strip()) +def cover_title_context(task): + """Return the title used to describe a cover without changing task results.""" + + new_title = str(getattr(task, "new_title", "") or "").strip() + if new_title: + return new_title + return str(getattr(task, "old_title", "") or "").strip() + + def generation_needs(task, generate_cover=False, generate_mode=None): """Return which AI components still need generation for this task.""" @@ -370,7 +379,7 @@ def generation_needs(task, generate_cover=False, generate_mode=None): needs_cover = False if appconfig.generate_mode_includes_cover(mode) and not _has_generated_cover(task): if mode == "cover": - needs_cover = has_title + needs_cover = bool(cover_title_context(task)) else: needs_cover = has_title or needs_title return {"title": needs_title, "cover": needs_cover} @@ -587,9 +596,29 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No for task in eligible: task_id = getattr(task, "id", None) - if task_id not in title_results and _has_generated_title(task): - title_results[task_id] = str(getattr(task, "new_title") or "") - _emit_generation_event(on_event, task, "title", "title_submit", "skipped", detail="已有标题") + if task_id in title_results: + continue + if generate_mode == "cover": + title_context = cover_title_context(task) + elif _has_generated_title(task): + title_context = str(getattr(task, "new_title") or "") + else: + title_context = "" + if not title_context: + continue + title_results[task_id] = title_context + if _has_generated_title(task): + detail = "已有标题" + else: + detail = "没有新标题,本轮使用旧标题作为封面参考" + _emit_generation_event( + on_event, + task, + "title", + "title_submit", + "skipped", + detail=detail, + ) cover_tasks = [ task for task in cover_candidates @@ -627,19 +656,21 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No level="warning", ) - def persist_cover_success(task, new_title, new_cover_path): + def persist_cover_success(task, title_context, new_cover_path): set_step(task, "db_write") _emit_generation_event(on_event, task, "cover", "db_write", "start") - db.set_generated(task.id, new_title, new_cover_path, path=db_path) + db.set_generated_cover(task.id, new_cover_path, path=db_path) summary["cover_done"] += 1 summary["generated_done"] += 1 + needs_title = needs_by_task.get(getattr(task, "id", None), {}).get("title") + persisted_title = title_context if needs_title else getattr(task, "new_title", None) if on_task_update is not None: on_task_update( task.id, { "stage": "generated", "status": "success", - "new_title": new_title, + "new_title": persisted_title, "new_cover_path": new_cover_path, }, ) diff --git a/app/db.py b/app/db.py index 5ae4bed..39db811 100644 --- a/app/db.py +++ b/app/db.py @@ -973,6 +973,29 @@ def set_generated(task_id, new_title, new_cover_path, path=None, conn=None) -> N (new_title, new_cover_path, now, now, int(task_id)), ) + +def set_generated_cover(task_id, new_cover_path, path=None, conn=None) -> None: + """Persist an AI-generated cover without changing the generated title.""" + + now = _now() + with _connection(conn, path) as database: + with database: + database.execute( + """ + UPDATE tasks + SET new_cover_path = ?, + stage = 'generated', + status = 'success', + last_error = NULL, + generate_attempts = generate_attempts + 1, + generated_at = ?, + updated_at = ? + WHERE id = ? + """, + (new_cover_path, now, now, int(task_id)), + ) + + def update_generated_title(task_id, new_title, path=None, conn=None) -> None: """Update a generated title manually without touching Shopee or local cover files.""" diff --git a/app/gui/models.py b/app/gui/models.py index 857aa83..528c1ef 100644 --- a/app/gui/models.py +++ b/app/gui/models.py @@ -2,7 +2,7 @@ from __future__ import annotations -from .. import diagnostics +from .. import ai, appconfig, diagnostics from ..collect_skip import skipped_stage_text from .widgets import * @@ -190,10 +190,15 @@ class GenerateTaskTableModel(QAbstractTableModel): self.db_path = db_path self.status_callback = status_callback self.last_edit_error = None + self.generate_mode = "title" self.generate_cover_enabled = False def set_generate_cover_enabled(self, enabled): - self.generate_cover_enabled = bool(enabled) + self.set_generate_mode("title_cover" if enabled else "title") + + def set_generate_mode(self, mode): + self.generate_mode = appconfig.normalize_generate_mode(mode) + self.generate_cover_enabled = appconfig.generate_mode_includes_cover(self.generate_mode) if not self.tasks: return top_left = self.index(0, 4) @@ -403,7 +408,10 @@ class GenerateTaskTableModel(QAbstractTableModel): return "失败" if not self.generate_cover_enabled: return "未启用" - if self._is_title_failure(task) or not self._has_new_title(task): + if self.generate_mode == "cover": + if not ai.cover_title_context(task): + return "未开始" + elif self._is_title_failure(task) or not self._has_new_title(task): return "未开始" if getattr(task, "status", None) == "running": return "处理中" diff --git a/app/gui/tabs/generate.py b/app/gui/tabs/generate.py index 2107065..8493d1c 100644 --- a/app/gui/tabs/generate.py +++ b/app/gui/tabs/generate.py @@ -945,7 +945,7 @@ class GenerateTab(QWidget): self.empty_state_button.clicked.connect(self.open_accounts_callback) self.task_table = QTableView() self.model = GenerateTaskTableModel(self.task_table, db_path=self.db_path, status_callback=self._set_status) - self.model.set_generate_cover_enabled(self._current_generate_mode_includes_cover()) + self.model.set_generate_mode(self._current_generate_mode()) self.task_table.setModel(self.model) self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows) self.task_table.setSelectionMode(QAbstractItemView.ExtendedSelection) @@ -1159,13 +1159,13 @@ class GenerateTab(QWidget): previous = appconfig.ai_generate_mode(self.config) current = self._current_generate_mode() if self._save_generate_mode_setting(show_status=True): - self.model.set_generate_cover_enabled(appconfig.generate_mode_includes_cover(current)) + self.model.set_generate_mode(current) self.refresh_tasks() return self.generate_mode_combo.blockSignals(True) self._set_combo_by_data(self.generate_mode_combo, previous) self.generate_mode_combo.blockSignals(False) - self.model.set_generate_cover_enabled(appconfig.generate_mode_includes_cover(previous)) + self.model.set_generate_mode(previous) def _apply_task_table_column_widths(self): if self.model.columnCount() != len(self.TABLE_COLUMN_WEIGHTS): @@ -1524,7 +1524,7 @@ class GenerateTab(QWidget): ] if not tasks: if generate_mode == "cover": - self._set_status("当前筛选结果没有可生成封面的任务,请先生成标题。") + self._set_status("当前筛选结果没有可生成的封面;请确认商品已完成采集且尚未生成新封面。") else: self._set_status("当前筛选结果没有可生成的缺失内容;请先在①导入采集完成采集,或调整生成内容") return @@ -2081,9 +2081,12 @@ class GenerateTab(QWidget): return None def _prompt_context(self, task): + new_title = task.new_title + if self._current_generate_mode() == "cover": + new_title = ai.cover_title_context(task) return { "old_title": task.old_title, - "new_title": task.new_title, + "new_title": new_title, "item_id": task.item_id, "account_name": self.model.account_name_for(task), "alias": task.alias, @@ -2133,7 +2136,7 @@ class GenerateTab(QWidget): _set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button) self._set_status(f"AI 生成任务读取失败:{exc}") return - self.model.set_generate_cover_enabled(self._current_generate_mode_includes_cover()) + self.model.set_generate_mode(self._current_generate_mode()) self.model.set_tasks(filtered_tasks, accounts_rows) self._apply_task_table_column_widths() self.summary_label.setText( diff --git a/app/gui/workers.py b/app/gui/workers.py index a23c2f8..9dbcbb8 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -593,6 +593,8 @@ class GenerateWorker(BaseWorker): if result == "start" and step == "title_submit": return f"[标题] {self._task_progress_label(task)} 开始生成" if result == "skipped": + if detail and "旧标题作为封面参考" in detail: + return f"[图片] {self._task_progress_label(task)} {detail}" return f"[标题] {self._task_progress_label(task)} 已有标题,跳过生文" if result == "success" and step == "title_done": return f"[标题] {self._task_progress_label(task)} 成功" diff --git a/docs/02-requirements.md b/docs/02-requirements.md index e4192f7..01a5db2 100644 --- a/docs/02-requirements.md +++ b/docs/02-requirements.md @@ -42,7 +42,7 @@ | 导入(①) | 导入多个 Excel,按模板解析输入列(账号名/别名/商品id)为任务列表 | P0 | | 导入校验汇总(①) | 导入后展示文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数;跑采集前先纠错 | P0 | | 采集旧数据(①) | 程序只读打开商品页,抓取旧标题、下载旧封面到本地,回写 Excel 旧字段 | P0 | -| AI 生成(②) | 输入标题/封面提示词,按②本轮「生成内容」下拉选择只生成标题、只生成封面或生成标题和封面;只生成封面要求任务已有新标题,避免用户无意产生图片模型成本;②任务表格拆分显示「标题状态 / 图片状态」,商品ID列缩窄约 50%,状态列再缩到当前约 33%,多出的宽度平均给旧标题和新标题;标题/图片两条进度条右侧分别显示生标题/生图实时用时秒数;点击「开始生成」时清空②界面旧日志,只显示本轮生成日志,历史 run_logs 仍保留 | P0 | +| AI 生成(②) | 输入标题/封面提示词,按②本轮「生成内容」下拉选择只生成标题、只生成封面或生成标题和封面;只生成封面时不调用生文,标题语义优先使用新标题、没有新标题时回退已采集的旧标题,并且只保存新封面、不把旧标题写成新标题;②任务表格拆分显示「标题状态 / 图片状态」,商品ID列缩窄约 50%,状态列再缩到当前约 33%,多出的宽度平均给旧标题和新标题;标题/图片两条进度条右侧分别显示生标题/生图实时用时秒数;点击「开始生成」时清空②界面旧日志,只显示本轮生成日志,历史 run_logs 仍保留 | P0 | | 提示词管理(②) | 标题提示词「保存」到 `data/title_prompt.txt` 并启动回显;封面提示词多模板(下拉 + 新建/保存/另存为/重命名/删除)+ 插入 `{新标题}` + 预览(变量替换) | P0 | | 查看对照(②) | 双击任务弹窗查看旧封面、新封面和历史候选图;可在弹窗内对当前行「重置图片」,退出后用状态筛选「待生成」批量补生成封面;不在弹窗内启动单条生图 | P0 | | 更新蝦皮(③) | 按批次/店铺/状态筛选;③左下角「更新内容」下拉选择只更新标题、只更新封面或更新标题和封面;点击「开始更新」前先校验所选内容已生成,缺失则中文弹窗阻断且不打开 Chrome;确认后对当前筛选出的已生成任务按每批最大条数分批更新蝦皮商品并点「更新」提交线上;普通正式更新不再受测试商品 ID 限制,可批量更新真实商品;可按状态=失败重试 | P0 | diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 28fd57f..9b3ae6a 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -56,7 +56,7 @@ imported → collected → generated → applied - **imported**:openpyxl 解析输入列入库。 - **collected**:只读打开商品页,读旧标题、下载旧封面到本地,写 `old_title/old_cover_path`,回写 Excel 旧字段。 -- **generated**:AI 按②「生成内容」下拉生成新标题和/或新封面;选择只生成标题时 `new_cover_path=NULL`,选择只生成封面时要求任务已有 `new_title`,选择生成标题和封面时可按缺失组件增量补齐。**不设逐条人工审核阶段**,生成成功即可进入 ③;③ 再按「更新内容」下拉决定只更新标题、只更新封面或更新图文。 +- **generated**:AI 按②「生成内容」下拉生成新标题和/或新封面;选择只生成标题时 `new_cover_path=NULL`;选择只生成封面时不调用生文,封面标题上下文取 `new_title or old_title`,只写 `new_cover_path`,因此“有新封面、无新标题”是合法组件状态;选择生成标题和封面时可按缺失组件增量补齐。**不设逐条人工审核阶段**,生成成功即可进入 ③;③ 再按「更新内容」下拉决定只更新标题、只更新封面或更新图文。 - **applied**:③ 点击「开始更新」后弹窗确认当前筛选范围和任务数量;确认后打开编辑页换标题+封面,逐条点「更新」提交线上,回写结果。 任意阶段失败 → `stage` 不前进、记 `error`、`result/skipped`,不影响其他任务。 @@ -334,7 +334,7 @@ CREATE TABLE run_log_events ( - `source_file_abs/source_sheet/source_row` 是回写 Excel 的权威定位;即使商品 ID 重复,也按原行回写。 - `row_key` 防止同一批次内重复导入同一行。 - `old_title/old_cover_path`:程序**采集阶段抓取**的快照(输出)。 -- `new_title/new_cover_path`:**AI 生成**结果(输出);`new_title` 是进入 ③ 的必需结果,`new_cover_path` 是可选结果,只有②勾选生成封面且图片成功时才写入本地新封面路径;不设逐条确认阶段,标题生成成功即可进入 ③。 +- `new_title/new_cover_path`:**AI 生成**的两个独立组件结果(输出)。③只更新标题要求 `new_title`,只更新封面要求 `new_cover_path`,更新图文才同时要求二者;只生成封面允许 `new_title=NULL`,已采集的 `old_title` 只作为封面prompt语义参考,不回填 `new_title`。不设逐条确认阶段。 - `stage` 表示已完成到哪个业务阶段;`status` 表示当前处理结果。失败时 `stage` 保持在最后成功阶段,`status=failed`,错误写 `last_error`。 - `committed`:③ 用户确认批量更新弹窗后逐条点「更新」提交;该字段记录提交是否成功。 - 各字段**每阶段处理完立即写回 SQLite**(实时落库);阶段结束后批量回写 Excel。 @@ -403,10 +403,10 @@ data/images///__new. # AI 生成的新 ### 6.2 AI 生成(② Tab) -单个「开始生成」按钮,标题必生成,封面按本轮成本开关可选生成: +单个「开始生成」按钮,按本轮「生成内容」模式生成标题、封面或图文: 1. **并发生成标题**:线程池大小 = `title_concurrency`,调 `gen_title(标题提示词, old_title)` → new_title。默认 `backend=cmhub`,调用 `POST /api/v1/generate/title` 并使用 `title_alias`;`backend=direct` 仅内部兼容时使用 `default_text_model`。 -2. **若②「生成内容」包含封面**:接着并发生成封面,线程池大小 = `image_concurrency`,调 `generate_batch()` 的封面阶段 → 新图存 `data/images///__new.jpg`。选择 `cover` 时仅处理已有 `new_title` 且缺新封面的任务;选择 `title_cover` 时标题和封面按缺失组件增量补齐。默认 `backend=cmhub` 的②批量生成走异步任务接口:先 `POST /api/v1/generate/image/tasks` 提交并预扣点,带 `Idempotency-Key` 与 `X-Client-Version`,submit 成功后立即把 `task_id` 写入 `tasks.image_task_id`;再循环 `GET /api/v1/generate/image/tasks/{task_id}` 轮询,`succeeded` 后取 `result.image_url` 并安全下载转本地 JPEG。若本地已有 `image_task_id`,下次开始生成直接续查,不重新 submit、不重复扣点。`gen_cover()` 单独直接调用无 DB 上下文,仍保留旧同步 `POST /api/v1/generate/image` 兼容路径;`backend=direct` 仅内部兼容时使用 `default_image_model`。用户日志只展示“cmhub 托管档位 / 别名 / 扣点 / 余额 / call_id”,不得输出 cmhub 内部接口路径、Provider 请求体、Authorization、Key 或完整 prompt。 +2. **若②「生成内容」包含封面**:接着并发生成封面,线程池大小 = `image_concurrency`,调 `generate_batch()` 的封面阶段 → 新图存 `data/images///__new.jpg`。选择 `cover` 时处理缺新封面且存在 `new_title or old_title` 的任务,优先用新标题、否则只在prompt上下文中用旧标题回退;选择 `title_cover` 时仍先补新标题,再按缺失组件补封面,不使用旧标题绕过失败的生文。默认 `backend=cmhub` 的②批量生成走异步任务接口:先 `POST /api/v1/generate/image/tasks` 提交并预扣点,带 `Idempotency-Key` 与 `X-Client-Version`,submit 成功后立即把 `task_id` 写入 `tasks.image_task_id`;再循环 `GET /api/v1/generate/image/tasks/{task_id}` 轮询,`succeeded` 后取 `result.image_url` 并安全下载转本地 JPEG。若本地已有 `image_task_id`,下次开始生成直接续查,不重新 submit、不重复扣点。`gen_cover()` 单独直接调用无 DB 上下文,仍保留旧同步 `POST /api/v1/generate/image` 兼容路径;`backend=direct` 仅内部兼容时使用 `default_image_model`。用户日志只展示“cmhub 托管档位 / 别名 / 扣点 / 余额 / call_id”,不得输出 cmhub 内部接口路径、Provider 请求体、Authorization、Key 或完整 prompt。 - T-545/T-564 已实现:cmhub 模式下不再直接按用户填写的 `image_concurrency` 全量打到网关;实际 submit+poll 在途并发 = `min(image_concurrency, 5)`。拿到 `image_url` 后交给独立下载/保存线程池,下载线程数量与实际生图并发一致,同样最大 5。这样批量时“下一批生图任务”和“上一批图片下载/保存”可以流水线并行,但不会对 cmhub 生图任务接口或 `/media/generated/images/*.png` 静态下载打出超过 5 的并发。direct 兼容路径暂不改变。 - cmhub 生图连接超时取 `ai.cmhub.connect_timeout`(默认 66 秒);异步 submit 读取等待 30 秒,poll 单次读取等待 15 秒,本地总等待预算 900 秒,撞预算或用户停止时保留 `image_task_id/image_task_key` 供下次续查;poll 返回 `failed/expired`(cmhub 已退点)时清空 `image_task_id/image_task_key`,后续重试会生成新的幂等键并重新 submit。下载层读取等待 900 秒,最多安全重试 3 次,只复用同一个 `image_url`,不重新请求 cmhub 生图;下载总耗时超过 20 秒时写“图片下载较慢”警告。`backend=direct` 兼容路径仍按模型 `timeout_seconds` 或 `resolution_timeouts[resolution]`(512→180/1k→240/2k→360/4k→600)取返回超时。 - T-548 已实现:cmhub 图片下载后端新增 `ai.cmhub.download_with_curl`(`auto`/`true`/`false`,默认 `auto`)。Windows 且检测到系统 curl 时优先用 curl 下载,否则或 curl 执行失败时回退 requests;生成、models、balance 仍走共享 requests Session。curl 下载前仍执行公网 URL 校验;URL 写入临时 curl 配置文件并通过 `-K` 传入,避免带 token 的 `image_url` 出现在进程命令行;`use_system_proxy=false` 时 curl 加 `--noproxy "*"`。 @@ -414,10 +414,10 @@ data/images///__new. # AI 生成的新 3. **若②「生成内容」为只生成标题**:标题成功后立即写 `new_title`,`new_cover_path=NULL`,不渲染封面提示词、不调用 `gen_cover()`、不创建本地新封面文件。 - 失败重试:每次调用失败按 `retry` 次重试,仍失败则记 error(不阻塞其余)。 -- 只生成标题模式在标题成功后立即 `set_generated(task_id, new_title, NULL)` 写库;只生成封面模式不调用生文、不覆盖已有标题;生成标题和封面模式在封面成功后 `set_generated(task_id, new_title, new_cover_path)` 写库。三种模式都实时落库,停止或崩溃不丢已生成结果。 +- 只生成标题模式在标题成功后立即 `set_generated(task_id, new_title, existing_cover_path)` 写库;只生成封面模式不调用生文,封面成功通过 `set_generated_cover(task_id, new_cover_path)` 只写封面并保留 `new_title`(包括保持NULL);生成标题和封面模式先保存标题,封面成功再用组件级helper写封面。三种模式都实时落库,停止或崩溃不丢已生成结果。 - **「停止」**:取消未开始的任务,正在跑的少量完成或中断;停止后可再次「开始生成」对剩余继续。 - 进度:标题和图片两条进度分开显示;只生成标题时图片进度显示本轮未生成/0 张,并在运行日志写明本轮生成内容。 -- 生成后 stage=generated;**不设逐条人工审核阶段**。若未生成封面,任务仍可进入③并选择只更新标题;若后续需要封面,用户可在②选择只生成封面或生成标题和封面补齐。双击任务弹窗查看旧封面、新封面和历史候选图;T-577 后弹窗内「重置图片」只清当前任务 `new_cover_path` 并归档旧图,不启动单条 `GenerateWorker`,用户退出后用状态筛选「待生成」批量补生成封面。②「重置生成结果」提供标题/封面/全部的多选或当前筛选范围重置,默认不删除本地新封面文件;已生成且未提交线上的新标题可在②表格本地微调。 +- 任一组件生成后 `stage=generated`;**不设逐条人工审核阶段**。若只有标题,③可选择只更新标题;若只有封面,③可选择只更新封面,②标题状态仍为待生成,后续补标题会保留已有封面且不重复生图。双击任务弹窗查看旧封面、新封面和历史候选图;T-577 后弹窗内「重置图片」只清当前任务 `new_cover_path` 并归档旧图,不启动单条 `GenerateWorker`,用户退出后用状态筛选「待生成」批量补生成封面。②「重置生成结果」提供标题/封面/全部的多选或当前筛选范围重置,默认不删除本地新封面文件;已生成且未提交线上的新标题可在②表格本地微调。 - 并发数、重试、分辨率、jpg 质量、模型/Key 均来自 ⑤ 设置(`data/config.json` 的 `ai` 段;Key 存 `data/config/cmhub.json` 或 direct 兼容清单)。T-547 后标题并发和图片并发都限制为 1..5,失败重试次数限制为 0..10;旧 `config.json` 或手工配置的超限值会在加载/保存时夹紧。⑤仍只展示一个「图片并发」设置;cmhub 模式下②运行日志显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”。 - ⑥AI工场固定使用⑤保存的 cmhub 生图 alias。生成区显示“cmhub 托管默认档 / 高质量档 / 省点档”、当前生图别名、余额和扣点;档位是产品说明层,真实模型可由 cmhub 后台调整,客户端不保存 OpenAI slug。 diff --git a/docs/api.md b/docs/api.md index 4cf5e0d..c1d3c5b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -110,6 +110,7 @@ mark_failed(task_id, phase, error) -> None # status=failed,stage 不前进 mark_skipped(task_id, reason) -> None # status=skipped,stage 不前进 set_collected(task_id, old_title, old_cover_path) -> None # stage=collected,status=success,collect_attempts+1 set_generated(task_id, new_title, new_cover_path) -> None # stage=generated,status=success,generate_attempts+1;new_cover_path 可为 None 表示只生成标题 +set_generated_cover(task_id, new_cover_path) -> None # 只写AI封面并保留new_title(含NULL);stage=generated,status=success,generate_attempts+1 ensure_image_task_key(task_id) -> str # T-564 cmhub 异步生图:无幂等键则生成并写 tasks.image_task_key set_image_task_submitted(task_id, image_task_id, image_task_key=None) -> None # T-564 submit 202 后立即写 tasks.image_task_id/image_task_key,供重启/重试续查 @@ -304,7 +305,7 @@ generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None) fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30) -> list[dict] # 编排:按 ai.generate_mode 跑标题和/或封面;title/title_cover 用 title_concurrency,cover/title_cover 用 image_concurrency # cmhub 批量生图由 generate_batch 直接走异步 /generate/image/tasks:submit 落库 task_id,再 poll 续查,成功后仍返回本地 JPEG 路径 -# 只生成标题时标题成功即 db.set_generated(task_id, new_title, None);只生成封面不调用生文;生成标题和封面时封面成功后写 new_cover_path;should_stop() 为真则取消未开始项 +# 只生成标题时标题成功即 db.set_generated(task_id, new_title, existing_cover_path);只生成封面不调用生文,标题上下文取new_title or old_title,成功后db.set_generated_cover只写封面;should_stop() 为真则取消未开始项 # on_progress({"total","title_done","cover_done","failed","cancelled","ok"}) 回调刷新进度 # ai_cfg 可传 on_event/on_error 回调,逐条报告 title/cover 阶段 step/result/detail,供 GUI run_logs 与本地诊断日志使用 # 返回同结构 summary;失败任务 mark_failed(..., "generate", error),不阻塞其余 @@ -407,7 +408,7 @@ class ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成 class SettingsTab(QWidget) # ⑤ 设置:cmhub 网关配置 + 响应式三列布局 + 角色/生成参数/路径端口 + 蝦皮更新安全 + 未保存状态追踪 class ImageStudioTab(QWidget) # ⑥ AI工场:项目、原图、照片池、终选、cmhub托管生成、导出 class CollectWorker(BaseWorker) # ① 后台采集:账号就绪预检 -> editor.collect -> db.set_collected/mark_skipped/mark_failed -class GenerateWorker(BaseWorker) # ② 后台生成:ai.generate_batch -> db.set_generated/mark_failed + 进度 +class GenerateWorker(BaseWorker) # ② 后台生成:ai.generate_batch -> db.set_generated/set_generated_cover/mark_failed + 进度 class ApplyWorker(BaseWorker) # ③ 后台更新:账号就绪预检 -> 检查或按批调用 editor.apply_task(...) -> db.set_applied/mark_skipped class WriteBackWorker(BaseWorker) # ①/③ 后台回写:旧字段或更新结果写回原 Excel class AIModelTestWorker(BaseWorker) # ⑤ 后台测试 AI 模型连接:appconfig.test_ai_model @@ -479,9 +480,9 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负 - 筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部;状态支持全部/待生成/已生成/失败/略过/已更新。 - 任务列表使用 `QTableView + GenerateTaskTableModel`,列为:店铺、商品ID、旧标题、新标题、状态。`stage=collected` 显示“待生成”,`stage=generated` 显示“已生成”,`status=failed/skipped/running` 优先显示对应状态;已生成、未提交线上、非运行中的「新标题」列可双击编辑,调用 `db.update_generated_title()` 写回本地并清空 `last_error`。 - 双击「新标题」列进入本地编辑;双击其他列弹窗展示旧封面与新封面路径对应图片;图片不存在时显示空态/路径提示,只做查看,不做审核。 -- 底部「开始生成」只处理当前筛选结果里可补齐的任务;批次/店铺/商品ID/状态筛选共同决定当前筛选结果;②「生成内容」下拉支持只生成标题、只生成封面、生成标题和封面,选择只生成封面时任务必须已有 `new_title`;点击「开始生成」时先清空 `GenerateTab` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;通过 `GenerateWorker` 调 `ai.generate_batch()`,按当前 `ai.generate_mode` 并发标题和/或封面。cmhub 模式开始摘要显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”,其中 `Y=min(X,5)`。 -- 「停止」调用 worker 的协作式 `cancel()`;未开始的 Future 取消,不记失败;已完成的 `db.set_generated()` 结果保留,可再次开始生成剩余任务。 -- 进度标签显示标题/图片/失败;只生成标题时图片进度显示本轮未生成,标题成功即 `db.set_generated(task_id, new_title, None)` 并进入 generated;包含封面时每条封面生成成功后立即 `db.set_generated(task_id, new_title, new_cover_path)`;只生成封面时不调用标题生成、不覆盖已有标题。失败 `db.mark_failed(..., "generate", error)`,GUI 刷新任务表。 +- 底部「开始生成」只处理当前筛选结果里可补齐的任务;批次/店铺/商品ID/状态筛选共同决定当前筛选结果;②「生成内容」下拉支持只生成标题、只生成封面、生成标题和封面。只生成封面时不调用生文,任务有新标题则使用新标题,否则使用已采集旧标题作为封面prompt回退;新旧标题都为空才不纳入。点击「开始生成」时先清空 `GenerateTab` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;通过 `GenerateWorker` 调 `ai.generate_batch()`,按当前 `ai.generate_mode` 并发标题和/或封面。cmhub 模式开始摘要显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”,其中 `Y=min(X,5)`。 +- 「停止」调用 worker 的协作式 `cancel()`;未开始的 Future 取消,不记失败;已完成的 `db.set_generated()` / `db.set_generated_cover()` 组件结果保留,可再次开始生成剩余任务。 +- 进度标签显示标题/图片/失败;只生成标题时图片进度显示本轮未生成,标题成功即 `db.set_generated(task_id, new_title, existing_cover_path)` 并进入 generated;封面成功立即调用 `db.set_generated_cover(task_id, new_cover_path)`,只更新封面、不覆盖或伪造新标题。只生成封面得到“有封面、无新标题”时,②标题状态继续待生成、图片状态已生成。失败 `db.mark_failed(..., "generate", error)`,GUI 刷新任务表。 - T-404a 已实现:「重置生成结果」读取当前选中单条;确认后调用 DB reset,清空本地 AI 结果并退回可生成状态;默认不删除本地新封面文件;写 `run_type=reset` 事件。 ③ 更新蝦皮当前要点(T-303b/T-401/T-402/T-403): diff --git a/docs/routes.md b/docs/routes.md index d7a8dce..f2e0b24 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -105,9 +105,9 @@ - 右上:按导入批次 / 店铺 / 商品ID / 状态筛选任务;商品ID输入框按包含匹配 `item_id`,清空表示全部。 - 筛选行提供「打开图片文件夹」按钮,用于只读打开本地图片目录:选中某行时打开该商品所在账号图片文件夹(优先打开已有新/旧封面文件的真实父目录,缺失时回退到规范账号目录);未选行且选择具体批次时打开该批次图片文件夹;未选行且为全部批次时打开图片根目录。目录不存在只中文提示,不自动创建目录,不修改任务状态。 - 右下:任务列表(店铺名、商品id、旧标题、新标题、标题状态、图片状态)+ AI生成运行日志;标题/图片状态由 `new_title`、`new_cover_path`、`stage/status` 和失败步骤推导,帮助用户区分“标题未生成 / 图片未生成 / 标题成功但图片失败”。商品ID列按原等分宽度约 50% 显示;标题状态和图片状态列在 T-554 基础上再缩到约 33%,缩出的宽度平均给旧标题和新标题。已生成、未提交线上、非运行中的任务可双击「新标题」列本地微调,写回 `tasks.new_title`,清空 `last_error` 并回到可更新;双击其他列弹窗展示旧封面、新封面和历史候选图。 -- 底部**单个「开始生成」+「停止」**,并增加「生成内容」下拉:默认只生成标题,可选只生成封面或生成标题和封面;只生成封面要求任务已有新标题。标题/图片两条进度条右侧分别显示同宽用时标签(`生标题用时 N 秒` / `生图用时 N 秒`),运行中每秒递增,完成/停止后冻结;原图片进度条右侧的失败数和 cmhub 余额不再占用该位置。cmhub 模式会把用户设置的图片并发内部限制到最大 5,并用同样最大 5 的独立下载线程池拉取 `image_url`,不新增用户可见下载并发配置;运行日志显示用户设置并发和实际并发。下拉状态持久化到 `config.json` 的 `ai.generate_mode`,并继续写回旧兼容 `ai.generate_cover`。 +- 底部**单个「开始生成」+「停止」**,并增加「生成内容」下拉:默认只生成标题,可选只生成封面或生成标题和封面;只生成封面不调用生文,有新标题时优先使用,没有时用已采集旧标题作为封面prompt参考,新旧标题都为空才不纳入。标题/图片两条进度条右侧分别显示同宽用时标签(`生标题用时 N 秒` / `生图用时 N 秒`),运行中每秒递增,完成/停止后冻结;原图片进度条右侧的失败数和 cmhub 余额不再占用该位置。cmhub 模式会把用户设置的图片并发内部限制到最大 5,并用同样最大 5 的独立下载线程池拉取 `image_url`,不新增用户可见下载并发配置;运行日志显示用户设置并发和实际并发。下拉状态持久化到 `config.json` 的 `ai.generate_mode`,并继续写回旧兼容 `ai.generate_cover`。 - 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、cmhub 网关/Key/别名)在 **⑤ 设置**;②只暴露本轮生成标题/封面/图文的内容模式。⑤ 不新增“下载并发”控件;cmhub 图片下载并发由程序按实际生图并发自动计算,最大 5。 -- 只生成标题时标题成功即写库并进入 `generated`,`new_cover_path` 留空;只生成封面时不调用标题生成、不覆盖已有标题;生成标题和封面时按缺失组件增量补齐。三种模式都写 `run_type=generate` 的 `run_logs/run_log_events` 和用户可读滚动日志;日志开头明确显示本轮生成内容。点击「开始生成」时先清空②界面可见日志并写入本轮开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`。进入页面默认可显示“本轮日志会在开始运行后显示”,历史日志不自动混入当前运行界面。「停止」取消未开始项,可再次「开始生成」对剩余继续。 +- 只生成标题时标题成功即写库并进入 `generated`,保留已有封面;只生成封面时通过组件级写库只保存 `new_cover_path`,不覆盖已有标题,也不把旧标题写入空的 `new_title`;生成标题和封面时按缺失组件增量补齐。“有新封面、无新标题”时标题状态为待生成、图片状态为已生成,后续补标题不重复生图。三种模式都写 `run_type=generate` 的 `run_logs/run_log_events` 和用户可读滚动日志;日志开头明确显示本轮生成内容。点击「开始生成」时先清空②界面可见日志并写入本轮开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`。进入页面默认可显示“本轮日志会在开始运行后显示”,历史日志不自动混入当前运行界面。「停止」取消未开始项,可再次「开始生成」对剩余继续。 - 「重置生成结果」支持选中任务或当前筛选结果,运行中禁用;确认框提供「重置标题 / 重置封面 / 重置全部」,只改本地 DB,默认不删除本地新封面文件。若范围内包含已提交线上记录,必须提示本地重置不回滚蝦皮,重生成后再更新会再次提交线上。 - **无逐条人工审核环节**;新标题默认使用 AI 输出,但允许对已生成且未提交线上的单行做本地微调。封面画廊内「重置图片」只清当前任务 `new_cover_path` 并归档旧图,不启动单条生图;用户翻看完后用状态筛选「待生成」并点击「开始生成」批量补封面。生成完即可进入 ③,③ 开始更新前会做批量确认。 diff --git a/docs/tasks/T-621.md b/docs/tasks/T-621.md index 4d981f7..26464ef 100644 --- a/docs/tasks/T-621.md +++ b/docs/tasks/T-621.md @@ -3,7 +3,7 @@ id: T-621 title: ②只生成封面支持无新标题任务并保持图文结果独立 phase: 7 deps: [T-533, T-554, T-576] -status: TODO +status: DONE created: 2026-07-13 --- @@ -111,4 +111,8 @@ created: 2026-07-13 ## 执行记录 -(完成后记录实现、验证命令与结果。) +- 2026-07-13:`app/ai.py` 新增统一的封面标题上下文,`cover` 模式按 `new_title` 优先、`old_title` 回退判断资格并渲染prompt;`title_cover` 仍须先生成新标题,不用旧标题绕过生文失败。 +- 2026-07-13:`app/db.py` 新增组件级 `set_generated_cover()`,封面成功只更新 `new_cover_path`,保留 `new_title`(包括NULL),并按AI成功口径更新阶段、状态、尝试次数和时间。 +- 2026-07-13:②表格改为持有精确 `generate_mode`;只生成封面时,无新标题但有旧标题的任务可进入待生成筛选,封面成功后保持“标题待生成 / 图片已生成”。封面prompt预览复用同一回退规则,运行日志明确提示使用旧标题作为封面参考。 +- 2026-07-13:补充AI、DB、GUI和③更新模式测试,覆盖无新标题独立生图、旧标题不回写、后补标题保留封面、状态/筛选/预览/进度,以及只有“只更新封面”放行该组件结果;同步需求、架构、API和界面流程文档。 +- 2026-07-13:主工作区GUI全量测试仅有2项既有默认提示词重命名断言失败,与本任务无关;在只包含T-621差异的干净worktree运行 `python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`(429项通过)、`git diff --check`,结果全部通过。 diff --git a/tests/test_ai.py b/tests/test_ai.py index be267d3..f339670 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -129,6 +129,32 @@ class AITests(TempDirMixin, unittest.TestCase): ) return batch_id, db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + def test_cover_title_context_and_cover_only_generation_needs(self): + task = SimpleNamespace( + stage="collected", + status="success", + old_title="旧标题", + new_title=None, + new_cover_path=None, + apply_attempts=0, + ) + + self.assertEqual("旧标题", ai.cover_title_context(task)) + self.assertEqual( + {"title": False, "cover": True}, + ai.generation_needs(task, generate_mode="cover"), + ) + task.new_title = "新标题" + self.assertEqual("新标题", ai.cover_title_context(task)) + task.new_title = None + task.old_title = "" + self.assertEqual("", ai.cover_title_context(task)) + self.assertFalse(ai.is_generatable_task(task, generate_mode="cover")) + self.assertEqual( + {"title": True, "cover": True}, + ai.generation_needs(task, generate_mode="title_cover"), + ) + def _write_old_cover_files(self, tasks): from PIL import Image @@ -1644,6 +1670,68 @@ class AITests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_generate_batch_cover_only_uses_old_title_and_keeps_new_title_empty(self): + with self.make_temp_dir() as temp_dir: + cfg = self._config() + cfg["db_path"] = os.path.join(temp_dir, "cmshopee.db") + cfg["image_dir"] = os.path.join(temp_dir, "images") + cfg["ai"]["generate_mode"] = "cover" + cfg["ai"]["generate_cover"] = True + batch_id, tasks = self._collected_tasks(temp_dir, cfg, ["旧标题"]) + cover_prompts = [] + events = [] + + def fake_cover(cover_prompt, old_cover_path, out_path, **kwargs): + cover_prompts.append(cover_prompt) + os.makedirs(os.path.dirname(out_path), exist_ok=True) + with open(out_path, "wb") as fh: + fh.write(b"jpeg") + return out_path + + with mock.patch("app.ai.gen_title") as gen_title, \ + mock.patch("app.ai.gen_cover", side_effect=fake_cover) as gen_cover: + summary = ai.generate_batch( + tasks, + {"title": "标题提示", "cover": "封面 {新标题} / {旧标题}"}, + ai_cfg={ + "config": cfg, + "db_path": cfg["db_path"], + "on_event": events.append, + }, + ) + + self.assertTrue(summary["ok"]) + self.assertEqual(0, summary["title_total"]) + self.assertEqual(1, summary["cover_total"]) + gen_title.assert_not_called() + gen_cover.assert_called_once() + self.assertEqual(["封面 旧标题 / 旧标题"], cover_prompts) + updated = db.get_task(tasks[0].id, path=cfg["db_path"]) + self.assertIsNone(updated.new_title) + self.assertTrue(os.path.exists(updated.new_cover_path)) + self.assertTrue( + any("使用旧标题作为封面参考" in str(event.get("detail") or "") for event in events) + ) + + cfg["ai"]["generate_mode"] = "title" + cfg["ai"]["generate_cover"] = False + with mock.patch("app.ai.gen_title", return_value="后补新标题") as gen_title, \ + mock.patch("app.ai.gen_cover") as gen_cover: + title_summary = ai.generate_batch( + [updated], + {"title": "标题提示", "cover": "封面"}, + ai_cfg={"config": cfg, "db_path": cfg["db_path"]}, + ) + + self.assertTrue(title_summary["ok"]) + gen_title.assert_called_once() + gen_cover.assert_not_called() + completed = db.get_task(tasks[0].id, path=cfg["db_path"]) + self.assertEqual("后补新标题", completed.new_title) + self.assertEqual(updated.new_cover_path, completed.new_cover_path) + + self.assert_removed(temp_dir) + def test_generate_batch_fills_reset_cover_after_committed_history(self): with self.make_temp_dir() as temp_dir: cfg = self._config() diff --git a/tests/test_db.py b/tests/test_db.py index d330581..4047e1b 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -217,6 +217,49 @@ class DbTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_set_generated_cover_preserves_title_and_records_ai_success(self): + with self.make_temp_dir() as temp_dir: + db_path = os.path.join(temp_dir, "cmshopee.db") + db.init_db(db_path) + batch_id = db.create_batch(["input.xlsx"], path=db_path) + db.insert_tasks( + batch_id, + [ + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "Sheet1", + "source_row": row, + "account_name": "shop", + "alias": "alias", + "item_id": str(51100639510 + row), + } + for row in (2, 3) + ], + path=db_path, + ) + tasks = db.list_tasks(batch_id=batch_id, path=db_path) + for task in tasks: + db.set_collected(task.id, "旧标题", "old.jpg", path=db_path) + db.set_generated(tasks[1].id, "已有新标题", None, path=db_path) + before_with_title = db.get_task(tasks[1].id, path=db_path) + + db.set_generated_cover(tasks[0].id, "cover-a.jpg", path=db_path) + db.set_generated_cover(tasks[1].id, "cover-b.jpg", path=db_path) + + without_title = db.get_task(tasks[0].id, path=db_path) + with_title = db.get_task(tasks[1].id, path=db_path) + self.assertIsNone(without_title.new_title) + self.assertEqual("cover-a.jpg", without_title.new_cover_path) + self.assertEqual("generated", without_title.stage) + self.assertEqual("success", without_title.status) + self.assertEqual(1, without_title.generate_attempts) + self.assertIsNotNone(without_title.generated_at) + self.assertEqual("已有新标题", with_title.new_title) + self.assertEqual("cover-b.jpg", with_title.new_cover_path) + self.assertEqual(before_with_title.generate_attempts + 1, with_title.generate_attempts) + + self.assert_removed(temp_dir) + def test_image_task_helpers_and_reset_lifecycle(self): with self.make_temp_dir() as temp_dir: db_path = os.path.join(temp_dir, "cmshopee.db") diff --git a/tests/test_gui.py b/tests/test_gui.py index 96bd69b..a8e472f 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -3272,6 +3272,21 @@ class GuiTests(TempDirMixin, unittest.TestCase): apply_attempts=0, committed=0, ), + SimpleNamespace( + alias="papa", + account_name="papa 店铺", + item_id="1006", + old_title="旧标题6", + new_title="", + new_cover_path="cover-only.jpg", + stage="generated", + status="success", + last_error="", + collect_attempts=1, + generate_attempts=1, + apply_attempts=0, + committed=0, + ), ] model = gui.GenerateTaskTableModel() model.set_tasks(tasks, [account]) @@ -3290,6 +3305,13 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual("待生成", model.index(3, 5).data()) self.assertEqual("已生成", model.index(4, 5).data()) + model.set_generate_mode("cover") + self.assertEqual("待生成", model.index(1, 5).data()) + self.assertEqual("待生成", model.index(3, 5).data()) + self.assertEqual("已生成", model.index(4, 5).data()) + self.assertEqual("待生成", model.index(5, 4).data()) + self.assertEqual("已生成", model.index(5, 5).data()) + tab = GenerateTab(config=self.make_config(temp_dir)) self.addCleanup(tab.close) tab.task_table.resize(600, 240) @@ -3920,6 +3942,47 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_generate_tab_cover_preview_uses_old_title_when_new_title_is_missing(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + cfg["ai"] = appconfig.ai_config(cfg) + cfg["ai"]["generate_mode"] = "cover" + cfg["ai"]["generate_cover"] = True + accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) + batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"]) + db.insert_tasks( + batch_id, + [ + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "商品", + "source_row": 2, + "account_name": "Excel主店", + "alias": "alias-a", + "item_id": "51100639510", + } + ], + path=cfg["db_path"], + ) + task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] + db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"]) + tab = GenerateTab( + config=cfg, + title_prompt_path=os.path.join(temp_dir, "title_prompt.txt"), + cover_prompts_dir=os.path.join(temp_dir, "prompts", "cover"), + title_templates_dir=os.path.join(temp_dir, "prompts", "title"), + ) + self.addCleanup(tab.close) + tab.cover_prompt_edit.setPlainText("新:{新标题};旧:{旧标题}") + tab.task_table.selectRow(0) + + with mock.patch("app.gui.QMessageBox.information") as information: + tab.preview_cover_prompt() + + self.assertEqual("新:旧标题;旧:旧标题", information.call_args[0][2]) + + self.assert_removed(temp_dir) + def test_cover_gallery_lists_candidates_and_selects_current_cover(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) @@ -5403,7 +5466,6 @@ class GuiTests(TempDirMixin, unittest.TestCase): ) task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"]) - db.set_generated(task.id, "手动标题", None, path=cfg["db_path"]) tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) logs = [] progress = [] @@ -5436,14 +5498,14 @@ class GuiTests(TempDirMixin, unittest.TestCase): gen_title.assert_not_called() gen_cover.assert_called_once() updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] - self.assertEqual("手动标题", updated.new_title) + self.assertIsNone(updated.new_title) self.assertTrue(os.path.exists(updated.new_cover_path)) self.assertEqual(0, progress[-1]["title_total"]) self.assertEqual(1, progress[-1]["cover_total"]) joined_logs = "\n".join(logs) self.assertIn("本轮生成内容:只生成封面", joined_logs) self.assertIn("标题0,图片1", joined_logs) - self.assertIn("已有标题,跳过生文", joined_logs) + self.assertIn("没有新标题,本轮使用旧标题作为封面参考", joined_logs) self.assertIn("[完成] AI 生成完成:标题0/0,图片1/1,失败0", joined_logs) self.assert_removed(temp_dir) @@ -5554,9 +5616,10 @@ class GuiTests(TempDirMixin, unittest.TestCase): ) task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"]) - db.set_generated(task.id, "手动标题", None, path=cfg["db_path"]) tab = GenerateTab(config=cfg) self.addCleanup(tab.close) + tab.status_filter.setCurrentIndex(tab.status_filter.findData("to_generate")) + self.assertEqual(1, tab.model.rowCount()) class FakeSignal: def __init__(self): @@ -6137,6 +6200,38 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_apply_tab_allows_cover_only_result_but_blocks_modes_that_need_title(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg) + batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"]) + db.insert_tasks( + batch_id, + [ + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "商品", + "source_row": 2, + "account_name": "Excel主店", + "alias": "alias-a", + "item_id": "51100639510", + } + ], + path=cfg["db_path"], + ) + task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0] + db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"]) + db.set_generated_cover(task.id, "new-cover.jpg", path=cfg["db_path"]) + generated = db.get_task(task.id, path=cfg["db_path"]) + tab = ApplyTab(config=cfg) + self.addCleanup(tab.close) + + self.assertIsNone(tab._update_content_error([generated], "cover")) + self.assertIn("缺少新标题", tab._update_content_error([generated], "title")) + self.assertIn("缺少新标题", tab._update_content_error([generated], "title_cover")) + + self.assert_removed(temp_dir) + def test_apply_worker_applies_success_failure_and_unmatched_serially(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir)