feat(ai): mark cover reset history in generate table

This commit is contained in:
chengma
2026-07-11 12:06:17 +08:00
parent b71fa60b9e
commit 5c01377173
5 changed files with 280 additions and 4 deletions
+31 -1
View File
@@ -141,6 +141,8 @@ class Task:
new_cover_path: Optional[str] new_cover_path: Optional[str]
image_task_id: Optional[str] image_task_id: Optional[str]
image_task_key: Optional[str] image_task_key: Optional[str]
cover_reset_count: int
cover_reset_at: Optional[str]
committed: int committed: int
stage: str stage: str
status: str status: str
@@ -236,6 +238,8 @@ CREATE TABLE IF NOT EXISTS tasks (
new_cover_path TEXT, new_cover_path TEXT,
image_task_id TEXT, image_task_id TEXT,
image_task_key TEXT, image_task_key TEXT,
cover_reset_count INTEGER NOT NULL DEFAULT 0,
cover_reset_at TEXT,
committed INTEGER NOT NULL DEFAULT 0, committed INTEGER NOT NULL DEFAULT 0,
stage TEXT NOT NULL DEFAULT 'imported', stage TEXT NOT NULL DEFAULT 'imported',
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
@@ -389,6 +393,7 @@ def init_db(path=None, conn=None) -> None:
database.executescript(SCHEMA_SQL) database.executescript(SCHEMA_SQL)
_ensure_batch_delete_columns(database) _ensure_batch_delete_columns(database)
_ensure_task_image_task_columns(database) _ensure_task_image_task_columns(database)
_ensure_task_cover_reset_columns(database)
def _ensure_batch_delete_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: if "image_task_key" not in columns:
database.execute("ALTER TABLE tasks ADD COLUMN image_task_key TEXT") 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: 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] 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] 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 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_id = None if reset_cover else before.image_task_id
image_task_key = None if reset_cover else before.image_task_key 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: with database:
database.execute( database.execute(
""" """
@@ -977,13 +996,24 @@ def reset_generated(
new_cover_path = ?, new_cover_path = ?,
image_task_id = ?, image_task_id = ?,
image_task_key = ?, image_task_key = ?,
cover_reset_count = ?,
cover_reset_at = ?,
stage = 'generated', stage = 'generated',
status = 'success', status = 'success',
last_error = NULL, last_error = NULL,
updated_at = ? updated_at = ?
WHERE id = ? 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) after = get_task(task_id, conn=database)
return { return {
+15
View File
@@ -217,9 +217,14 @@ class GenerateTaskTableModel(QAbstractTableModel):
task = self.tasks[index.row()] task = self.tasks[index.row()]
if role in (Qt.DisplayRole, Qt.EditRole): if role in (Qt.DisplayRole, Qt.EditRole):
return self._display_value(task, index.column()) 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}: if role == Qt.ForegroundRole and index.column() in {4, 5}:
return self._component_status_color(self._display_value(task, index.column())) return self._component_status_color(self._display_value(task, index.column()))
if role == Qt.ToolTipRole: 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): if index.column() == 3 and self._can_edit_title(task):
return "双击可微调新标题,只修改本地待更新内容" return "双击可微调新标题,只修改本地待更新内容"
if task.last_error: if task.last_error:
@@ -300,6 +305,16 @@ class GenerateTaskTableModel(QAbstractTableModel):
except (TypeError, ValueError): except (TypeError, ValueError):
return 0 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): def _has_new_title(self, task):
return bool(str(getattr(task, "new_title", "") or "").strip()) return bool(str(getattr(task, "new_title", "") or "").strip())
+19 -2
View File
@@ -3,7 +3,7 @@ id: T-603
title: ②AI生成封面重置计数与商品ID警示色 title: ②AI生成封面重置计数与商品ID警示色
phase: 7 phase: 7
deps: [T-511, T-534, T-554, T-566, T-577] deps: [T-511, T-534, T-554, T-566, T-577]
status: TODO status: DONE
created: 2026-07-11 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`
+163 -1
View File
@@ -34,7 +34,88 @@ class DbTests(TempDirMixin, unittest.TestCase):
row["name"] row["name"]
for row in conn.execute("PRAGMA table_info(tasks)").fetchall() 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: finally:
conn.close() conn.close()
@@ -87,6 +168,8 @@ class DbTests(TempDirMixin, unittest.TestCase):
self.assertEqual("pending", task.status) self.assertEqual("pending", task.status)
self.assertIsNone(task.image_task_id) self.assertIsNone(task.image_task_id)
self.assertIsNone(task.image_task_key) 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) db.mark_running(task.id, "collect", path=db_path)
self.assertEqual("running", db.list_tasks(path=db_path)[0].status) self.assertEqual("running", db.list_tasks(path=db_path)[0].status)
@@ -174,6 +257,8 @@ class DbTests(TempDirMixin, unittest.TestCase):
)["after"] )["after"]
self.assertEqual("cmhub-task-1", title_only.image_task_id) self.assertEqual("cmhub-task-1", title_only.image_task_id)
self.assertEqual(key, title_only.image_task_key) 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( cover_reset = db.reset_generated(
task.id, task.id,
@@ -183,6 +268,8 @@ class DbTests(TempDirMixin, unittest.TestCase):
)["after"] )["after"]
self.assertIsNone(cover_reset.image_task_id) self.assertIsNone(cover_reset.image_task_id)
self.assertIsNone(cover_reset.image_task_key) 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) new_key = db.ensure_image_task_key(task.id, path=db_path)
self.assertNotEqual(key, new_key) 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.new_cover_path)
self.assertIsNone(after_generated.last_error) self.assertIsNone(after_generated.last_error)
self.assertEqual(1, after_generated.committed) 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) self.assert_removed(temp_dir)
@@ -368,6 +457,8 @@ class DbTests(TempDirMixin, unittest.TestCase):
self.assertEqual(first_cover, after_title.new_cover_path) self.assertEqual(first_cover, after_title.new_cover_path)
self.assertIsNone(after_title.last_error) self.assertIsNone(after_title.last_error)
self.assertTrue(os.path.exists(first_cover)) 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( cover_reset = db.reset_generated(
second.id, second.id,
@@ -385,6 +476,8 @@ class DbTests(TempDirMixin, unittest.TestCase):
self.assertIsNotNone(cover_reset["archived_file"]) self.assertIsNotNone(cover_reset["archived_file"])
self.assertFalse(os.path.exists(second_cover)) self.assertFalse(os.path.exists(second_cover))
self.assertTrue(os.path.exists(cover_reset["archived_file"])) 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): with self.assertRaises(db.DbError):
db.reset_generated(first.id, reset_title=False, reset_cover=False, path=db_path) 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) after = db.get_task(task.id, path=db_path)
self.assertEqual("新标题", after.new_title) self.assertEqual("新标题", after.new_title)
self.assertIsNone(after.new_cover_path) self.assertIsNone(after.new_cover_path)
self.assertEqual(1, after.cover_reset_count)
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
@@ -478,6 +572,8 @@ class DbTests(TempDirMixin, unittest.TestCase):
after = db.get_task(task.id, path=db_path) after = db.get_task(task.id, path=db_path)
self.assertEqual(cover, after.new_cover_path) self.assertEqual(cover, after.new_cover_path)
self.assertEqual("新标题", after.new_title) 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.assertTrue(os.path.exists(cover))
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
@@ -563,6 +659,72 @@ class DbTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) 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): def test_delete_batch_soft_hides_batch_and_tasks(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
db_path = os.path.join(temp_dir, "cmshopee.db") db_path = os.path.join(temp_dir, "cmshopee.db")
+52
View File
@@ -384,6 +384,58 @@ class GuiTests(TempDirMixin, unittest.TestCase):
apply_model.data(apply_model.index(0, 5), gui.Qt.ToolTipRole), 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): def test_main_window_has_five_tabs_in_workflow_order(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
window = MainWindow(config=self.make_config(temp_dir)) window = MainWindow(config=self.make_config(temp_dir))