From 4146ab559330f770df2e712baa31483db1fa6a52 Mon Sep 17 00:00:00 2001 From: chengma Date: Wed, 8 Jul 2026 11:37:34 +0800 Subject: [PATCH] feat: split generate title and image statuses --- app/gui/models.py | 120 +++++++++++++++++++++++++++++++++----- app/gui/tabs/generate.py | 32 +++++++++- docs/tasks/T-554.md | 10 +++- tests/test_gui.py | 123 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 264 insertions(+), 21 deletions(-) diff --git a/app/gui/models.py b/app/gui/models.py index 427a06f..283d49b 100644 --- a/app/gui/models.py +++ b/app/gui/models.py @@ -155,7 +155,7 @@ class TaskTableModel(QAbstractTableModel): class GenerateTaskTableModel(QAbstractTableModel): """Table model for Tab 2 generation candidates.""" - HEADERS = ["店铺", "商品ID", "旧标题", "新标题", "状态"] + HEADERS = ["店铺", "商品ID", "旧标题", "新标题", "标题状态", "图片状态"] STATUS_TEXT = { "running": "处理中", @@ -178,6 +178,15 @@ class GenerateTaskTableModel(QAbstractTableModel): self.db_path = db_path self.status_callback = status_callback self.last_edit_error = None + self.generate_cover_enabled = False + + def set_generate_cover_enabled(self, enabled): + self.generate_cover_enabled = bool(enabled) + if not self.tasks: + return + top_left = self.index(0, 4) + bottom_right = self.index(len(self.tasks) - 1, 5) + self.dataChanged.emit(top_left, bottom_right, [Qt.DisplayRole, Qt.ForegroundRole]) def set_tasks(self, tasks, accounts): self.beginResetModel() @@ -208,8 +217,8 @@ class GenerateTaskTableModel(QAbstractTableModel): task = self.tasks[index.row()] if role in (Qt.DisplayRole, Qt.EditRole): return self._display_value(task, index.column()) - if role == Qt.ForegroundRole and index.column() == 4: - return self._status_color(task) + if role == Qt.ForegroundRole and index.column() in {4, 5}: + return self._component_status_color(self._display_value(task, index.column())) if role == Qt.ToolTipRole: if index.column() == 3 and self._can_edit_title(task): return "双击可微调新标题,只修改本地待更新内容" @@ -273,13 +282,6 @@ class GenerateTaskTableModel(QAbstractTableModel): return account.account_name return task.account_name or task.alias or "" - def _status_text(self, task): - if task.status == "failed": - return self._failed_status_text(task) - if task.status in self.STATUS_TEXT and task.status != "pending": - return self.STATUS_TEXT[task.status] - return self.STAGE_TEXT.get(task.stage, task.stage) - def _failed_status_text(self, task): collect_attempts = self._attempt_count(task, "collect_attempts") generate_attempts = self._attempt_count(task, "generate_attempts") @@ -298,12 +300,97 @@ class GenerateTaskTableModel(QAbstractTableModel): except (TypeError, ValueError): return 0 - def _status_color(self, task): - base_color = _status_base_color(getattr(task, "status", None)) - if base_color is not None: - return _qcolor(base_color) - if getattr(task, "stage", None) in {"generated", "applied"}: + def _has_new_title(self, task): + return bool(str(getattr(task, "new_title", "") or "").strip()) + + def _has_new_cover(self, task): + return bool(str(getattr(task, "new_cover_path", "") or "").strip()) + + def _generate_failed(self, task): + return ( + getattr(task, "status", None) == "failed" + and self._attempt_count(task, "generate_attempts") > 0 + ) + + def _last_error_text(self, task): + return str(getattr(task, "last_error", "") or "").lower() + + def _is_title_failure(self, task): + if not self._generate_failed(task): + return False + error = self._last_error_text(task) + if any(marker in error for marker in ("title", "标题", "生文", "文本")): + return True + return not self._has_new_title(task) and not self._is_cover_failure(task) + + def _is_cover_failure(self, task): + if not self._generate_failed(task): + return False + error = self._last_error_text(task) + return any( + marker in error + for marker in ("cover", "封面", "图片", "图像", "新封面") + ) + + def _is_collect_incomplete(self, task): + return ( + getattr(task, "stage", None) == "imported" + and self._attempt_count(task, "generate_attempts") == 0 + and not self._is_collect_failure(task) + ) + + def _is_collect_failure(self, task): + return ( + getattr(task, "status", None) == "failed" + and self._attempt_count(task, "collect_attempts") > 0 + and self._attempt_count(task, "generate_attempts") == 0 + ) + + def _title_status_text(self, task): + if getattr(task, "status", None) == "skipped": + return "略过" + if self._is_collect_failure(task): + return "采集失败" + if self._is_collect_incomplete(task): + return "未采集" + if self._is_title_failure(task): + return "失败" + if self._has_new_title(task): + return "已生成" + if getattr(task, "status", None) == "running": + return "处理中" + if getattr(task, "stage", None) == "applied": + return "已生成" if self._has_new_title(task) else "未生成" + return "待生成" + + def _cover_status_text(self, task): + if getattr(task, "status", None) == "skipped": + return "略过" + if self._is_collect_failure(task): + return "采集失败" + if self._is_collect_incomplete(task): + return "未采集" + if self._has_new_cover(task): + return "已生成" + if self._is_cover_failure(task): + return "失败" + if not self.generate_cover_enabled: + return "未启用" + if self._is_title_failure(task) or not self._has_new_title(task): + return "未开始" + if getattr(task, "status", None) == "running": + return "处理中" + return "待生成" + + def _component_status_color(self, text): + if text in {"失败", "采集失败"}: + return _qcolor(COLOR_DANGER) + if text == "处理中": + return _qcolor(COLOR_INFO) + if text == "已生成": return _qcolor(COLOR_SUCCESS) + if text in {"未启用", "未采集", "未开始", "略过", "已取消"}: + return _qcolor(COLOR_MUTED) return _qcolor(COLOR_PENDING) def _display_value(self, task, column): @@ -312,7 +399,8 @@ class GenerateTaskTableModel(QAbstractTableModel): task.item_id, task.old_title or "", task.new_title or "", - self._status_text(task), + self._title_status_text(task), + self._cover_status_text(task), ] return values[column] if 0 <= column < len(values) else None diff --git a/app/gui/tabs/generate.py b/app/gui/tabs/generate.py index 8541669..9c1cf0e 100644 --- a/app/gui/tabs/generate.py +++ b/app/gui/tabs/generate.py @@ -26,6 +26,7 @@ class GenerateTab(QWidget): "cover": "封面", "all": "标题和封面", } + TABLE_COLUMN_WEIGHTS = (1.0, 0.5, 1.0, 1.0, 1.25, 1.25) def __init__( self, @@ -171,11 +172,13 @@ 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.generate_cover_checkbox.isChecked()) self.task_table.setModel(self.model) self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows) self.task_table.setSelectionMode(QAbstractItemView.ExtendedSelection) self.task_table.setEditTriggers(QAbstractItemView.DoubleClicked | QAbstractItemView.EditKeyPressed) - self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) + self.task_table.horizontalHeader().setStretchLastSection(False) self.task_table.verticalHeader().setVisible(False) self.run_log_view = QPlainTextEdit() @@ -201,6 +204,7 @@ class GenerateTab(QWidget): self.splitter.setStretchFactor(0, 1) self.splitter.setStretchFactor(1, 3) self.splitter.setSizes([280, 860]) + self.splitter.splitterMoved.connect(lambda *_: self._apply_task_table_column_widths()) title_progress_layout = QHBoxLayout() title_progress_layout.addWidget(self.title_progress_label) @@ -256,6 +260,10 @@ class GenerateTab(QWidget): self._show_current_run_log_empty() self._update_cmhub_balance_label({}) + def resizeEvent(self, event): + super().resizeEvent(event) + self._apply_task_table_column_widths() + def _set_status(self, message, level=None): _emit_status(self.status_callback, message, level=level) @@ -281,11 +289,31 @@ class GenerateTab(QWidget): def _on_generate_cover_toggled(self, checked): previous = bool(appconfig.ai_config(self.config).get("generate_cover", False)) if self._save_generate_cover_setting(show_status=True): + self.model.set_generate_cover_enabled(bool(checked)) self.refresh_tasks() return self.generate_cover_checkbox.blockSignals(True) self.generate_cover_checkbox.setChecked(previous) self.generate_cover_checkbox.blockSignals(False) + self.model.set_generate_cover_enabled(previous) + + def _apply_task_table_column_widths(self): + if self.model.columnCount() != len(self.TABLE_COLUMN_WEIGHTS): + return + width = max(0, int(self.task_table.viewport().width())) + if width <= 0: + return + total_weight = sum(self.TABLE_COLUMN_WEIGHTS) + used = 0 + last_column = len(self.TABLE_COLUMN_WEIGHTS) - 1 + header = self.task_table.horizontalHeader() + for column, weight in enumerate(self.TABLE_COLUMN_WEIGHTS): + if column == last_column: + section_width = max(1, width - used) + else: + section_width = max(1, int(width * weight / total_weight)) + used += section_width + header.resizeSection(column, section_width) def _save_generate_cover_setting(self, show_status=True): generate_cover = bool(self.generate_cover_checkbox.isChecked()) @@ -964,7 +992,9 @@ 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.generate_cover_checkbox.isChecked()) self.model.set_tasks(filtered_tasks, accounts_rows) + self._apply_task_table_column_widths() self.summary_label.setText( f"任务 {len(filtered_tasks)}/{len(batch_tasks)} 条" ) diff --git a/docs/tasks/T-554.md b/docs/tasks/T-554.md index 33f6220..d738b23 100644 --- a/docs/tasks/T-554.md +++ b/docs/tasks/T-554.md @@ -3,7 +3,7 @@ id: T-554 title: ②AI生成表格拆分标题/图片状态列并调整列宽 phase: 7 deps: [T-533, T-519, T-552, T-523] -status: TODO +status: DONE created: 2026-07-08 --- @@ -33,6 +33,7 @@ created: 2026-07-08 - `stage=collected` 且无标题:标题状态显示「待生成」。 - 本轮未启用生成封面,或任务已生成标题但没有新封面且没有图片失败时,图片状态显示「未启用」;若需要生成封面但还没生成,则显示「待生成」。第一版可通过 ②当前「生成封面图片(成本较高)」开关参与推导,不写入 DB。 - `stage=imported`:两个状态均显示「未采集」。 + - 若任务是采集阶段失败(有 `collect_attempts`、无 `generate_attempts`),两个状态列显示「采集失败」,避免旧整体状态的信息丢失。 - `stage=applied`:按已有结果显示,标题/图片已有值则显示「已生成」,没有图片则显示「未启用」。 3. **列宽调整** @@ -56,3 +57,10 @@ created: 2026-07-08 只改②AI生成表格展示、状态推导和列宽;不改 DB schema、`tasks.stage/status` 语义、AI 生成编排、cmhub HTTP、运行日志、底部进度条、Excel、Shopee/CDP。 +## 执行记录 + +- 2026-07-08:完成 T-554。 +- 代码:`app/gui/models.py` 将 `GenerateTaskTableModel` 表头改为 `店铺 / 商品ID / 旧标题 / 新标题 / 标题状态 / 图片状态`,新增标题/图片组件状态推导;采集失败保留显示「采集失败」,生成阶段标题失败/图片失败分别显示在对应状态列,tooltip 继续显示完整 `last_error`。 +- 代码:`app/gui/tabs/generate.py` 将②表格表头从等宽 Stretch 改为权重列宽,权重为 `1.0 / 0.5 / 1.0 / 1.0 / 1.25 / 1.25`;刷新、窗口 resize、splitter 移动时重新应用列宽;当前「生成封面图片(成本较高)」开关联动图片状态的「待生成 / 未启用」展示。 +- 测试:`tests/test_gui.py` 更新表头断言、旧整体状态断言和 tooltip 列号,新增 `test_generate_table_splits_title_and_cover_statuses_and_widths` 覆盖采集失败、标题失败、图片失败、标题成功图片未启用、开启封面后的待生成状态,以及商品ID列宽小于普通列、两个状态列宽基本相等且大于普通列。 +- 验证:`py -3.10 -m unittest tests.test_gui` 通过(105 tests);`python -m ruff check app tests main.py` 通过;`py -3.10 -m compileall app main.py` 通过;`git diff --check` 通过;`py -3.10 -m unittest discover -s tests` 通过(258 tests)。 diff --git a/tests/test_gui.py b/tests/test_gui.py index b9218cb..7922dcc 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -209,7 +209,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): generate_model.set_tasks([generate_task], [account]) self.assertEqual( "请求生成标题失败:cmhub 上游生成失败", - generate_model.data(generate_model.index(0, 4), gui.Qt.ToolTipRole), + generate_model.data(generate_model.index(0, 5), gui.Qt.ToolTipRole), ) apply_model = gui.ApplyTaskTableModel() @@ -1645,11 +1645,128 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertFalse(hasattr(tab, "save_cover_template_as_button")) self.assertFalse(hasattr(tab, "rename_cover_template_button")) self.assertFalse(hasattr(tab, "delete_cover_template_button")) - self.assertEqual(["店铺", "商品ID", "旧标题", "新标题", "状态"], tab.model.HEADERS) + self.assertEqual( + ["店铺", "商品ID", "旧标题", "新标题", "标题状态", "图片状态"], + tab.model.HEADERS, + ) self.assertEqual("任务 0/0 条", tab.summary_label.text()) self.assert_removed(temp_dir) + def test_generate_table_splits_title_and_cover_statuses_and_widths(self): + with self.make_temp_dir() as temp_dir: + account = SimpleNamespace(alias="papa", account_name="papa 店铺") + tasks = [ + SimpleNamespace( + alias="papa", + account_name="papa 店铺", + item_id="1001", + old_title="旧标题1", + new_title="", + new_cover_path="", + stage="imported", + status="failed", + last_error="读标题失败:页面错误", + collect_attempts=1, + generate_attempts=0, + apply_attempts=0, + committed=0, + ), + SimpleNamespace( + alias="papa", + account_name="papa 店铺", + item_id="1002", + old_title="旧标题2", + new_title="", + new_cover_path="", + stage="collected", + status="failed", + last_error="请求生成标题失败:cmhub 上游失败", + collect_attempts=1, + generate_attempts=1, + apply_attempts=0, + committed=0, + ), + SimpleNamespace( + alias="papa", + account_name="papa 店铺", + item_id="1003", + old_title="旧标题3", + new_title="新标题3", + new_cover_path="", + stage="generated", + status="failed", + last_error="请求生成封面失败:等待 cmhub 返回超时", + collect_attempts=1, + generate_attempts=2, + apply_attempts=0, + committed=0, + ), + SimpleNamespace( + alias="papa", + account_name="papa 店铺", + item_id="1004", + old_title="旧标题4", + new_title="新标题4", + new_cover_path="", + stage="generated", + status="success", + last_error="", + collect_attempts=1, + generate_attempts=1, + apply_attempts=0, + committed=0, + ), + SimpleNamespace( + alias="papa", + account_name="papa 店铺", + item_id="1005", + old_title="旧标题5", + new_title="新标题5", + new_cover_path="new.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]) + + self.assertEqual("采集失败", model.index(0, 4).data()) + self.assertEqual("采集失败", model.index(0, 5).data()) + self.assertEqual("失败", model.index(1, 4).data()) + self.assertEqual("未启用", model.index(1, 5).data()) + self.assertEqual("已生成", model.index(2, 4).data()) + self.assertEqual("失败", model.index(2, 5).data()) + self.assertEqual("已生成", model.index(3, 4).data()) + self.assertEqual("未启用", model.index(3, 5).data()) + + model.set_generate_cover_enabled(True) + self.assertEqual("未开始", model.index(1, 5).data()) + self.assertEqual("待生成", model.index(3, 5).data()) + self.assertEqual("已生成", model.index(4, 5).data()) + + tab = GenerateTab(config=self.make_config(temp_dir)) + self.addCleanup(tab.close) + tab.task_table.resize(600, 240) + tab.task_table.show() + QApplication.processEvents() + tab._apply_task_table_column_widths() + header = tab.task_table.horizontalHeader() + self.assertLess(header.sectionSize(1), header.sectionSize(0)) + self.assertAlmostEqual( + header.sectionSize(4), + header.sectionSize(5), + delta=5, + ) + self.assertGreater(header.sectionSize(4), header.sectionSize(0)) + + self.assert_removed(temp_dir) + def test_generate_tab_shows_cmhub_balance_and_billing_error(self): with self.make_temp_dir() as temp_dir: cfg = self.make_config(temp_dir) @@ -3858,7 +3975,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): tab.status_filter.setCurrentIndex(tab.status_filter.findData("all")) tab.batch_filter.setCurrentIndex(tab.batch_filter.findData(batch_b)) self.assertEqual(1, tab.model.rowCount()) - self.assertEqual("生成失败", tab.model.index(0, 4).data()) + self.assertEqual("失败", tab.model.index(0, 4).data()) self.assert_foreground(tab.model, 0, 4, gui.COLOR_DANGER) self.assertEqual( "生成失败",