feat(ai): mark cover reset history in generate table
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
+19
-2
@@ -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`
|
||||
|
||||
+163
-1
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user