From 90a054a14d12caa886b4f9333eda78e60f0005b5 Mon Sep 17 00:00:00 2001 From: chengma Date: Thu, 9 Jul 2026 18:15:14 +0800 Subject: [PATCH] =?UTF-8?q?T-577=20=E5=B0=81=E9=9D=A2=E7=94=BB=E5=BB=8A?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/gui/tabs/generate.py | 216 +++++++---------------------------- docs/02-requirements.md | 2 +- docs/04-architecture.md | 2 +- docs/routes.md | 6 +- docs/tasks/T-577.md | 31 +++-- docs/ui/README.md | 2 +- docs/ui/tab2-ai-generate.svg | 2 +- tests/test_gui.py | 161 +++++++++++--------------- 8 files changed, 135 insertions(+), 287 deletions(-) diff --git a/app/gui/tabs/generate.py b/app/gui/tabs/generate.py index 218a305..906e8a0 100644 --- a/app/gui/tabs/generate.py +++ b/app/gui/tabs/generate.py @@ -141,10 +141,7 @@ class CoverGalleryDialog(QDialog): account=None, parent=None, config=None, - prompt_values=None, - diagnostic_log_dir=None, bulk_running_checker=None, - regenerate_running_callback=None, gallery_updated_callback=None, visible_tasks=None, task_index=None, @@ -160,10 +157,7 @@ class CoverGalleryDialog(QDialog): self.fallback_account = account self.account = self._account_for_task(self.task) or account self.config = config or {} - self.prompt_values = dict(prompt_values or {}) - self.diagnostic_log_dir = diagnostic_log_dir self.bulk_running_checker = bulk_running_checker - self.regenerate_running_callback = regenerate_running_callback self.gallery_updated_callback = gallery_updated_callback self.current_path = _normalize_file_path(getattr(task, "new_cover_path", None)) self.selected_path = None @@ -171,10 +165,6 @@ class CoverGalleryDialog(QDialog): self.candidate_buttons = {} self.button_group = QButtonGroup(self) self.button_group.setExclusive(True) - self.regenerate_worker = None - self.regenerate_thread = None - self.regenerating_task_id = None - self._regenerate_last_error = "" self.candidates = image_paths.list_task_cover_candidates(image_root, self.task, account=self.account) self.setWindowTitle(f"封面画廊:{getattr(task, 'item_id', '')}") @@ -191,9 +181,9 @@ class CoverGalleryDialog(QDialog): self.next_button.clicked.connect(lambda checked=False: self.switch_task(1)) button_layout.addWidget(self.previous_button) button_layout.addWidget(self.next_button) - self.regenerate_button = QPushButton("重新生成") - self.regenerate_button.clicked.connect(self.regenerate_cover) - button_layout.addWidget(self.regenerate_button) + self.reset_cover_button = QPushButton("重置图片") + self.reset_cover_button.clicked.connect(self.reset_cover_image) + button_layout.addWidget(self.reset_cover_button) button_layout.addStretch(1) self.save_hint_label = QLabel("") self.save_hint_label.setObjectName("coverGallerySaveHintLabel") @@ -209,7 +199,7 @@ class CoverGalleryDialog(QDialog): self._sync_initial_selection() self._update_status() self._update_navigation_buttons() - self._update_regenerate_button_state() + self._update_reset_cover_button_state() self._fit_to_screen() self._install_navigation_event_filters() @@ -363,9 +353,6 @@ class CoverGalleryDialog(QDialog): self._update_status() def _update_status(self): - if self._is_current_task_regenerating(): - self.status_label.setText("封面重新生成中,请稍候") - return if not self.candidates: self.status_label.setText("暂无生成封面图片") self.save_button.setEnabled(False) @@ -426,17 +413,15 @@ class CoverGalleryDialog(QDialog): def _set_save_hint(self, message): self.save_hint_label.setText(str(message or "")) - def regenerate_cover(self, checked=False): - if self.regenerate_thread is not None: + def reset_cover_image(self, checked=False): + if self._is_bulk_generate_running(): + self.status_label.setText("AI 生成正在进行,不能重置图片") + self._update_reset_cover_button_state() return False - if self._bulk_generate_running(): - self.status_label.setText("②批量生成正在运行,完成后才能重新生成单个封面") - self._update_regenerate_button_state() + if not str(getattr(self.task, "new_cover_path", "") or "").strip(): + self.status_label.setText("当前任务没有可重置的新封面图片") return False - if not str(getattr(self.task, "new_title", "") or "").strip(): - QMessageBox.warning(self, "重新生成封面", "当前任务缺少新标题,不能单独重新生成封面") - return False - if not self._confirm_regenerate(): + if self._needs_committed_confirmation() and not self._confirm_committed_reset(): return False try: db.reset_generated( @@ -447,47 +432,22 @@ class CoverGalleryDialog(QDialog): ) fresh_task = db.get_task(self.task.id, path=self.db_path) except Exception as exc: - QMessageBox.warning(self, "重新生成封面", str(exc)) - self.status_label.setText(f"重新生成封面失败:{exc}") + QMessageBox.warning(self, "重置图片", str(exc)) + self.status_label.setText(f"重置图片失败:{exc}") return False if fresh_task is None: - QMessageBox.warning(self, "重新生成封面", "任务不存在或已删除") - self.status_label.setText("重新生成封面失败:任务不存在或已删除") + QMessageBox.warning(self, "重置图片", "任务不存在或已删除") + self.status_label.setText("重置图片失败:任务不存在或已删除") return False - self.regenerating_task_id = getattr(fresh_task, "id", None) self._reload_task_and_candidates(fresh_task) - self.status_label.setText(f"开始重新生成商品 {getattr(fresh_task, 'item_id', '')} 的封面图片") - worker = GenerateWorker( - [fresh_task], - self.prompt_values, - db_path=self.db_path, - config=_cover_regenerate_config(self.config), - diagnostic_log_dir=self.diagnostic_log_dir, + self.status_label.setText( + "已重置图片,该行图片状态已改为待生成;" + "请在②选择「只生成封面」或「生成标题和封面」后点击开始生成" ) - worker.progress.connect(self._on_regenerate_progress) - worker.row_updated.connect(self._on_regenerate_row_updated) - worker.log.connect(self._on_regenerate_log) - worker.failed.connect(self._on_regenerate_failed) - worker.finished.connect(self._on_regenerate_finished) - worker.cancelled.connect(self._on_regenerate_cancelled) - thread = run_worker(worker, thread_name="CoverRegenerateWorker", start=False) - thread.finished.connect(lambda: self._forget_regenerate_thread(thread)) - self.regenerate_worker = worker - self.regenerate_thread = thread - self._set_regenerate_running(True) - thread.start() + if self.gallery_updated_callback is not None: + self.gallery_updated_callback(self.task) return True - def _confirm_regenerate(self): - box = QMessageBox(self) - box.setWindowTitle("重新生成封面") - box.setText("将重新生成当前商品封面,可能消耗 cmhub 点数。") - confirm_button = box.addButton("重新生成", QMessageBox.AcceptRole) - cancel_button = box.addButton("取消", QMessageBox.RejectRole) - box.setDefaultButton(cancel_button) - box.exec() - return box.clickedButton() is confirm_button - def _needs_committed_confirmation(self): return int(getattr(self.task, "committed", 0) or 0) == 1 or getattr(self.task, "stage", None) == "applied" @@ -503,6 +463,18 @@ class CoverGalleryDialog(QDialog): box.exec() return box.clickedButton() is save_button + def _confirm_committed_reset(self): + box = QMessageBox(self) + box.setWindowTitle("确认重置图片") + box.setText( + "该商品已经提交过线上。本地重置不会回滚蝦皮,重新生成并更新会再次提交线上。" + ) + reset_button = box.addButton("确认重置", QMessageBox.AcceptRole) + cancel_button = box.addButton("取消", QMessageBox.RejectRole) + box.setDefaultButton(cancel_button) + box.exec() + return box.clickedButton() is reset_button + def switch_task(self, offset): target_index = self.task_index + int(offset) if target_index < 0 or target_index >= len(self.visible_tasks): @@ -528,7 +500,7 @@ class CoverGalleryDialog(QDialog): ) self._reload_task_and_candidates(fresh_task) self._update_navigation_buttons() - self._update_regenerate_button_state() + self._update_reset_cover_button_state() def _update_navigation_buttons(self): self.previous_button.setEnabled(self.task_index > 0) @@ -611,34 +583,14 @@ class CoverGalleryDialog(QDialog): dialog = OriginalImageDialog(image_path, self) dialog.exec() - def _bulk_generate_running(self): + def _is_bulk_generate_running(self): return bool(self.bulk_running_checker and self.bulk_running_checker()) - def _is_current_task_regenerating(self): - return ( - self.regenerate_thread is not None - and self.regenerating_task_id is not None - and getattr(self.task, "id", None) == self.regenerating_task_id - ) - - def _set_regenerate_running(self, running): - self.save_button.setEnabled(not running and self.selected_path is not None) - self.regenerate_button.setEnabled(not running and not self._bulk_generate_running()) - self.regenerate_button.setToolTip( - "②批量生成正在运行,完成后才能重新生成单个封面" - if self._bulk_generate_running() - else ("正在重新生成当前商品封面" if running else "") - ) - if self.regenerate_running_callback is not None: - self.regenerate_running_callback(bool(running)) - - def _update_regenerate_button_state(self): - running = self.regenerate_thread is not None - self.regenerate_button.setEnabled(not running and not self._bulk_generate_running()) - self.regenerate_button.setToolTip( - "②批量生成正在运行,完成后才能重新生成单个封面" - if self._bulk_generate_running() - else ("正在重新生成当前商品封面" if running else "") + def _update_reset_cover_button_state(self): + running = self._is_bulk_generate_running() + self.reset_cover_button.setEnabled(not running) + self.reset_cover_button.setToolTip( + "AI 生成正在进行,完成后才能重置图片" if running else "" ) def _reload_task_and_candidates(self, task=None): @@ -671,58 +623,6 @@ class CoverGalleryDialog(QDialog): self._sync_candidate_content_size() self._install_navigation_event_filters() - def _on_regenerate_progress(self, payload): - if payload.get("failed"): - self.status_label.setText("封面重新生成失败,历史候选仍可选择保存") - - def _on_regenerate_row_updated(self, task_id, fields): - if self.gallery_updated_callback is not None: - self.gallery_updated_callback(self.task) - - def _on_regenerate_log(self, message): - text = str(message or "").strip() - if not text: - return - if text.startswith("[失败]"): - self._regenerate_last_error = text - self.status_label.setText(text) - elif "成功" in text or "完成" in text: - self.status_label.setText(text) - - def _on_regenerate_failed(self, task_id, error): - self._regenerate_last_error = str(error or "未知错误") - self.status_label.setText(f"封面重新生成失败:{self._regenerate_last_error}") - - def _on_regenerate_finished(self, summary): - self._finish_regenerate(summary, cancelled=False) - - def _on_regenerate_cancelled(self, summary): - self._finish_regenerate(summary, cancelled=True) - - def _finish_regenerate(self, summary, cancelled=False): - self.regenerating_task_id = None - fresh_task = db.get_task(self.task.id, path=self.db_path) - if fresh_task is not None: - self._reload_task_and_candidates(fresh_task) - self._set_regenerate_running(False) - if self.gallery_updated_callback is not None: - self.gallery_updated_callback(self.task) - if cancelled or summary.get("cancelled"): - self.status_label.setText("封面重新生成已停止,历史候选仍可选择保存") - return - if summary.get("ok") and summary.get("cover_done", 0) > 0: - self.status_label.setText("封面重新生成完成") - return - detail = self._regenerate_last_error or summary.get("error") or "请稍后重试" - self.status_label.setText(f"封面重新生成失败:{detail}") - - def _forget_regenerate_thread(self, thread): - if self.regenerate_thread is thread: - self.regenerate_thread = None - self.regenerate_worker = None - self.regenerating_task_id = None - self._set_regenerate_running(False) - def _fit_to_screen(self): available = _available_geometry() if available is None: @@ -747,15 +647,6 @@ def _clear_layout(layout): widget.deleteLater() -def _cover_regenerate_config(config): - runtime_config = dict(config or {}) - ai_settings = appconfig.ai_config(runtime_config) - ai_settings["generate_cover"] = True - ai_settings["generate_mode"] = "cover" - runtime_config["ai"] = ai_settings - return runtime_config - - def _build_image_label(image_path, size, empty_text): label = QLabel() label.setAlignment(Qt.AlignCenter) @@ -877,7 +768,6 @@ class GenerateTab(QWidget): self.generate_worker = None self.generate_thread = None self.generate_running = False - self.cover_gallery_regenerate_running = False self._title_elapsed_start = None self._cover_elapsed_start = None self._title_elapsed_seconds = 0 @@ -1458,9 +1348,6 @@ class GenerateTab(QWidget): self._set_status("封面提示词预览已生成") def start_generate(self, checked=False): - if self.cover_gallery_regenerate_running: - self._set_status("封面画廊正在重新生成,完成后再开始批量生成") - return if self.generate_thread is not None: self._set_status("AI 生成正在进行...") return @@ -1737,13 +1624,7 @@ class GenerateTab(QWidget): account=account, parent=self, config=self.config, - prompt_values={ - "title": self.title_prompt_edit.toPlainText(), - "cover": self.cover_prompt_edit.toPlainText(), - }, - diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, bulk_running_checker=lambda: self.generate_thread is not None, - regenerate_running_callback=self._set_cover_gallery_regenerate_running, gallery_updated_callback=self._on_cover_gallery_updated, visible_tasks=list(self.model.tasks), task_index=row if row >= 0 else 0, @@ -1758,9 +1639,9 @@ class GenerateTab(QWidget): def _set_generate_running(self, running): self.generate_running = bool(running) - self.generate_button.setEnabled(not running and not self.cover_gallery_regenerate_running) + self.generate_button.setEnabled(not running) self.stop_generate_button.setEnabled(running) - self.reset_generate_button.setEnabled(not running and not self.cover_gallery_regenerate_running) + self.reset_generate_button.setEnabled(not running) self.refresh_button.setEnabled(not running) self.batch_filter.setEnabled(not running) self.shop_filter.setEnabled(not running) @@ -1774,21 +1655,6 @@ class GenerateTab(QWidget): self.rename_cover_template_action.setEnabled(not running) self.delete_cover_template_action.setEnabled(not running) self.generate_mode_combo.setEnabled(not running) - self._sync_cover_gallery_regenerate_guard() - - def _set_cover_gallery_regenerate_running(self, running): - self.cover_gallery_regenerate_running = bool(running) - self._sync_cover_gallery_regenerate_guard() - - def _sync_cover_gallery_regenerate_guard(self): - if self.generate_thread is not None or self.generate_running: - return - blocked = bool(self.cover_gallery_regenerate_running) - self.generate_button.setEnabled(not blocked) - self.reset_generate_button.setEnabled(not blocked) - tooltip = "封面画廊正在重新生成,完成后再开始批量生成" if blocked else "" - self.generate_button.setToolTip(tooltip) - self.reset_generate_button.setToolTip(tooltip) def _on_cover_gallery_updated(self, task): self.refresh_tasks() diff --git a/docs/02-requirements.md b/docs/02-requirements.md index 4bb6da5..e4192f7 100644 --- a/docs/02-requirements.md +++ b/docs/02-requirements.md @@ -44,7 +44,7 @@ | 采集旧数据(①) | 程序只读打开商品页,抓取旧标题、下载旧封面到本地,回写 Excel 旧字段 | P0 | | AI 生成(②) | 输入标题/封面提示词,按②本轮「生成内容」下拉选择只生成标题、只生成封面或生成标题和封面;只生成封面要求任务已有新标题,避免用户无意产生图片模型成本;②任务表格拆分显示「标题状态 / 图片状态」,商品ID列缩窄约 50%,状态列再缩到当前约 33%,多出的宽度平均给旧标题和新标题;标题/图片两条进度条右侧分别显示生标题/生图实时用时秒数;点击「开始生成」时清空②界面旧日志,只显示本轮生成日志,历史 run_logs 仍保留 | P0 | | 提示词管理(②) | 标题提示词「保存」到 `data/title_prompt.txt` 并启动回显;封面提示词多模板(下拉 + 新建/保存/另存为/重命名/删除)+ 插入 `{新标题}` + 预览(变量替换) | P0 | -| 查看对照(②) | 双击任务弹窗查看新旧封面(纯查看,无逐条审核阶段);可选对单行重生成 | P0 | +| 查看对照(②) | 双击任务弹窗查看旧封面、新封面和历史候选图;可在弹窗内对当前行「重置图片」,退出后用状态筛选「待生成」批量补生成封面;不在弹窗内启动单条生图 | P0 | | 更新蝦皮(③) | 按批次/店铺/状态筛选;③左下角「更新内容」下拉选择只更新标题、只更新封面或更新标题和封面;点击「开始更新」前先校验所选内容已生成,缺失则中文弹窗阻断且不打开 Chrome;确认后对当前筛选出的已生成任务按每批最大条数分批更新蝦皮商品并点「更新」提交线上;普通正式更新不再受测试商品 ID 限制,可批量更新真实商品;可按状态=失败重试 | P0 | | 检查本轮更新 / 运行日志 / 多账号并行(③/⑤) | ③ 提供「检查本轮更新」按钮,不打开 Shopee、不提交、不改任务状态,只显示当前筛选范围、店铺分布、预计分批、更新内容和会更新字段;点击「检查本轮更新」或「开始更新」时清空③界面旧日志,只显示本轮检查/更新日志;真实更新写运行日志;可在⑤开启多账号并行,同账号内仍串行 | V2 已接入,检查按钮已接入 | | 结果存储与回写 | 各阶段结果实时存 SQLite;该文件全部完成后把旧/新数据+状态批量回写原 Excel | P0 | diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 7af1a23..6bea50d 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -420,7 +420,7 @@ data/images///__new. # AI 生成的新 - 只生成标题模式在标题成功后立即 `set_generated(task_id, new_title, NULL)` 写库;只生成封面模式不调用生文、不覆盖已有标题;生成标题和封面模式在封面成功后 `set_generated(task_id, new_title, new_cover_path)` 写库。三种模式都实时落库,停止或崩溃不丢已生成结果。 - **「停止」**:取消未开始的任务,正在跑的少量完成或中断;停止后可再次「开始生成」对剩余继续。 - 进度:标题和图片两条进度分开显示;只生成标题时图片进度显示本轮未生成/0 张,并在运行日志写明本轮生成内容。 -- 生成后 stage=generated;**不设逐条人工审核阶段**。若未生成封面,任务仍可进入③并选择只更新标题;若后续需要封面,用户可在②选择只生成封面或生成标题和封面补齐。双击任务弹窗查看新旧封面(纯查看,无新封面时显示为空);T-404a 已提供对当前选中单条的「重置生成结果」,确认后只清本地 AI 结果并退回 collected 供重新生成,默认不删除本地新封面文件;新标题直接用 AI 输出(不可编辑)。 +- 生成后 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”。 提示词管理: diff --git a/docs/routes.md b/docs/routes.md index 52825e9..703cb67 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -98,12 +98,12 @@ - **封面提示词**:模板下拉(读 `data/prompts/cover/*.txt`)+ 图标工具栏(新建/保存/另存为/重命名/删除)+ 多行输入 + 「插入标题」(插 `{新标题}`)/「预览」(变量替换后查看)。 - 变量:标题提示词本阶段只支持 `{旧标题}`;封面提示词支持 `{旧标题}`/`{新标题}`/`{商品id}`/`{店铺}`,生成前按任务替换。 - 右上:按导入批次 / 店铺 / 商品ID / 状态筛选任务;商品ID输入框按包含匹配 `item_id`,清空表示全部。 -- 右下:任务列表(店铺名、商品id、旧标题、新标题、标题状态、图片状态)+ AI生成运行日志;标题/图片状态由 `new_title`、`new_cover_path`、`stage/status` 和失败步骤推导,帮助用户区分“标题未生成 / 图片未生成 / 标题成功但图片失败”。商品ID列按原等分宽度约 50% 显示;标题状态和图片状态列在 T-554 基础上再缩到约 33%,缩出的宽度平均给旧标题和新标题。已生成、未提交线上、非运行中的任务可双击「新标题」列本地微调,写回 `tasks.new_title`,清空 `last_error` 并回到可更新;双击其他列弹窗展示旧封面 | 新封面(纯查看)。 +- 右下:任务列表(店铺名、商品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`。 - 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、cmhub 网关/Key/别名)在 **⑤ 设置**;②只暴露本轮生成标题/封面/图文的内容模式。⑤ 不新增“下载并发”控件;cmhub 图片下载并发由程序按实际生图并发自动计算,最大 5。 - 只生成标题时标题成功即写库并进入 `generated`,`new_cover_path` 留空;只生成封面时不调用标题生成、不覆盖已有标题;生成标题和封面时按缺失组件增量补齐。三种模式都写 `run_type=generate` 的 `run_logs/run_log_events` 和用户可读滚动日志;日志开头明确显示本轮生成内容。点击「开始生成」时先清空②界面可见日志并写入本轮开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`。进入页面默认可显示“本轮日志会在开始运行后显示”,历史日志不自动混入当前运行界面。「停止」取消未开始项,可再次「开始生成」对剩余继续。 -- 「重置生成结果」仅作用当前选中单条,运行中禁用;确认后只改本地 DB,清空 `new_title/new_cover_path/last_error` 并退回 `collected/success` 供重新生成,默认不删除本地新封面文件。若该记录已提交过线上,必须在确认框提示本地重置不回滚蝦皮。 -- **无逐条人工审核环节**;新标题默认直接用 AI 输出,但允许对已生成且未提交线上的单行做本地微调;可选对单行 `重生成`。生成完即可进入 ③,③ 开始更新前会做批量确认。 +- 「重置生成结果」支持选中任务或当前筛选结果,运行中禁用;确认框提供「重置标题 / 重置封面 / 重置全部」,只改本地 DB,默认不删除本地新封面文件。若范围内包含已提交线上记录,必须提示本地重置不回滚蝦皮,重生成后再更新会再次提交线上。 +- **无逐条人工审核环节**;新标题默认使用 AI 输出,但允许对已生成且未提交线上的单行做本地微调。封面画廊内「重置图片」只清当前任务 `new_cover_path` 并归档旧图,不启动单条生图;用户翻看完后用状态筛选「待生成」并点击「开始生成」批量补封面。生成完即可进入 ③,③ 开始更新前会做批量确认。 ## ③ 更新蝦皮 diff --git a/docs/tasks/T-577.md b/docs/tasks/T-577.md index dd3c9ec..5c9a588 100644 --- a/docs/tasks/T-577.md +++ b/docs/tasks/T-577.md @@ -3,7 +3,7 @@ id: T-577 title: ②封面画廊「重新生成」改为「重置图片」:删单任务重生链路,复用筛选+批量生成 phase: 7 deps: [T-566, T-568] -status: TODO +status: DONE created: 2026-07-09 --- @@ -19,18 +19,18 @@ T-568 已实现弹窗内「重新生成」:确认弹窗 → 重置封面 → - 筛选:状态下拉已有「待生成」(`generate.py:834`,`to_generate` → `ai.is_generatable_task`); - 批量生成:`start_generate` 作用于当前筛选结果、只生成缺失内容(`generate.py:1477`)。 -收益:删掉单任务重生这条第二生成路径及其互斥机械,系统回到**唯一生成路径**(批量),双 worker 抢 DB/并发池的风险类别永久消失;N 次「确认→串行单图重生」变一次「开始生成」(并发 5、一次点数确认)。重置可撤销(旧图归档在画廊里,选中再「保存」即恢复指针),故**不需要重确认弹窗**(T-568 的弹窗 C 一并删除)。 +收益:删掉单任务重生这条第二生成路径及其互斥机械,系统回到**唯一生成路径**(批量),双 worker 抢 DB/并发池的风险类别永久消失;N 次「确认→串行单图重生」变一次「开始生成」(并发 5、一次点数确认)。重置本身不触碰蝦皮;若旧图文件存在且归档成功,可在画廊里选中归档图再「保存」恢复指针,因此普通行**不需要重确认弹窗**(T-568 的弹窗 C 一并删除)。 ## 方案(改哪个文件、改成什么) ### `app/gui/tabs/generate.py` — 画廊弹窗按钮改造 - 「重新生成」按钮改名「重置图片」,点击后: - 1. 复用 ② 重置的同一条底层路径(`reset_generated(task_id, reset_title=False, reset_cover=True)`),**不复制逻辑**;继承既有守卫:批量生成运行中 → 拒绝并提示(同 `generate.py:1537`「AI 生成正在进行,不能重置」口径);归档改名 `PermissionError` → 中止并弹中文提示(T-566 口径)。 + 1. 复用 ② 重置的同一条底层路径(`reset_generated(task_id, reset_title=False, reset_cover=True)`),**不复制逻辑**;保留批量运行判断:批量生成运行中 → 拒绝并提示(同 `generate.py:1537`「AI 生成正在进行,不能重置」口径);归档改名 `PermissionError` → 中止并弹中文提示(T-566 口径),且 DB 指针不清空。 2. 成功后刷新②列表该行(图片状态变「待生成」)+ 重新枚举画廊(规范名已归档改名,列表变化)。 - 3. 左下状态 label 提示:「已重置图片,该行状态已改为待生成;请在②选『仅封面/标题和封面』生成模式后点开始生成」。 + 3. 左下状态 label 提示:「已重置图片,该行图片状态已改为待生成;请在②选择『只生成封面』或『生成标题和封面』后点击开始生成」。 4. **不弹确认弹窗**(重置可撤销:归档图仍在画廊,选中→保存即恢复);committed/applied 行例外,见下。 -- **删除单任务重生链路**(T-568 引入的机械):`regenerate_thread`、单任务 `GenerateWorker` 启动、`_bulk_generate_running`/`_set_regenerate_running`/`_update_regenerate_button_state`/`regenerate_running_callback`、②「开始生成」对单任务重生的感知禁用、弹窗 C 确认、完成/失败刷新回调。`rg regenerate` 核对无残留死代码。 +- **删除单任务重生链路**(T-568 引入的机械):`regenerate_thread`、单任务 `GenerateWorker` 启动、`_set_regenerate_running`/`_update_regenerate_button_state`/`regenerate_running_callback`、②「开始生成」对单任务重生的感知禁用、弹窗 C 确认、完成/失败刷新回调。批量运行检查不能删,只能改为通用重置守卫(例如保留 `bulk_running_checker` 或改名为 `_is_bulk_generate_running()`)。`rg regenerate` 核对无残留死代码。 ### 重置后弹窗状态语义(防 T-569 拦截误触发) @@ -44,7 +44,7 @@ T-568 已实现弹窗内「重新生成」:确认弹窗 → 重置封面 → ### 文档同步 -- T-568 保持 DONE(历史),本任务在其之上改造;`docs/04-architecture.md` / 相关 GUI 文档里「弹窗内单任务重新生成」的表述更新为「重置图片 + 批量补生成」。 +- T-568 保持 DONE(历史),本任务在其之上改造;`docs/04-architecture.md`、`docs/02-requirements.md`、`docs/routes.md`、`docs/ui/README.md`、`docs/ui/tab2-ai-generate.svg` 等仍描述「单行重生成 / 弹窗内单任务重新生成」的位置更新为「重置图片 + 批量补生成」。历史任务文件无需回写。 ## 验收要点 @@ -54,8 +54,9 @@ T-568 已实现弹窗内「重新生成」:确认弹窗 → 重置封面 → - 批量生成运行中点「重置图片」→ 拒绝并提示,不改 DB。 - committed/applied 行重置前弹警示,取消则无副作用;普通行不弹确认。 - 归档改名失败(文件占用)→ 中止、DB 指针不清(T-566 口径不回归)。 -- 单任务重生机械已删净:`rg "regenerate_thread|_set_regenerate_running|_bulk_generate_running"` 无残留;②「开始生成」不再有单任务重生感知逻辑。 +- 单任务重生机械已删净:`rg "regenerate_thread|_set_regenerate_running|regenerate_running_callback|CoverRegenerateWorker"` 无残留;②「开始生成」不再有单任务重生感知逻辑。批量运行判断可保留,但命名不应再表达「重新生成」。 - 生成模式为「仅标题」时:重置行不出现在「待生成」筛选、图片状态列走未启用分支——界面不误导(label 提示已写明要选含封面模式)。 +- 旧 `regenerate` GUI 单测要替换为重置语义单测:普通行重置不弹确认、不启动 `GenerateWorker`;committed/applied 行确认后才重置;批量生成中拒绝重置且 DB 不变;重置后无选中切换/关闭不触发未保存确认;选择归档图保存可恢复指针。 - 验证命令(unittest,不引入 pytest): - `py -3.10 -m unittest tests.test_gui tests.test_db` - `python -m ruff check app tests main.py` @@ -73,4 +74,18 @@ T-568 已实现弹窗内「重新生成」:确认弹窗 → 重置封面 → ## 执行记录 -(做完在这里写:改了什么文件、跑了什么验证命令及结果、遇到的阻塞、关键决策。) +- 2026-07-09:已完成。 +- 代码: + - `app/gui/tabs/generate.py`:封面画廊底部「重新生成」改为「重置图片」;点击后只调用 `db.reset_generated(task_id, reset_title=False, reset_cover=True)` 清空当前任务封面指针并重新枚举候选图,不再启动单任务 `GenerateWorker`。 + - 删除弹窗单任务重生 worker、`CoverRegenerateWorker`、重生运行回调、②主界面对“画廊重生中”的互斥逻辑;保留批量生成运行判断,批量生成中拒绝「重置图片」。 + - committed/applied 行重置前增加线上风险确认;普通行免确认。重置后无选中、保存置灰,切换/关闭不触发未保存确认;归档图仍可选中保存恢复指针。 + - `tests/test_gui.py`:旧 `regenerate` 单测替换为重置语义单测,覆盖普通重置、已提交线上确认/取消、归档失败不清 DB、批量生成中拒绝、归档图恢复。 +- 文档: + - 更新 `docs/04-architecture.md`、`docs/02-requirements.md`、`docs/routes.md`、`docs/ui/README.md`、`docs/ui/tab2-ai-generate.svg`,把“单行重生成”改为“重置图片 + 批量补生成”。 +- 验证: + - `py -3.10 -m unittest tests.test_gui.GuiTests.test_cover_gallery_reset_image_clears_pointer_without_worker_or_confirmation ... test_cover_gallery_reset_image_disabled_while_bulk_generation_runs`:通过,6 tests。 + - `py -3.10 -m unittest tests.test_gui tests.test_db`:通过,153 tests。 + - `python -m ruff check app tests main.py`:通过。 + - `py -3.10 -m compileall app main.py`:通过。 + - `py -3.10 -m unittest discover -s tests`:通过,312 tests。 + - `git diff --check`:通过,无输出。 diff --git a/docs/ui/README.md b/docs/ui/README.md index afef19e..f5db7d0 100644 --- a/docs/ui/README.md +++ b/docs/ui/README.md @@ -6,7 +6,7 @@ | --- | --- | | [overview-pipeline.svg](overview-pipeline.svg) | 5 Tab 流水线总览(stage:imported→collected→generated→applied) | | [tab1-import-collect.svg](tab1-import-collect.svg) | ① 导入采集:导入 Excel、任务列表、采集旧标题/旧封面、回写、日志 | -| [tab2-ai-generate.svg](tab2-ai-generate.svg) | ② AI生成:提示词、生成、新旧对照预览、重生成 | +| [tab2-ai-generate.svg](tab2-ai-generate.svg) | ② AI生成:提示词、生成、新旧封面对照、重置图片后批量补生成 | | [tab3-update-shopee.svg](tab3-update-shopee.svg) | ③ 更新蝦皮:已生成任务、开始更新确认、执行结果、汇总 | | [tab4-accounts.svg](tab4-accounts.svg) | ④ 账号管理:账号列表、启动登录、检测登录、账号编辑弹窗 | | [tab5-settings.svg](tab5-settings.svg) | ⑤ 设置:AI 模型/Key、路径、端口 | diff --git a/docs/ui/tab2-ai-generate.svg b/docs/ui/tab2-ai-generate.svg index c42c817..a634e27 100644 --- a/docs/ui/tab2-ai-generate.svg +++ b/docs/ui/tab2-ai-generate.svg @@ -90,7 +90,7 @@ …百搭耳環飾品生成中… 处理中 - 新标题直接用 AI 输出(不可编辑)· 可对单行右键「重生成」· 无逐条审核 + 可微调新标题 · 封面画廊可「重置图片」· 退出后批量补生成 diff --git a/tests/test_gui.py b/tests/test_gui.py index 6c09769..16e052d 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -2800,7 +2800,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) - def test_cover_gallery_regenerate_cancel_has_no_side_effect(self): + def test_cover_gallery_reset_image_clears_pointer_without_worker_or_confirmation(self): with self.make_temp_dir() as temp_dir: cfg, account, task, canonical = self._cover_gallery_task(temp_dir) dialog = CoverGalleryDialog( @@ -2808,131 +2808,119 @@ class GuiTests(TempDirMixin, unittest.TestCase): cfg["image_dir"], cfg["db_path"], account=account, - config=cfg, - prompt_values={"title": "标题", "cover": "封面"}, ) self.addCleanup(dialog.close) - message_box, _ = self.make_fake_message_box("取消") + message_box, boxes = self.make_fake_message_box("取消") FakeGenerateWorker.instances.clear() with mock.patch("app.gui.tabs.generate.QMessageBox", message_box), \ mock.patch("app.gui.tabs.generate.GenerateWorker", FakeGenerateWorker): - self.assertFalse(dialog.regenerate_cover()) + self.assertTrue(dialog.reset_cover_image()) - self.assertEqual(canonical, db.get_task(task.id, path=cfg["db_path"]).new_cover_path) - self.assertTrue(os.path.exists(canonical)) + updated = db.get_task(task.id, path=cfg["db_path"]) + self.assertEqual("新标题", updated.new_title) + self.assertIsNone(updated.new_cover_path) + self.assertEqual("generated", updated.stage) + self.assertEqual("success", updated.status) + self.assertFalse(os.path.exists(canonical)) + self.assertIsNone(dialog.current_path) + self.assertIsNone(dialog.selected_path) + self.assertFalse(dialog.save_button.isEnabled()) + self.assertFalse(dialog._has_unsaved_selection()) + self.assertTrue(dialog.candidates) + self.assertIn("已重置图片", dialog.status_label.text()) + self.assertEqual([], boxes) self.assertEqual([], FakeGenerateWorker.instances) self.assert_removed(temp_dir) - def test_cover_gallery_regenerate_uses_fresh_task_and_forces_cover_generation(self): + def test_cover_gallery_reset_image_committed_cancel_does_not_write_db(self): with self.make_temp_dir() as temp_dir: - cfg, account, task, canonical = self._cover_gallery_task(temp_dir, generate_cover=False) - fake_thread = FakeThread() - running_states = [] + cfg, account, task, canonical = self._cover_gallery_task(temp_dir) + db.set_applied(task.id, True, path=cfg["db_path"]) + task = db.get_task(task.id, path=cfg["db_path"]) dialog = CoverGalleryDialog( task, cfg["image_dir"], cfg["db_path"], account=account, - config=cfg, - prompt_values={"title": "标题提示", "cover": "封面提示 {新标题}"}, - regenerate_running_callback=running_states.append, ) self.addCleanup(dialog.close) - message_box, _ = self.make_fake_message_box("重新生成") - FakeGenerateWorker.instances.clear() + message_box, boxes = self.make_fake_message_box("取消") - with mock.patch("app.gui.tabs.generate.QMessageBox", message_box), \ - mock.patch("app.gui.tabs.generate.GenerateWorker", FakeGenerateWorker), \ - mock.patch("app.gui.tabs.generate.run_worker", return_value=fake_thread): - self.assertTrue(dialog.regenerate_cover()) + with mock.patch("app.gui.tabs.generate.QMessageBox", message_box): + self.assertFalse(dialog.reset_cover_image()) - self.assertTrue(fake_thread.started) - self.assertEqual([True], running_states) - worker = FakeGenerateWorker.instances[0] - self.assertIsNone(worker.tasks[0].new_cover_path) - self.assertEqual("新标题", worker.tasks[0].new_title) - self.assertTrue(worker.config["ai"]["generate_cover"]) - self.assertFalse(appconfig.ai_config(cfg).get("generate_cover", False)) - self.assertFalse(os.path.exists(canonical)) - self.assertTrue(any(path.endswith(".jpg") for path in dialog.candidates)) - self.assertIn("开始重新生成商品", dialog.status_label.text()) + updated = db.get_task(task.id, path=cfg["db_path"]) + self.assertEqual(canonical, updated.new_cover_path) + self.assertEqual("applied", updated.stage) + self.assertEqual(1, updated.committed) + self.assertTrue(os.path.exists(canonical)) + self.assertIn("本地重置不会回滚蝦皮", boxes[0].text) self.assert_removed(temp_dir) - def test_cover_gallery_regenerate_success_refreshes_candidates_and_current_selection(self): + def test_cover_gallery_reset_image_committed_confirm_clears_pointer(self): with self.make_temp_dir() as temp_dir: cfg, account, task, canonical = self._cover_gallery_task(temp_dir) - fake_thread = FakeThread() + db.set_applied(task.id, True, path=cfg["db_path"]) + task = db.get_task(task.id, path=cfg["db_path"]) refreshed = [] dialog = CoverGalleryDialog( task, cfg["image_dir"], cfg["db_path"], account=account, - config=cfg, - prompt_values={"title": "标题", "cover": "封面"}, - regenerate_running_callback=lambda running: None, gallery_updated_callback=lambda task: refreshed.append(task.id), ) self.addCleanup(dialog.close) - message_box, _ = self.make_fake_message_box("重新生成") - FakeGenerateWorker.instances.clear() + message_box, boxes = self.make_fake_message_box("确认重置") - with mock.patch("app.gui.tabs.generate.QMessageBox", message_box), \ - mock.patch("app.gui.tabs.generate.GenerateWorker", FakeGenerateWorker), \ - mock.patch("app.gui.tabs.generate.run_worker", return_value=fake_thread): - self.assertTrue(dialog.regenerate_cover()) - worker = FakeGenerateWorker.instances[0] - self.write_test_image(canonical) - db.set_generated(task.id, "新标题", canonical, path=cfg["db_path"]) + with mock.patch("app.gui.tabs.generate.QMessageBox", message_box): + self.assertTrue(dialog.reset_cover_image()) - worker.finished.emit({"ok": True, "cover_done": 1, "failed": 0}) - - self.assertEqual(canonical, db.get_task(task.id, path=cfg["db_path"]).new_cover_path) - self.assertEqual(canonical, dialog.selected_path) - self.assertTrue(dialog.candidate_buttons[canonical].isChecked()) - self.assertEqual("封面重新生成完成", dialog.status_label.text()) + updated = db.get_task(task.id, path=cfg["db_path"]) + self.assertIsNone(updated.new_cover_path) + self.assertEqual("generated", updated.stage) + self.assertEqual(1, updated.committed) + self.assertIn("再次提交线上", boxes[0].text) self.assertIn(task.id, refreshed) self.assert_removed(temp_dir) - def test_cover_gallery_regenerate_failure_keeps_archived_candidate_selectable(self): + def test_cover_gallery_reset_image_keeps_archive_selectable_for_restore(self): with self.make_temp_dir() as temp_dir: - cfg, account, task, canonical = self._cover_gallery_task(temp_dir) - fake_thread = FakeThread() + cfg, account, tasks, canonicals, _archives = self._cover_gallery_task_set(temp_dir, count=2) dialog = CoverGalleryDialog( - task, + tasks[0], cfg["image_dir"], cfg["db_path"], account=account, - config=cfg, - prompt_values={"title": "标题", "cover": "封面"}, + visible_tasks=tasks, + task_index=0, + account_by_alias={"alias-a": account}, ) self.addCleanup(dialog.close) - message_box, _ = self.make_fake_message_box("重新生成") - FakeGenerateWorker.instances.clear() - with mock.patch("app.gui.tabs.generate.QMessageBox", message_box), \ - mock.patch("app.gui.tabs.generate.GenerateWorker", FakeGenerateWorker), \ - mock.patch("app.gui.tabs.generate.run_worker", return_value=fake_thread): - self.assertTrue(dialog.regenerate_cover()) - worker = FakeGenerateWorker.instances[0] - worker.log.emit("[失败] 商品 51100639510 图片生成失败:测试失败") - worker.finished.emit({"ok": False, "cover_done": 0, "failed": 1}) - - self.assertFalse(os.path.exists(canonical)) - self.assertIsNone(db.get_task(task.id, path=cfg["db_path"]).new_cover_path) + self.assertTrue(dialog.reset_cover_image()) self.assertTrue(dialog.candidates) archived = dialog.candidates[0] + self.assertIsNone(db.get_task(tasks[0].id, path=cfg["db_path"]).new_cover_path) + + message_box, boxes = self.make_fake_message_box("取消") + with mock.patch("app.gui.tabs.generate.QMessageBox", message_box): + self.assertTrue(dialog.switch_task(1)) + self.assertEqual([], boxes) + self.assertEqual(tasks[1].id, dialog.task.id) + + self.assertTrue(dialog.switch_task(-1)) dialog.candidate_buttons[archived].setChecked(True) self.assertTrue(dialog.save_selection()) - self.assertEqual(os.path.abspath(archived), db.get_task(task.id, path=cfg["db_path"]).new_cover_path) + self.assertEqual(os.path.abspath(archived), db.get_task(tasks[0].id, path=cfg["db_path"]).new_cover_path) self.assert_removed(temp_dir) - def test_cover_gallery_regenerate_archive_failure_does_not_start_worker(self): + def test_cover_gallery_reset_image_archive_failure_keeps_db_pointer(self): with self.make_temp_dir() as temp_dir: cfg, account, task, canonical = self._cover_gallery_task(temp_dir) dialog = CoverGalleryDialog( @@ -2940,17 +2928,15 @@ class GuiTests(TempDirMixin, unittest.TestCase): cfg["image_dir"], cfg["db_path"], account=account, - config=cfg, - prompt_values={"title": "标题", "cover": "封面"}, ) self.addCleanup(dialog.close) - message_box, boxes = self.make_fake_message_box("重新生成") + message_box, boxes = self.make_fake_message_box("取消") FakeGenerateWorker.instances.clear() with mock.patch("app.gui.tabs.generate.QMessageBox", message_box), \ mock.patch("app.db.os.rename", side_effect=PermissionError), \ mock.patch("app.gui.tabs.generate.GenerateWorker", FakeGenerateWorker): - self.assertFalse(dialog.regenerate_cover()) + self.assertFalse(dialog.reset_cover_image()) self.assertTrue(os.path.exists(canonical)) self.assertEqual(canonical, db.get_task(task.id, path=cfg["db_path"]).new_cover_path) @@ -2959,7 +2945,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) - def test_cover_gallery_regenerate_disabled_while_bulk_generation_runs(self): + def test_cover_gallery_reset_image_disabled_while_bulk_generation_runs(self): with self.make_temp_dir() as temp_dir: cfg, account, task, canonical = self._cover_gallery_task(temp_dir) dialog = CoverGalleryDialog( @@ -2967,36 +2953,17 @@ class GuiTests(TempDirMixin, unittest.TestCase): cfg["image_dir"], cfg["db_path"], account=account, - config=cfg, - prompt_values={"title": "标题", "cover": "封面"}, bulk_running_checker=lambda: True, ) self.addCleanup(dialog.close) - self.assertFalse(dialog.regenerate_button.isEnabled()) - self.assertIn("批量生成正在运行", dialog.regenerate_button.toolTip()) - self.assertFalse(dialog.regenerate_cover()) + self.assertFalse(dialog.reset_cover_button.isEnabled()) + self.assertIn("AI 生成正在进行", dialog.reset_cover_button.toolTip()) + self.assertFalse(dialog.reset_cover_image()) self.assertEqual(canonical, db.get_task(task.id, path=cfg["db_path"]).new_cover_path) self.assert_removed(temp_dir) - def test_generate_tab_blocks_batch_generate_while_gallery_regenerates(self): - with self.make_temp_dir() as temp_dir: - cfg = self.make_config(temp_dir) - statuses = [] - tab = GenerateTab(config=cfg, status_callback=statuses.append) - self.addCleanup(tab.close) - - tab._set_cover_gallery_regenerate_running(True) - tab.start_generate() - - self.assertFalse(tab.generate_button.isEnabled()) - self.assertIn("封面画廊正在重新生成", statuses[-1]) - tab._set_cover_gallery_regenerate_running(False) - self.assertTrue(tab.generate_button.isEnabled()) - - self.assert_removed(temp_dir) - def test_generate_tab_allows_editing_generated_title_locally(self): with self.make_temp_dir() as temp_dir: