From 5c013771733720826964039e3e793c38a73dc93f Mon Sep 17 00:00:00 2001 From: chengma Date: Sat, 11 Jul 2026 12:06:17 +0800 Subject: [PATCH] feat(ai): mark cover reset history in generate table --- app/db.py | 32 ++++++++- app/gui/models.py | 15 ++++ docs/tasks/T-603.md | 21 +++++- tests/test_db.py | 164 +++++++++++++++++++++++++++++++++++++++++++- tests/test_gui.py | 52 ++++++++++++++ 5 files changed, 280 insertions(+), 4 deletions(-) diff --git a/app/db.py b/app/db.py index cc1996b..4a7652e 100644 --- a/app/db.py +++ b/app/db.py @@ -141,6 +141,8 @@ class Task: new_cover_path: Optional[str] image_task_id: Optional[str] image_task_key: Optional[str] + cover_reset_count: int + cover_reset_at: Optional[str] committed: int stage: str status: str @@ -236,6 +238,8 @@ CREATE TABLE IF NOT EXISTS tasks ( new_cover_path TEXT, image_task_id TEXT, image_task_key TEXT, + cover_reset_count INTEGER NOT NULL DEFAULT 0, + cover_reset_at TEXT, committed INTEGER NOT NULL DEFAULT 0, stage TEXT NOT NULL DEFAULT 'imported', status TEXT NOT NULL DEFAULT 'pending', @@ -389,6 +393,7 @@ def init_db(path=None, conn=None) -> None: database.executescript(SCHEMA_SQL) _ensure_batch_delete_columns(database) _ensure_task_image_task_columns(database) + _ensure_task_cover_reset_columns(database) def _ensure_batch_delete_columns(database): @@ -406,6 +411,14 @@ def _ensure_task_image_task_columns(database): if "image_task_key" not in columns: database.execute("ALTER TABLE tasks ADD COLUMN image_task_key TEXT") + +def _ensure_task_cover_reset_columns(database): + columns = {row["name"] for row in database.execute("PRAGMA table_info(tasks)").fetchall()} + if "cover_reset_count" not in columns: + database.execute("ALTER TABLE tasks ADD COLUMN cover_reset_count INTEGER NOT NULL DEFAULT 0") + if "cover_reset_at" not in columns: + database.execute("ALTER TABLE tasks ADD COLUMN cover_reset_at TEXT") + def create_batch(file_paths: Iterable[str], note=None, path=None, conn=None) -> str: batch_id = datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:8] files = [os.path.abspath(file_path) for file_path in file_paths] @@ -969,6 +982,12 @@ def reset_generated( new_cover = None if reset_cover else before.new_cover_path image_task_id = None if reset_cover else before.image_task_id image_task_key = None if reset_cover else before.image_task_key + should_record_cover_reset = bool(reset_cover and str(before.new_cover_path or "").strip()) + cover_reset_count = int(before.cover_reset_count or 0) + cover_reset_at = before.cover_reset_at + if should_record_cover_reset: + cover_reset_count += 1 + cover_reset_at = now with database: database.execute( """ @@ -977,13 +996,24 @@ def reset_generated( new_cover_path = ?, image_task_id = ?, image_task_key = ?, + cover_reset_count = ?, + cover_reset_at = ?, stage = 'generated', status = 'success', last_error = NULL, updated_at = ? WHERE id = ? """, - (new_title, new_cover, image_task_id, image_task_key, now, int(task_id)), + ( + new_title, + new_cover, + image_task_id, + image_task_key, + cover_reset_count, + cover_reset_at, + now, + int(task_id), + ), ) after = get_task(task_id, conn=database) return { diff --git a/app/gui/models.py b/app/gui/models.py index 283d49b..bfedf4c 100644 --- a/app/gui/models.py +++ b/app/gui/models.py @@ -217,9 +217,14 @@ 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() == 1: + if self._cover_reset_count(task) > 0: + return _qcolor(COLOR_WARNING) 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() == 1 and self._cover_reset_count(task) > 0: + return self._cover_reset_tooltip(task) if index.column() == 3 and self._can_edit_title(task): return "双击可微调新标题,只修改本地待更新内容" if task.last_error: @@ -300,6 +305,16 @@ class GenerateTaskTableModel(QAbstractTableModel): except (TypeError, ValueError): return 0 + def _cover_reset_count(self, task): + try: + return int(getattr(task, "cover_reset_count", 0) or 0) + except (TypeError, ValueError): + return 0 + + def _cover_reset_tooltip(self, task): + count = self._cover_reset_count(task) + return f"该商品封面已重置 {count} 次,可能有多张候选图,双击可查看封面画廊" + def _has_new_title(self, task): return bool(str(getattr(task, "new_title", "") or "").strip()) diff --git a/docs/tasks/T-603.md b/docs/tasks/T-603.md index 76a99a1..6821166 100644 --- a/docs/tasks/T-603.md +++ b/docs/tasks/T-603.md @@ -3,7 +3,7 @@ id: T-603 title: ②AI生成封面重置计数与商品ID警示色 phase: 7 deps: [T-511, T-534, T-554, T-566, T-577] -status: TODO +status: DONE created: 2026-07-11 --- @@ -132,4 +132,21 @@ git diff --check ## 执行记录 -(完成后记录 schema 字段、记录口径、商品ID列颜色和验证结果。) +- 2026-07-11:已完成。 +- 代码: + - `app/db.py`:`tasks` 新增 `cover_reset_count INTEGER NOT NULL DEFAULT 0` 与 `cover_reset_at TEXT`,补 `init_db()` 兼容迁移、`Task` dataclass 字段和旧库迁移路径。 + - `db.reset_generated()`:仅在 `reset_cover=True` 且重置前 `new_cover_path` 非空时累加 `cover_reset_count` 并写最近重置时间;只重置标题、空封面重置不计数。`update_generated_cover()` 与 `set_generated()` 不清除该历史。 + - `app/gui/models.py`:② `GenerateTaskTableModel` 商品ID列在 `cover_reset_count > 0` 时使用 `COLOR_WARNING`,tooltip 显示“该商品封面已重置 X 次,可能有多张候选图,双击可查看封面画廊”;标题/图片状态列原有颜色不变。 +- 测试: + - `tests/test_db.py`:覆盖新字段初始化、旧库迁移、封面重置计数、只重置标题不计数、空封面重置不计数、选择候选图/重新生成后不清除计数。 + - `tests/test_gui.py`:覆盖②商品ID列 warning 色、tooltip 和状态列颜色不回归。 +- 验证: + - 当前主工作区全量 unittest 会被既有未提交默认提示词文件影响(`papa1` 与 `默认.txt` 差异),该问题不属于 T-603。 + - 在临时干净 worktree 中仅应用本任务 diff 后验证通过: + - `python -m ruff check app tests main.py` + - `py -3.10 -m compileall app main.py` + - `py -3.10 -m unittest discover -s tests`(338 tests) + - `git diff --check` + - 主工作区定向验证通过: + - `py -3.10 -m unittest tests.test_db` + - `py -3.10 -m unittest tests.test_gui.GuiTests.test_generate_table_marks_cover_reset_history_on_item_id_column tests.test_gui.GuiTests.test_generate_table_splits_title_and_cover_statuses_and_widths` diff --git a/tests/test_db.py b/tests/test_db.py index 8638214..d330581 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -34,7 +34,88 @@ class DbTests(TempDirMixin, unittest.TestCase): row["name"] for row in conn.execute("PRAGMA table_info(tasks)").fetchall() } - self.assertTrue({"image_task_id", "image_task_key"}.issubset(task_columns)) + self.assertTrue( + { + "image_task_id", + "image_task_key", + "cover_reset_count", + "cover_reset_at", + }.issubset(task_columns) + ) + finally: + conn.close() + + self.assert_removed(temp_dir) + + def test_init_db_migrates_cover_reset_columns_for_legacy_tasks(self): + with self.make_temp_dir() as temp_dir: + db_path = os.path.join(temp_dir, "legacy.sqlite") + conn = db.connect(db_path) + try: + with conn: + conn.executescript( + """ + CREATE TABLE batches ( + id TEXT PRIMARY KEY, + source_files_json TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + note TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE tasks ( + id INTEGER PRIMARY KEY, + batch_id TEXT NOT NULL REFERENCES batches(id), + source_file TEXT NOT NULL, + source_file_abs TEXT NOT NULL, + source_sheet TEXT NOT NULL, + source_row INTEGER NOT NULL, + row_key TEXT NOT NULL UNIQUE, + account_name TEXT, + alias TEXT NOT NULL, + item_id TEXT NOT NULL, + old_title TEXT, + old_cover_path TEXT, + new_title TEXT, + new_cover_path TEXT, + image_task_id TEXT, + image_task_key TEXT, + committed INTEGER NOT NULL DEFAULT 0, + stage TEXT NOT NULL DEFAULT 'imported', + status TEXT NOT NULL DEFAULT 'pending', + last_error TEXT, + collect_attempts INTEGER NOT NULL DEFAULT 0, + generate_attempts INTEGER NOT NULL DEFAULT 0, + apply_attempts INTEGER NOT NULL DEFAULT 0, + imported_at TEXT NOT NULL, + collected_at TEXT, + generated_at TEXT, + applied_at TEXT, + updated_at TEXT NOT NULL, + UNIQUE(batch_id, source_file_abs, source_sheet, source_row) + ); + INSERT INTO batches + (id, source_files_json, status, note, created_at, updated_at) + VALUES ('batch', '[]', 'active', NULL, '2026-07-11T00:00:00', '2026-07-11T00:00:00'); + INSERT INTO tasks + (batch_id, source_file, source_file_abs, source_sheet, source_row, + row_key, account_name, alias, item_id, imported_at, updated_at) + VALUES + ('batch', 'input.xlsx', 'input.xlsx', 'Sheet1', 2, + 'row-key', 'shop', 'alias', '51100639510', + '2026-07-11T00:00:00', '2026-07-11T00:00:00'); + """ + ) + db.init_db(conn=conn) + columns = { + row["name"] + for row in conn.execute("PRAGMA table_info(tasks)").fetchall() + } + self.assertIn("cover_reset_count", columns) + self.assertIn("cover_reset_at", columns) + task = db.get_task(1, conn=conn) + self.assertEqual(0, task.cover_reset_count) + self.assertIsNone(task.cover_reset_at) finally: conn.close() @@ -87,6 +168,8 @@ class DbTests(TempDirMixin, unittest.TestCase): self.assertEqual("pending", task.status) self.assertIsNone(task.image_task_id) self.assertIsNone(task.image_task_key) + self.assertEqual(0, task.cover_reset_count) + self.assertIsNone(task.cover_reset_at) db.mark_running(task.id, "collect", path=db_path) self.assertEqual("running", db.list_tasks(path=db_path)[0].status) @@ -174,6 +257,8 @@ class DbTests(TempDirMixin, unittest.TestCase): )["after"] self.assertEqual("cmhub-task-1", title_only.image_task_id) self.assertEqual(key, title_only.image_task_key) + self.assertEqual(0, title_only.cover_reset_count) + self.assertIsNone(title_only.cover_reset_at) cover_reset = db.reset_generated( task.id, @@ -183,6 +268,8 @@ class DbTests(TempDirMixin, unittest.TestCase): )["after"] self.assertIsNone(cover_reset.image_task_id) self.assertIsNone(cover_reset.image_task_key) + self.assertEqual(1, cover_reset.cover_reset_count) + self.assertIsNotNone(cover_reset.cover_reset_at) new_key = db.ensure_image_task_key(task.id, path=db_path) self.assertNotEqual(key, new_key) @@ -313,6 +400,8 @@ class DbTests(TempDirMixin, unittest.TestCase): self.assertIsNone(after_generated.new_cover_path) self.assertIsNone(after_generated.last_error) self.assertEqual(1, after_generated.committed) + self.assertEqual(1, after_generated.cover_reset_count) + self.assertIsNotNone(after_generated.cover_reset_at) self.assert_removed(temp_dir) @@ -368,6 +457,8 @@ class DbTests(TempDirMixin, unittest.TestCase): self.assertEqual(first_cover, after_title.new_cover_path) self.assertIsNone(after_title.last_error) self.assertTrue(os.path.exists(first_cover)) + self.assertEqual(0, after_title.cover_reset_count) + self.assertIsNone(after_title.cover_reset_at) cover_reset = db.reset_generated( second.id, @@ -385,6 +476,8 @@ class DbTests(TempDirMixin, unittest.TestCase): self.assertIsNotNone(cover_reset["archived_file"]) self.assertFalse(os.path.exists(second_cover)) self.assertTrue(os.path.exists(cover_reset["archived_file"])) + self.assertEqual(1, after_cover.cover_reset_count) + self.assertIsNotNone(after_cover.cover_reset_at) with self.assertRaises(db.DbError): db.reset_generated(first.id, reset_title=False, reset_cover=False, path=db_path) @@ -443,6 +536,7 @@ class DbTests(TempDirMixin, unittest.TestCase): after = db.get_task(task.id, path=db_path) self.assertEqual("新标题", after.new_title) self.assertIsNone(after.new_cover_path) + self.assertEqual(1, after.cover_reset_count) self.assert_removed(temp_dir) @@ -478,6 +572,8 @@ class DbTests(TempDirMixin, unittest.TestCase): after = db.get_task(task.id, path=db_path) self.assertEqual(cover, after.new_cover_path) self.assertEqual("新标题", after.new_title) + self.assertEqual(0, after.cover_reset_count) + self.assertIsNone(after.cover_reset_at) self.assertTrue(os.path.exists(cover)) self.assert_removed(temp_dir) @@ -563,6 +659,72 @@ class DbTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_cover_reset_history_persists_after_selecting_or_generating_cover(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": 2, + "account_name": "shop", + "alias": "alias", + "item_id": "51100639510", + }, + { + "source_file_abs": os.path.join(temp_dir, "input.xlsx"), + "source_sheet": "Sheet1", + "source_row": 3, + "account_name": "shop", + "alias": "alias", + "item_id": "51100639511", + }, + ], + path=db_path, + ) + first, second = db.list_tasks(batch_id=batch_id, path=db_path) + first_cover = os.path.join(temp_dir, "first.jpg") + selected_cover = os.path.join(temp_dir, "selected.jpg") + regenerated_cover = os.path.join(temp_dir, "regenerated.jpg") + for cover in (first_cover, selected_cover, regenerated_cover): + with open(cover, "wb") as fh: + fh.write(b"jpeg") + db.set_generated(first.id, "新标题", first_cover, path=db_path) + + reset = db.reset_generated( + first.id, + reset_title=False, + reset_cover=True, + path=db_path, + )["after"] + self.assertEqual(1, reset.cover_reset_count) + self.assertIsNotNone(reset.cover_reset_at) + + db.update_generated_cover(first.id, selected_cover, path=db_path) + selected = db.get_task(first.id, path=db_path) + self.assertEqual(1, selected.cover_reset_count) + self.assertIsNotNone(selected.cover_reset_at) + + db.set_generated(first.id, selected.new_title, regenerated_cover, path=db_path) + regenerated = db.get_task(first.id, path=db_path) + self.assertEqual(1, regenerated.cover_reset_count) + self.assertIsNotNone(regenerated.cover_reset_at) + + empty_reset = db.reset_generated( + second.id, + reset_title=False, + reset_cover=True, + path=db_path, + )["after"] + self.assertEqual(0, empty_reset.cover_reset_count) + self.assertIsNone(empty_reset.cover_reset_at) + + self.assert_removed(temp_dir) + def test_delete_batch_soft_hides_batch_and_tasks(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 61d9987..424b65a 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -384,6 +384,58 @@ class GuiTests(TempDirMixin, unittest.TestCase): apply_model.data(apply_model.index(0, 5), gui.Qt.ToolTipRole), ) + def test_generate_table_marks_cover_reset_history_on_item_id_column(self): + account = SimpleNamespace(alias="papa", account_name="papa 店铺") + reset_task = SimpleNamespace( + alias="papa", + account_name="papa 店铺", + item_id="26887160467", + old_title="旧标题", + new_title="新标题", + new_cover_path="new.jpg", + stage="generated", + status="success", + last_error="", + collect_attempts=1, + generate_attempts=1, + apply_attempts=0, + committed=0, + cover_reset_count=2, + cover_reset_at="2026-07-11T10:00:00", + ) + normal_task = SimpleNamespace( + alias="papa", + account_name="papa 店铺", + item_id="28431952912", + old_title="旧标题", + new_title="新标题", + new_cover_path="new.jpg", + stage="generated", + status="success", + last_error="", + collect_attempts=1, + generate_attempts=1, + apply_attempts=0, + committed=0, + cover_reset_count=0, + cover_reset_at=None, + ) + model = gui.GenerateTaskTableModel() + model.set_tasks([reset_task, normal_task], [account]) + + self.assert_foreground(model, 0, 1, gui.COLOR_WARNING) + self.assertIsNone(model.data(model.index(1, 1), gui.Qt.ForegroundRole)) + self.assertIn( + "该商品封面已重置 2 次", + model.data(model.index(0, 1), gui.Qt.ToolTipRole), + ) + self.assertIn( + "双击可查看封面画廊", + model.data(model.index(0, 1), gui.Qt.ToolTipRole), + ) + self.assert_foreground(model, 0, 4, gui.COLOR_SUCCESS) + self.assert_foreground(model, 0, 5, gui.COLOR_SUCCESS) + def test_main_window_has_five_tabs_in_workflow_order(self): with self.make_temp_dir() as temp_dir: window = MainWindow(config=self.make_config(temp_dir))