feat: complete T-534 generated reset enhancements
This commit is contained in:
@@ -247,7 +247,11 @@ def generation_needs(task, generate_cover=False):
|
|||||||
return {"title": False, "cover": False}
|
return {"title": False, "cover": False}
|
||||||
if stage not in {"collected", "generated"}:
|
if stage not in {"collected", "generated"}:
|
||||||
return {"title": False, "cover": False}
|
return {"title": False, "cover": False}
|
||||||
if stage == "generated" and _task_attempt_count(task, "apply_attempts") > 0:
|
if (
|
||||||
|
stage == "generated"
|
||||||
|
and status == "failed"
|
||||||
|
and _task_attempt_count(task, "apply_attempts") > 0
|
||||||
|
):
|
||||||
return {"title": False, "cover": False}
|
return {"title": False, "cover": False}
|
||||||
needs_title = not _has_generated_title(task)
|
needs_title = not _has_generated_title(task)
|
||||||
needs_cover = bool(generate_cover) and not _has_generated_cover(task)
|
needs_cover = bool(generate_cover) and not _has_generated_cover(task)
|
||||||
|
|||||||
@@ -743,32 +743,45 @@ def set_applied(task_id, committed, error=None, path=None, conn=None) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def reset_generated(task_id, delete_file=False, path=None, conn=None) -> dict:
|
def reset_generated(
|
||||||
"""Clear local AI result and move the task back to collected state."""
|
task_id,
|
||||||
|
reset_title=True,
|
||||||
|
reset_cover=True,
|
||||||
|
delete_file=False,
|
||||||
|
path=None,
|
||||||
|
conn=None,
|
||||||
|
) -> dict:
|
||||||
|
"""Clear selected local AI result components and keep the task generatable."""
|
||||||
|
|
||||||
|
if not reset_title and not reset_cover:
|
||||||
|
raise DbError("至少需要选择重置标题或重置封面")
|
||||||
|
if delete_file and not reset_cover:
|
||||||
|
raise DbError("只有重置封面时才允许删除本地新封面文件")
|
||||||
with _connection(conn, path) as database:
|
with _connection(conn, path) as database:
|
||||||
before = get_task(task_id, conn=database)
|
before = get_task(task_id, conn=database)
|
||||||
if before is None:
|
if before is None:
|
||||||
raise DbError(f"任务不存在: {task_id}")
|
raise DbError(f"任务不存在: {task_id}")
|
||||||
new_cover_path = before.new_cover_path
|
new_cover_path = before.new_cover_path
|
||||||
deleted_file = None
|
deleted_file = None
|
||||||
if delete_file:
|
if delete_file and reset_cover and new_cover_path and os.path.isfile(str(new_cover_path)):
|
||||||
raise DbError("重置生成结果默认不删除本地新封面文件,请手动确认后清理")
|
os.remove(str(new_cover_path))
|
||||||
|
deleted_file = new_cover_path
|
||||||
now = _now()
|
now = _now()
|
||||||
|
new_title = None if reset_title else before.new_title
|
||||||
|
new_cover = None if reset_cover else before.new_cover_path
|
||||||
with database:
|
with database:
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE tasks
|
UPDATE tasks
|
||||||
SET new_title = NULL,
|
SET new_title = ?,
|
||||||
new_cover_path = NULL,
|
new_cover_path = ?,
|
||||||
stage = 'collected',
|
stage = 'generated',
|
||||||
status = 'success',
|
status = 'success',
|
||||||
last_error = NULL,
|
last_error = NULL,
|
||||||
generated_at = NULL,
|
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""",
|
""",
|
||||||
(now, int(task_id)),
|
(new_title, new_cover, now, int(task_id)),
|
||||||
)
|
)
|
||||||
after = get_task(task_id, conn=database)
|
after = get_task(task_id, conn=database)
|
||||||
return {
|
return {
|
||||||
@@ -777,6 +790,8 @@ def reset_generated(task_id, delete_file=False, path=None, conn=None) -> dict:
|
|||||||
"after": after,
|
"after": after,
|
||||||
"new_cover_path": new_cover_path,
|
"new_cover_path": new_cover_path,
|
||||||
"deleted_file": deleted_file,
|
"deleted_file": deleted_file,
|
||||||
|
"reset_title": bool(reset_title),
|
||||||
|
"reset_cover": bool(reset_cover),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+176
-37
@@ -21,6 +21,11 @@ class GenerateTab(QWidget):
|
|||||||
("略过", "skipped"),
|
("略过", "skipped"),
|
||||||
("已更新", "applied"),
|
("已更新", "applied"),
|
||||||
]
|
]
|
||||||
|
RESET_MODE_LABELS = {
|
||||||
|
"title": "标题",
|
||||||
|
"cover": "封面",
|
||||||
|
"all": "标题和封面",
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -162,7 +167,7 @@ class GenerateTab(QWidget):
|
|||||||
self.model = GenerateTaskTableModel(self.task_table, db_path=self.db_path, status_callback=self._set_status)
|
self.model = GenerateTaskTableModel(self.task_table, db_path=self.db_path, status_callback=self._set_status)
|
||||||
self.task_table.setModel(self.model)
|
self.task_table.setModel(self.model)
|
||||||
self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||||
self.task_table.setSelectionMode(QAbstractItemView.SingleSelection)
|
self.task_table.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||||
self.task_table.setEditTriggers(QAbstractItemView.DoubleClicked | QAbstractItemView.EditKeyPressed)
|
self.task_table.setEditTriggers(QAbstractItemView.DoubleClicked | QAbstractItemView.EditKeyPressed)
|
||||||
self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
|
self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
|
||||||
self.task_table.verticalHeader().setVisible(False)
|
self.task_table.verticalHeader().setVisible(False)
|
||||||
@@ -531,57 +536,191 @@ class GenerateTab(QWidget):
|
|||||||
if self.generate_thread is not None:
|
if self.generate_thread is not None:
|
||||||
self._set_status("AI 生成正在进行,不能重置")
|
self._set_status("AI 生成正在进行,不能重置")
|
||||||
return
|
return
|
||||||
task = self._selected_task()
|
scope, tasks = self._reset_scope_tasks()
|
||||||
if task is None:
|
if not tasks:
|
||||||
self._set_status("请选择要重置生成结果的任务")
|
self._set_status("当前没有可重置生成结果的任务")
|
||||||
return
|
return
|
||||||
has_ai_result = bool(getattr(task, "new_title", None) or getattr(task, "new_cover_path", None))
|
if not self._resettable_tasks(tasks, reset_title=True, reset_cover=True):
|
||||||
if not has_ai_result and getattr(task, "stage", None) not in {"generated", "applied"}:
|
self._set_status("当前范围没有可重置的生成结果")
|
||||||
self._set_status("选中任务没有可重置的生成结果")
|
|
||||||
return
|
return
|
||||||
lines = [
|
mode = self._ask_reset_generated_mode(tasks, scope)
|
||||||
"确定重置当前选中任务的本地生成结果吗?",
|
if mode is None:
|
||||||
"",
|
|
||||||
f"商品ID:{task.item_id}",
|
|
||||||
f"店铺:{self.model.account_name_for(task)}",
|
|
||||||
"",
|
|
||||||
"将清空新标题、新封面路径和错误信息,并退回到已采集状态。",
|
|
||||||
"默认不删除本地新封面文件,不触碰 Shopee,也不会自动回写 Excel。",
|
|
||||||
]
|
|
||||||
if getattr(task, "new_cover_path", None):
|
|
||||||
lines.append(f"本地新封面文件保留:{task.new_cover_path}")
|
|
||||||
if getattr(task, "committed", 0):
|
|
||||||
lines.extend([
|
|
||||||
"",
|
|
||||||
"注意:该记录曾经提交过线上。本地重置不会回滚 Shopee,后续重新生成/更新可能再次提交线上。",
|
|
||||||
])
|
|
||||||
answer = QMessageBox.question(
|
|
||||||
self,
|
|
||||||
"重置生成结果",
|
|
||||||
"\n".join(lines),
|
|
||||||
QMessageBox.Yes | QMessageBox.No,
|
|
||||||
QMessageBox.No,
|
|
||||||
)
|
|
||||||
if answer != QMessageBox.Yes:
|
|
||||||
self._set_status("已取消重置生成结果")
|
self._set_status("已取消重置生成结果")
|
||||||
return
|
return
|
||||||
|
reset_title, reset_cover = self._reset_mode_flags(mode)
|
||||||
|
target_tasks = self._resettable_tasks(
|
||||||
|
tasks,
|
||||||
|
reset_title=reset_title,
|
||||||
|
reset_cover=reset_cover,
|
||||||
|
)
|
||||||
|
if not target_tasks:
|
||||||
|
self._set_status(f"当前范围没有可重置的{self.RESET_MODE_LABELS[mode]}")
|
||||||
|
return
|
||||||
|
label = self.RESET_MODE_LABELS[mode]
|
||||||
try:
|
try:
|
||||||
db.reset_generated(task.id, path=self.db_path)
|
for task in target_tasks:
|
||||||
message = (
|
db.reset_generated(
|
||||||
"action=reset_generated step=db_write result=success "
|
task.id,
|
||||||
f"detail=清空AI生成结果 task_id={task.id}"
|
reset_title=reset_title,
|
||||||
|
reset_cover=reset_cover,
|
||||||
|
path=self.db_path,
|
||||||
|
)
|
||||||
|
run_id = self._write_generated_reset_run_log(
|
||||||
|
target_tasks,
|
||||||
|
scope,
|
||||||
|
mode,
|
||||||
|
reset_title=reset_title,
|
||||||
|
reset_cover=reset_cover,
|
||||||
)
|
)
|
||||||
run_id = _write_reset_run_log(self.db_path, task, "reset_generated", message)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
QMessageBox.warning(self, "重置生成结果", str(exc))
|
QMessageBox.warning(self, "重置生成结果", str(exc))
|
||||||
self._set_status(f"重置生成结果失败:{exc}")
|
self._set_status(f"重置生成结果失败:{exc}")
|
||||||
return
|
return
|
||||||
|
message = (
|
||||||
|
"action=reset_generated step=db_write result=success "
|
||||||
|
f"detail=重置{label} scope={scope} total={len(target_tasks)}"
|
||||||
|
)
|
||||||
self.refresh_tasks()
|
self.refresh_tasks()
|
||||||
self._append_generate_log(message)
|
self._append_generate_log(message)
|
||||||
self._set_status(
|
self._set_status(
|
||||||
f"已重置生成结果:商品 {task.item_id},run_id={run_id}"
|
f"已重置生成结果:{len(target_tasks)} 条,内容:{label},run_id={run_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _reset_scope_tasks(self):
|
||||||
|
selection = self.task_table.selectionModel()
|
||||||
|
selected_rows = []
|
||||||
|
if selection is not None:
|
||||||
|
selected_rows = sorted({index.row() for index in selection.selectedRows()})
|
||||||
|
if selected_rows:
|
||||||
|
tasks = [self.model.task_at(row) for row in selected_rows]
|
||||||
|
return "selected", [task for task in tasks if task is not None]
|
||||||
|
return "filtered", list(self.model.tasks)
|
||||||
|
|
||||||
|
def _ask_reset_generated_mode(self, tasks, scope):
|
||||||
|
scope_text = "选中任务" if scope == "selected" else "当前筛选结果"
|
||||||
|
title_count = sum(1 for task in tasks if getattr(task, "new_title", None))
|
||||||
|
cover_count = sum(1 for task in tasks if getattr(task, "new_cover_path", None))
|
||||||
|
committed_count = sum(1 for task in tasks if int(getattr(task, "committed", 0) or 0))
|
||||||
|
running_count = sum(1 for task in tasks if getattr(task, "status", None) == "running")
|
||||||
|
sample_ids = [str(getattr(task, "item_id", "") or "") for task in tasks[:5]]
|
||||||
|
sample_ids = [item_id for item_id in sample_ids if item_id]
|
||||||
|
sample_text = "、".join(sample_ids)
|
||||||
|
if len(tasks) > 5:
|
||||||
|
sample_text += f" 等 {len(tasks)} 条"
|
||||||
|
lines = [
|
||||||
|
"请选择要重置的本地生成内容。",
|
||||||
|
"",
|
||||||
|
f"作用范围:{scope_text} {len(tasks)} 条",
|
||||||
|
f"包含新标题:{title_count} 条",
|
||||||
|
f"包含新封面:{cover_count} 条",
|
||||||
|
]
|
||||||
|
if sample_text:
|
||||||
|
lines.append(f"商品ID:{sample_text}")
|
||||||
|
if running_count:
|
||||||
|
lines.append(f"运行中任务会跳过:{running_count} 条")
|
||||||
|
lines.extend([
|
||||||
|
"",
|
||||||
|
"重置标题:只清空新标题,保留新封面。",
|
||||||
|
"重置封面:只清空新封面,保留新标题和手动微调标题。",
|
||||||
|
"重置全部:清空新标题和新封面。",
|
||||||
|
"",
|
||||||
|
"不会触碰 Shopee,也不会自动回写 Excel;后续重新生成会消耗 cmhub 点数。",
|
||||||
|
"默认不删除本地新封面文件。",
|
||||||
|
])
|
||||||
|
if committed_count:
|
||||||
|
lines.extend([
|
||||||
|
"",
|
||||||
|
f"注意:其中 {committed_count} 条已经提交过线上。本地重置不会回滚 Shopee,重生成后再更新会再次提交线上。",
|
||||||
|
])
|
||||||
|
box = QMessageBox(self)
|
||||||
|
box.setWindowTitle("重置生成结果")
|
||||||
|
box.setText("\n".join(lines))
|
||||||
|
title_button = box.addButton("重置标题", QMessageBox.AcceptRole)
|
||||||
|
cover_button = box.addButton("重置封面", QMessageBox.AcceptRole)
|
||||||
|
all_button = box.addButton("重置全部", QMessageBox.DestructiveRole)
|
||||||
|
cancel_button = box.addButton("取消", QMessageBox.RejectRole)
|
||||||
|
box.setDefaultButton(cancel_button)
|
||||||
|
box.exec()
|
||||||
|
clicked = box.clickedButton()
|
||||||
|
if clicked is title_button:
|
||||||
|
return "title"
|
||||||
|
if clicked is cover_button:
|
||||||
|
return "cover"
|
||||||
|
if clicked is all_button:
|
||||||
|
return "all"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _reset_mode_flags(self, mode):
|
||||||
|
if mode == "title":
|
||||||
|
return True, False
|
||||||
|
if mode == "cover":
|
||||||
|
return False, True
|
||||||
|
return True, True
|
||||||
|
|
||||||
|
def _resettable_tasks(self, tasks, reset_title=True, reset_cover=True):
|
||||||
|
resettable = []
|
||||||
|
for task in tasks:
|
||||||
|
if getattr(task, "status", None) == "running":
|
||||||
|
continue
|
||||||
|
has_title = bool(getattr(task, "new_title", None))
|
||||||
|
has_cover = bool(getattr(task, "new_cover_path", None))
|
||||||
|
if reset_title and has_title:
|
||||||
|
resettable.append(task)
|
||||||
|
continue
|
||||||
|
if reset_cover and has_cover:
|
||||||
|
resettable.append(task)
|
||||||
|
return resettable
|
||||||
|
|
||||||
|
def _write_generated_reset_run_log(
|
||||||
|
self,
|
||||||
|
tasks,
|
||||||
|
scope,
|
||||||
|
mode,
|
||||||
|
reset_title=True,
|
||||||
|
reset_cover=True,
|
||||||
|
):
|
||||||
|
task_ids = [getattr(task, "id", None) for task in tasks]
|
||||||
|
committed_count = sum(1 for task in tasks if int(getattr(task, "committed", 0) or 0))
|
||||||
|
options = {
|
||||||
|
"action": "reset_generated",
|
||||||
|
"scope": scope,
|
||||||
|
"mode": mode,
|
||||||
|
"reset_title": bool(reset_title),
|
||||||
|
"reset_cover": bool(reset_cover),
|
||||||
|
"task_ids": task_ids,
|
||||||
|
"committed_count": committed_count,
|
||||||
|
}
|
||||||
|
run_id = db.create_run_log(
|
||||||
|
"reset",
|
||||||
|
total=len(tasks),
|
||||||
|
options=options,
|
||||||
|
path=self.db_path,
|
||||||
|
)
|
||||||
|
label = self.RESET_MODE_LABELS[mode]
|
||||||
|
for task in tasks:
|
||||||
|
message = (
|
||||||
|
"action=reset_generated step=db_write result=success "
|
||||||
|
f"detail=重置{label} task_id={getattr(task, 'id', None)}"
|
||||||
|
)
|
||||||
|
db.add_run_log_event(
|
||||||
|
run_id,
|
||||||
|
message,
|
||||||
|
task_id=getattr(task, "id", None),
|
||||||
|
alias=getattr(task, "alias", None),
|
||||||
|
item_id=getattr(task, "item_id", None),
|
||||||
|
path=self.db_path,
|
||||||
|
)
|
||||||
|
db.finish_run_log(
|
||||||
|
run_id,
|
||||||
|
status="done",
|
||||||
|
done=len(tasks),
|
||||||
|
success_count=len(tasks),
|
||||||
|
failed_count=0,
|
||||||
|
summary_json=options,
|
||||||
|
path=self.db_path,
|
||||||
|
)
|
||||||
|
return run_id
|
||||||
|
|
||||||
def show_task_images(self, index):
|
def show_task_images(self, index):
|
||||||
if index.isValid() and index.column() == 3:
|
if index.isValid() and index.column() == 3:
|
||||||
return
|
return
|
||||||
|
|||||||
+2
-2
@@ -77,7 +77,7 @@
|
|||||||
| T-401 | Tab③ 列出已生成任务 + 按批次/店铺/商品ID/状态筛选 + 开始更新确认弹窗 | T-303 | 顶部批次/店铺/商品ID/状态筛选;「开始更新」仅作用于当前筛选结果;弹窗显示筛选条件/任务数/线上提交风险;取消不执行;状态=失败可重试;无常驻提交开关 | DONE |
|
| T-401 | Tab③ 列出已生成任务 + 按批次/店铺/商品ID/状态筛选 + 开始更新确认弹窗 | T-303 | 顶部批次/店铺/商品ID/状态筛选;「开始更新」仅作用于当前筛选结果;弹窗显示筛选条件/任务数/线上提交风险;取消不执行;状态=失败可重试;无常驻提交开关 | DONE |
|
||||||
| T-402 | 串行执行 apply:批量确认后换标题+换封面+点更新提交,单条失败继续,立即写库 | T-401, T-001, T-104b | 确认后 worker 串行执行并逐条 set_applied;失败继续;未确认时不调用 apply;真实更新前检查本轮账号 Chrome/CDP/登录态,未启动或未登录时整体阻断并提示去④,不自动打开 Chrome | DONE |
|
| T-402 | 串行执行 apply:批量确认后换标题+换封面+点更新提交,单条失败继续,立即写库 | T-401, T-001, T-104b | 确认后 worker 串行执行并逐条 set_applied;失败继续;未确认时不调用 apply;真实更新前检查本轮账号 Chrome/CDP/登录态,未启动或未登录时整体阻断并提示去④,不自动打开 Chrome | DONE |
|
||||||
| T-403 | 回写结果到原 Excel(新标题/新封面/更新状态)+ 结束弹窗汇总 | T-402, T-204 | 回写原文件(锁处理);弹窗 成功/失败/略过 | DONE |
|
| T-403 | 回写结果到原 Excel(新标题/新封面/更新状态)+ 结束弹窗汇总 | T-402, T-204 | 回写原文件(锁处理);弹窗 成功/失败/略过 | DONE |
|
||||||
| T-404a | ②/③ 选中记录重置(联调重试辅助) | T-303, T-403, T-504 | 现状:② AI生成和③ 更新shopee列表没有重置选中记录的功能,生成/上传/提交成功后难以用同一商品重复测试。方案:② 增加「重置生成结果」按钮,仅作用当前选中单条,确认后清空本地 `new_title/new_cover_path`、清空 `last_error`,把任务退回 `stage=collected/status=success`,默认不删除本地新封面文件,可在确认框提示路径;③ 增加「重置更新状态」按钮,仅作用当前选中单条,保留 `new_title/new_cover_path`,确认后把本地状态退回可更新(`stage=generated/status=pending`、清空 `last_error`),用于重复测试上传/提交。若记录 `committed=1`,确认框必须醒目提示“线上已提交过,本地重置不会回滚 Shopee,重复更新会再次提交线上”,并保留 committed 历史事实/运行日志;运行中禁用;两类重置都写 `run_type=reset` 运行日志,不触碰 Shopee、不回写 Excel,后续由手动回写同步本地状态 | DONE |
|
| T-404a | ②/③ 选中记录重置(联调重试辅助) | T-303, T-403, T-504 | 初版解决 ② AI生成和③ 更新shopee列表无法重置选中记录的问题:③「重置更新状态」保留 `new_title/new_cover_path`,把本地状态退回可更新(`stage=generated/status=pending`、清空 `last_error`),用于重复测试上传/提交。②「重置生成结果」后续已由 T-534 升级,不再按初版退回 `collected` 或仅作用单选;当前以 T-534 为准:支持多选/当前筛选结果,确认框可选「重置标题 / 重置封面 / 重置全部」,只清对应 `new_title` 或 `new_cover_path` 与 `last_error`,保持 `stage=generated/status=success`,由 T-533 后续只补缺失组件;只重置封面时必须保留 `new_title` 和 T-509 手动标题。若记录 `committed=1`,确认框必须醒目提示“线上已提交过,本地重置不会回滚 Shopee,重复更新会再次提交线上”,并保留 committed 历史事实/运行日志;运行中禁用;两类重置都写 `run_type=reset` 运行日志,不触碰 Shopee、不回写 Excel,后续由手动回写同步本地状态 | DONE |
|
||||||
| T-404 | 真实 Shopee 单条更新冒烟验收 | T-403, T-501c | 已完成真实验收:2026-07-01 用户手动导入包含 5 个商品 ID 的 Excel,完成 AI 标题/图片生成,并通过 Tab③「开始更新」流程更新到 Shopee;覆盖真实编辑页、标题/封面更新、Shopee 站点侧确认框、提交后结果判断与本地状态链路。后续若 Shopee 页面结构变化,仍需按 CDP 规则补回归验证 | DONE |
|
| T-404 | 真实 Shopee 单条更新冒烟验收 | T-403, T-501c | 已完成真实验收:2026-07-01 用户手动导入包含 5 个商品 ID 的 Excel,完成 AI 标题/图片生成,并通过 Tab③「开始更新」流程更新到 Shopee;覆盖真实编辑页、标题/封面更新、Shopee 站点侧确认框、提交后结果判断与本地状态链路。后续若 Shopee 页面结构变化,仍需按 CDP 规则补回归验证 | DONE |
|
||||||
| T-404b | 商品详情页加载失败 toast 自动捕获 | T-404, T-505 | 问题:③ 更新或①采集打开商品详情页时,如果商品 ID 已失效、无权限或店铺不匹配,Shopee 可能只弹出很快消失的错误 toast,例如 `please input correct product id`;用户来不及复制 HTML,GUI 也可能只报“商品详情页加载失败/等待超时”,无法判断真实原因。方案:在 `open_product()`/等待商品详情页就绪前安装或启用 toast 捕获,监听并兜底读取 `.eds-toasts .eds-toast__content`,保存最近 toast 的文本、`outerHTML`、当前 URL、时间和可见状态到页面内缓存;当标题输入框/图片管理器/上传入口等关键元素超时或页面不是商品编辑页时,优先把最近错误 toast 提升为失败原因,写入 `tasks.last_error`、`run_logs/run_log_events` 和本地 `logs/` 诊断日志,并可保存失败现场 HTML/toast JSON 片段用于排查。若 toast 明确属于商品失效/商品不存在/无权限类错误,则底层仍保持 `stage=imported/status=failed/last_error=商品失效:<原始toast>`,①导入采集列表“阶段”列显示“商品失效”;其他打开失败仍显示“失败”。如果失败发生在 `open_product()` 内部、尚未把 `cdp` 返回给上层,仍必须由 `open_product()` 自己清理:本轮自动新建的商品 tab 要断开 CDP 后关闭浏览器 target;复用用户原本打开的 tab 只断开 CDP,不关闭页面。不得记录 Cookie、密码、token;不绕过 Shopee 校验,不改变上传、删图、提交逻辑。同步 editor/CDP mock 和 GUI model 测试覆盖短暂 toast、隐藏 toast、商品 ID 失效错误文案上浮、自动新开 tab 在商品失效时关闭、复用 tab 不关闭、仅明确失效 toast 才显示“商品失效” | DONE |
|
| T-404b | 商品详情页加载失败 toast 自动捕获 | T-404, T-505 | 问题:③ 更新或①采集打开商品详情页时,如果商品 ID 已失效、无权限或店铺不匹配,Shopee 可能只弹出很快消失的错误 toast,例如 `please input correct product id`;用户来不及复制 HTML,GUI 也可能只报“商品详情页加载失败/等待超时”,无法判断真实原因。方案:在 `open_product()`/等待商品详情页就绪前安装或启用 toast 捕获,监听并兜底读取 `.eds-toasts .eds-toast__content`,保存最近 toast 的文本、`outerHTML`、当前 URL、时间和可见状态到页面内缓存;当标题输入框/图片管理器/上传入口等关键元素超时或页面不是商品编辑页时,优先把最近错误 toast 提升为失败原因,写入 `tasks.last_error`、`run_logs/run_log_events` 和本地 `logs/` 诊断日志,并可保存失败现场 HTML/toast JSON 片段用于排查。若 toast 明确属于商品失效/商品不存在/无权限类错误,则底层仍保持 `stage=imported/status=failed/last_error=商品失效:<原始toast>`,①导入采集列表“阶段”列显示“商品失效”;其他打开失败仍显示“失败”。如果失败发生在 `open_product()` 内部、尚未把 `cdp` 返回给上层,仍必须由 `open_product()` 自己清理:本轮自动新建的商品 tab 要断开 CDP 后关闭浏览器 target;复用用户原本打开的 tab 只断开 CDP,不关闭页面。不得记录 Cookie、密码、token;不绕过 Shopee 校验,不改变上传、删图、提交逻辑。同步 editor/CDP mock 和 GUI model 测试覆盖短暂 toast、隐藏 toast、商品 ID 失效错误文案上浮、自动新开 tab 在商品失效时关闭、复用 tab 不关闭、仅明确失效 toast 才显示“商品失效” | DONE |
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
| T-531 | ⑤ 设置未保存状态追踪 + 离开确认 | T-530, T-529, T-527 | 现象:在⑤填写 cmhub Base URL/API Key(或改任何设置)后,没有任何"未保存"提示;切换到其它 Tab、关闭窗口都不拦截,输入静默丢失。且「刷新别名/测试连接」用输入框实时文本、不落盘,成功后易误以为已保存。采用**方式 B(显式保存 + 脏状态追踪 + 离开确认)**,不改自动保存(API Key/半份 cmhub 配置不宜自动写盘)。方案:① 脏追踪——⑤ 所有可编辑控件(cmhub Base URL/API Key/别名下拉、模型详情、生成参数、路径端口、Shopee 更新安全等)接 `textChanged`/`currentIndexChanged`/`toggled`/`valueChanged` 置 `self._dirty=True`;「保存设置」成功后清 `False`;`save_app_settings()` 或等价保存入口必须返回成功/失败,失败时保留 dirty 并阻止离开。② 程序化回填保护——加载配置、保存后重载、刷新别名写回下拉、`_populate_app_settings()` / `_populate_cmhub_alias_combos()` 等必须用 `_suspend_dirty` 或 `QSignalBlocker` 避免误置脏;用户手动输入后由刷新/测试触发的 Base URL 规整仍应保持 dirty,提醒其保存。③ 离开拦截——`main_window` 切 Tab 与 `closeEvent` 若发现⑤ `_dirty` 为真,弹三选一「保存 / 放弃 / 取消」:保存则调保存入口且成功后再切/关;放弃必须重新从 `config.json` + `config/cmhub.json` 载入并回填控件,再清 dirty,不能只清标志;取消则留在⑤不切不关。`QTabWidget.currentChanged` 是切换后信号,实现时需维护上一 Tab index,并用 `_reverting_tab_change`/类似 guard 防止 `setCurrentIndex()` 递归触发。④ 视觉提示——「保存设置」旁显示「● 未保存更改」,保存或放弃回填后消失。⑤ 「刷新别名/测试连接」成功后提示语补「别名已拉取,记得点『保存设置』持久化」,**不**顺带偷偷保存 url/key(保持显式保存一致性)。只改 ⑤ 设置页、`main_window` Tab 切换/关闭守卫和 GUI 单测(脏标志置位/清除、切 Tab 保存/放弃/取消/保存失败分支、程序化回填不置脏、放弃会还原输入、currentChanged 回退不递归);不改配置 schema、AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | DONE |
|
| T-531 | ⑤ 设置未保存状态追踪 + 离开确认 | T-530, T-529, T-527 | 现象:在⑤填写 cmhub Base URL/API Key(或改任何设置)后,没有任何"未保存"提示;切换到其它 Tab、关闭窗口都不拦截,输入静默丢失。且「刷新别名/测试连接」用输入框实时文本、不落盘,成功后易误以为已保存。采用**方式 B(显式保存 + 脏状态追踪 + 离开确认)**,不改自动保存(API Key/半份 cmhub 配置不宜自动写盘)。方案:① 脏追踪——⑤ 所有可编辑控件(cmhub Base URL/API Key/别名下拉、模型详情、生成参数、路径端口、Shopee 更新安全等)接 `textChanged`/`currentIndexChanged`/`toggled`/`valueChanged` 置 `self._dirty=True`;「保存设置」成功后清 `False`;`save_app_settings()` 或等价保存入口必须返回成功/失败,失败时保留 dirty 并阻止离开。② 程序化回填保护——加载配置、保存后重载、刷新别名写回下拉、`_populate_app_settings()` / `_populate_cmhub_alias_combos()` 等必须用 `_suspend_dirty` 或 `QSignalBlocker` 避免误置脏;用户手动输入后由刷新/测试触发的 Base URL 规整仍应保持 dirty,提醒其保存。③ 离开拦截——`main_window` 切 Tab 与 `closeEvent` 若发现⑤ `_dirty` 为真,弹三选一「保存 / 放弃 / 取消」:保存则调保存入口且成功后再切/关;放弃必须重新从 `config.json` + `config/cmhub.json` 载入并回填控件,再清 dirty,不能只清标志;取消则留在⑤不切不关。`QTabWidget.currentChanged` 是切换后信号,实现时需维护上一 Tab index,并用 `_reverting_tab_change`/类似 guard 防止 `setCurrentIndex()` 递归触发。④ 视觉提示——「保存设置」旁显示「● 未保存更改」,保存或放弃回填后消失。⑤ 「刷新别名/测试连接」成功后提示语补「别名已拉取,记得点『保存设置』持久化」,**不**顺带偷偷保存 url/key(保持显式保存一致性)。只改 ⑤ 设置页、`main_window` Tab 切换/关闭守卫和 GUI 单测(脏标志置位/清除、切 Tab 保存/放弃/取消/保存失败分支、程序化回填不置脏、放弃会还原输入、currentChanged 回退不递归);不改配置 schema、AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | DONE |
|
||||||
| T-532 | ⑤ cmhub 连接成功提示显示账号名 | T-531, T-527, T-528 | 问题:⑤「测试连接/查余额」或刷新别名成功后,界面只提示 `cmhub 连接成功:...`,用户无法确认当前 API Key 实际属于哪个 cmhub 账号,尤其多账号或换 Key 时容易误判。方案:成功 payload 中若 `/api/v1/balance` 返回可识别账号信息,则在结果 label 和状态栏优先显示 `cmhub 账号「<账号名>」连接成功:生文别名 X 个,生图别名 X 个;余额 Y;别名已拉取,记得点『保存设置』持久化`;当前接口结构按 `{ "user": "cmhub_user", "points_balance": 88, "account": { "username": "cmhub_user", "display_name": "主账号" } }` 兼容,显示名优先取 `account.display_name/name/account_name/username/email/id`,再兜底 `user` 字符串或 `user.name/account_name/username/email/id`、顶层 `display_name/name/username/email/id`;邮箱需要脱敏展示;没有账号字段、仅刷新别名不查余额或接口未返回用户信息时,保留现有 `cmhub 连接成功:...` 文案。不得把 API Key、token、完整邮箱或原始敏感响应写入 GUI、run log、诊断日志;不改变 cmhub HTTP 协议、配置 schema、保存逻辑、AI 生成流程或 Shopee/CDP 流程。同步 GUI 单测覆盖有账号名、当前接口结构、只有邮箱、无账号名四种成功提示 | DONE |
|
| T-532 | ⑤ cmhub 连接成功提示显示账号名 | T-531, T-527, T-528 | 问题:⑤「测试连接/查余额」或刷新别名成功后,界面只提示 `cmhub 连接成功:...`,用户无法确认当前 API Key 实际属于哪个 cmhub 账号,尤其多账号或换 Key 时容易误判。方案:成功 payload 中若 `/api/v1/balance` 返回可识别账号信息,则在结果 label 和状态栏优先显示 `cmhub 账号「<账号名>」连接成功:生文别名 X 个,生图别名 X 个;余额 Y;别名已拉取,记得点『保存设置』持久化`;当前接口结构按 `{ "user": "cmhub_user", "points_balance": 88, "account": { "username": "cmhub_user", "display_name": "主账号" } }` 兼容,显示名优先取 `account.display_name/name/account_name/username/email/id`,再兜底 `user` 字符串或 `user.name/account_name/username/email/id`、顶层 `display_name/name/username/email/id`;邮箱需要脱敏展示;没有账号字段、仅刷新别名不查余额或接口未返回用户信息时,保留现有 `cmhub 连接成功:...` 文案。不得把 API Key、token、完整邮箱或原始敏感响应写入 GUI、run log、诊断日志;不改变 cmhub HTTP 协议、配置 schema、保存逻辑、AI 生成流程或 Shopee/CDP 流程。同步 GUI 单测覆盖有账号名、当前接口结构、只有邮箱、无账号名四种成功提示 | DONE |
|
||||||
| T-533 | ② 增量生成:按缺失组件补生成(先标题后封面分离) | T-303, T-520, T-509 | 现象:先关闭「生成封面图片」只生成了标题(`stage=generated/status=success`、`new_cover_path` 为空),之后勾选封面再点「开始生成」,提示「当前筛选结果没有待生成任务」,无法补生成封面;若改用「重置生成结果」会连标题一起清掉、重生成时白扣一次标题点并覆盖 T-509 手动改的标题。根因:`ai.is_generatable_task` 把 `generated+success` 一律判为不可生成,且 `generate_batch` 对每条 eligible 任务都先重生成标题再生成封面。方案:把「开始生成」改为**按缺失组件增量补齐**——① 判定放宽:`generated+success` 且(封面开关 ON 且 `new_cover_path` 为空)判为可生成(只补封面);② `generate_batch` 组件级跳过:`new_title` 已存在则**跳过标题生成**(复用现有标题,不再调 cmhub 生文、不扣标题点、**不覆盖用户手动标题**),封面开关 ON 且 `new_cover_path` 为空才生成封面;③ 进度计数分组件——`title_total`=缺标题任务数、`cover_total`=(封面开关 ON 时)缺封面任务数,不再用 `len(eligible)` 一刀切;④「没有待生成任务」提示只在**按当前设置真的无缺口**(标题全有 且(封面关 或 封面全有))时出现;⑤ 幂等/点数:全组件齐备的任务不纳入、不重复调用扣点。保持 `set_generated` 写库、run_logs、诊断日志、停止取消未开始项语义不变;不改 cmhub HTTP 协议、DB schema、Excel、Shopee/CDP 流程。单测覆盖:只补封面(跳过标题、标题点不增、手动标题不被覆盖)、混合批(部分缺标题+部分缺封面)、无缺口提示无任务、封面关时标题态任务不纳入 | DONE |
|
| T-533 | ② 增量生成:按缺失组件补生成(先标题后封面分离) | T-303, T-520, T-509 | 现象:先关闭「生成封面图片」只生成了标题(`stage=generated/status=success`、`new_cover_path` 为空),之后勾选封面再点「开始生成」,提示「当前筛选结果没有待生成任务」,无法补生成封面;若改用「重置生成结果」会连标题一起清掉、重生成时白扣一次标题点并覆盖 T-509 手动改的标题。根因:`ai.is_generatable_task` 把 `generated+success` 一律判为不可生成,且 `generate_batch` 对每条 eligible 任务都先重生成标题再生成封面。方案:把「开始生成」改为**按缺失组件增量补齐**——① 判定放宽:`generated+success` 且(封面开关 ON 且 `new_cover_path` 为空)判为可生成(只补封面);② `generate_batch` 组件级跳过:`new_title` 已存在则**跳过标题生成**(复用现有标题,不再调 cmhub 生文、不扣标题点、**不覆盖用户手动标题**),封面开关 ON 且 `new_cover_path` 为空才生成封面;③ 进度计数分组件——`title_total`=缺标题任务数、`cover_total`=(封面开关 ON 时)缺封面任务数,不再用 `len(eligible)` 一刀切;④「没有待生成任务」提示只在**按当前设置真的无缺口**(标题全有 且(封面关 或 封面全有))时出现;⑤ 幂等/点数:全组件齐备的任务不纳入、不重复调用扣点。保持 `set_generated` 写库、run_logs、诊断日志、停止取消未开始项语义不变;不改 cmhub HTTP 协议、DB schema、Excel、Shopee/CDP 流程。单测覆盖:只补封面(跳过标题、标题点不增、手动标题不被覆盖)、混合批(部分缺标题+部分缺封面)、无缺口提示无任务、封面关时标题态任务不纳入 | DONE |
|
||||||
| T-534 | ② 重置增强:多选/筛选范围 + 按组件(标题/封面/全部)重置 | T-533, T-404a, T-509 | 现状:②「重置生成结果」只能对单条、且整条全清(`db.reset_generated` 同时清 `new_title` 和 `new_cover_path` 退回 collected)。真实诉求是精准重置——一批标题不满意但封面 OK(只重标题、保封面、不重扣封面点)、或封面差但标题 OK(只重封面、**保留 `new_title` 保护 T-509 手动标题**、不重扣标题点);且需要作用于多条。方案:与 T-533 的组件模型咬合——① 后端 `db.reset_generated` 加组件开关 `reset_title`/`reset_cover`(`delete_file` 仅在重置封面时可选),只清对应字段与 `last_error`,stage 保持 `generated`,由 T-533 泛化后的 `is_generatable_task`(缺任一必需组件即可生成)识别缺口后由「开始生成」只补被清组件;② UI 重置入口给三选项「重置标题 / 重置封面 / 重置全部」;③ 作用域从单条扩到**多选(表格改 `ExtendedSelection`)或当前筛选结果**,与③「作用于当前筛选结果」心智一致;④ 二次确认框显示将重置的标题/封面条数、其中 `committed=1` 条数,并提示「线上已提交、本地重置不回滚 Shopee、重生成后再更新会再次提交线上」+ 重生成会消耗 cmhub 点数;⑤ 运行中禁用;批量写 `run_type=reset` 运行日志,不触碰 Shopee、不回写 Excel。**只重置封面时绝不改 `new_title`**。不改 cmhub HTTP 协议、Excel、Shopee/CDP 流程。单测覆盖:只重标题保留封面、只重封面保留标题(含手动标题不被清)、重置全部、多选/筛选批量、committed 提示、运行中禁用 | TODO |
|
| T-534 | ② 重置增强:多选/筛选范围 + 按组件(标题/封面/全部)重置 | T-533, T-404a, T-509 | 现状:②「重置生成结果」只能对单条、且整条全清(`db.reset_generated` 同时清 `new_title` 和 `new_cover_path` 退回 collected)。真实诉求是精准重置——一批标题不满意但封面 OK(只重标题、保封面、不重扣封面点)、或封面差但标题 OK(只重封面、**保留 `new_title` 保护 T-509 手动标题**、不重扣标题点);且需要作用于多条。方案:与 T-533 的组件模型咬合——① 后端 `db.reset_generated` 加组件开关 `reset_title`/`reset_cover`(`delete_file` 仅在重置封面时可选),只清对应字段与 `last_error`,stage 保持 `generated`,由 T-533 泛化后的 `is_generatable_task`(缺任一必需组件即可生成)识别缺口后由「开始生成」只补被清组件;② UI 重置入口给三选项「重置标题 / 重置封面 / 重置全部」;③ 作用域从单条扩到**多选(表格改 `ExtendedSelection`)或当前筛选结果**,与③「作用于当前筛选结果」心智一致;④ 二次确认框显示将重置的标题/封面条数、其中 `committed=1` 条数,并提示「线上已提交、本地重置不回滚 Shopee、重生成后再更新会再次提交线上」+ 重生成会消耗 cmhub 点数;⑤ 运行中禁用;批量写 `run_type=reset` 运行日志,不触碰 Shopee、不回写 Excel。**只重置封面时绝不改 `new_title`**。不改 cmhub HTTP 协议、Excel、Shopee/CDP 流程。单测覆盖:只重标题保留封面、只重封面保留标题(含手动标题不被清)、重置全部、多选/筛选批量、committed 提示、运行中禁用 | DONE |
|
||||||
|
|
||||||
## Phase 8 · 工程基础设施后续(`docs/engineering-review.md`)
|
## Phase 8 · 工程基础设施后续(`docs/engineering-review.md`)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+10
@@ -1235,3 +1235,13 @@
|
|||||||
- 边界:未改 cmhub HTTP 协议、配置 schema、DB schema、Excel、Shopee/CDP 流程;全组件齐备任务不纳入本轮生成。
|
- 边界:未改 cmhub HTTP 协议、配置 schema、DB schema、Excel、Shopee/CDP 流程;全组件齐备任务不纳入本轮生成。
|
||||||
- 测试:新增/调整 AI 与 GUI 单测覆盖只补封面、混合标题/封面缺口、封面关闭时标题态任务不纳入、全组件齐备不重复生成、②入口可启动补封面。
|
- 测试:新增/调整 AI 与 GUI 单测覆盖只补封面、混合标题/封面缺口、封面关闭时标题态任务不纳入、全组件齐备不重复生成、②入口可启动补封面。
|
||||||
- 验证:`python -m py_compile app\ai.py app\gui\workers.py app\gui\tabs\generate.py tests\test_ai.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p test_ai.py` 通过(23 tests);相关 GUI 用例通过;`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(207 tests);`git diff --check` 通过,仅有本机 LF/CRLF 提示。
|
- 验证:`python -m py_compile app\ai.py app\gui\workers.py app\gui\tabs\generate.py tests\test_ai.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p test_ai.py` 通过(23 tests);相关 GUI 用例通过;`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(207 tests);`git diff --check` 通过,仅有本机 LF/CRLF 提示。
|
||||||
|
|
||||||
|
## 【2026-07-06】T-534 完成 · ②按组件批量重置生成结果
|
||||||
|
|
||||||
|
- 状态:DONE
|
||||||
|
- 代码:`db.reset_generated()` 增加 `reset_title` / `reset_cover` 组件开关和仅重置封面时可选的 `delete_file`,重置后保持 `stage=generated/status=success`,让 T-533 能按缺口只补被清掉的标题或封面;已提交线上记录保留 `committed/apply_attempts` 历史。
|
||||||
|
- AI:`generation_needs()` 只阻断 `status=failed` 且有 `apply_attempts` 的更新失败记录;用户主动重置后的成功态记录即使保留提交历史,也允许按缺口补生成。
|
||||||
|
- GUI:②任务表改为多选;「重置生成结果」确认框提供「重置标题 / 重置封面 / 重置全部 / 取消」,有选中行时作用于选中任务,没有选中行时作用于当前筛选结果;确认框显示标题/封面数量、运行中跳过数、已提交线上数量和重复提交风险;批量写 `run_type=reset` 日志。
|
||||||
|
- 边界:不改 cmhub HTTP 协议、配置 schema、Excel、Shopee/CDP 流程;默认不删除本地新封面文件,不触碰 Shopee,不自动回写 Excel。
|
||||||
|
- 测试:新增/调整 DB、AI、GUI 单测覆盖只重标题保留封面、只重封面保留手动标题、重置全部、多选/筛选范围、committed 警告、运行中禁用、提交历史下补封面。
|
||||||
|
- 验证:`python -m py_compile app\db.py app\ai.py app\gui\tabs\generate.py tests\test_db.py tests\test_ai.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p test_db.py` 通过(7 tests);`python -m unittest discover -s tests -p test_ai.py` 通过(24 tests);相关 GUI 用例通过;`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(212 tests)。
|
||||||
|
|||||||
@@ -802,6 +802,51 @@ class AITests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_generate_batch_fills_reset_cover_after_committed_history(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
cfg = self._config()
|
||||||
|
cfg["db_path"] = os.path.join(temp_dir, "cmshopee.db")
|
||||||
|
cfg["image_dir"] = os.path.join(temp_dir, "images")
|
||||||
|
cfg["ai"]["generate_cover"] = True
|
||||||
|
batch_id, tasks = self._collected_tasks(temp_dir, cfg, ["旧标题"])
|
||||||
|
db.set_generated(tasks[0].id, "手动标题", "old-new.jpg", path=cfg["db_path"])
|
||||||
|
db.set_applied(tasks[0].id, True, path=cfg["db_path"])
|
||||||
|
db.reset_generated(
|
||||||
|
tasks[0].id,
|
||||||
|
reset_title=False,
|
||||||
|
reset_cover=True,
|
||||||
|
path=cfg["db_path"],
|
||||||
|
)
|
||||||
|
cover_only_task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||||||
|
|
||||||
|
def fake_cover(cover_prompt, old_cover_path, out_path, **kwargs):
|
||||||
|
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||||
|
with open(out_path, "wb") as fh:
|
||||||
|
fh.write(b"jpeg")
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
with mock.patch("app.ai.gen_title") as gen_title, \
|
||||||
|
mock.patch("app.ai.gen_cover", side_effect=fake_cover) as gen_cover:
|
||||||
|
summary = ai.generate_batch(
|
||||||
|
[cover_only_task],
|
||||||
|
{"title": "标题提示", "cover": "封面 {新标题}"},
|
||||||
|
ai_cfg={"config": cfg, "db_path": cfg["db_path"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(summary["ok"])
|
||||||
|
self.assertEqual(1, summary["total"])
|
||||||
|
self.assertEqual(0, summary["title_total"])
|
||||||
|
self.assertEqual(1, summary["cover_total"])
|
||||||
|
gen_title.assert_not_called()
|
||||||
|
gen_cover.assert_called_once()
|
||||||
|
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||||||
|
self.assertEqual("手动标题", updated.new_title)
|
||||||
|
self.assertTrue(os.path.exists(updated.new_cover_path))
|
||||||
|
self.assertEqual(1, updated.committed)
|
||||||
|
self.assertEqual(1, updated.apply_attempts)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
def test_generate_batch_handles_mixed_title_and_cover_gaps(self):
|
def test_generate_batch_handles_mixed_title_and_cover_gaps(self):
|
||||||
with self.make_temp_dir() as temp_dir:
|
with self.make_temp_dir() as temp_dir:
|
||||||
cfg = self._config()
|
cfg = self._config()
|
||||||
|
|||||||
+76
-1
@@ -168,7 +168,7 @@ class DbTests(TempDirMixin, unittest.TestCase):
|
|||||||
self.assertEqual(new_cover, reset_generated["new_cover_path"])
|
self.assertEqual(new_cover, reset_generated["new_cover_path"])
|
||||||
self.assertIsNone(reset_generated["deleted_file"])
|
self.assertIsNone(reset_generated["deleted_file"])
|
||||||
self.assertTrue(os.path.exists(new_cover))
|
self.assertTrue(os.path.exists(new_cover))
|
||||||
self.assertEqual("collected", after_generated.stage)
|
self.assertEqual("generated", after_generated.stage)
|
||||||
self.assertEqual("success", after_generated.status)
|
self.assertEqual("success", after_generated.status)
|
||||||
self.assertIsNone(after_generated.new_title)
|
self.assertIsNone(after_generated.new_title)
|
||||||
self.assertIsNone(after_generated.new_cover_path)
|
self.assertIsNone(after_generated.new_cover_path)
|
||||||
@@ -177,6 +177,81 @@ class DbTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_reset_generated_can_reset_title_or_cover_components(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_new.jpg")
|
||||||
|
second_cover = os.path.join(temp_dir, "second_new.jpg")
|
||||||
|
for cover in [first_cover, second_cover]:
|
||||||
|
with open(cover, "wb") as fh:
|
||||||
|
fh.write(b"jpeg")
|
||||||
|
db.set_collected(first.id, "旧标题A", "old-a.jpg", path=db_path)
|
||||||
|
db.set_generated(first.id, "手动标题A", first_cover, path=db_path)
|
||||||
|
db.mark_failed(first.id, "generate", "图片不满意", path=db_path)
|
||||||
|
db.set_collected(second.id, "旧标题B", "old-b.jpg", path=db_path)
|
||||||
|
db.set_generated(second.id, "手动标题B", second_cover, path=db_path)
|
||||||
|
|
||||||
|
title_reset = db.reset_generated(
|
||||||
|
first.id,
|
||||||
|
reset_title=True,
|
||||||
|
reset_cover=False,
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
after_title = title_reset["after"]
|
||||||
|
self.assertEqual("generated", after_title.stage)
|
||||||
|
self.assertEqual("success", after_title.status)
|
||||||
|
self.assertIsNone(after_title.new_title)
|
||||||
|
self.assertEqual(first_cover, after_title.new_cover_path)
|
||||||
|
self.assertIsNone(after_title.last_error)
|
||||||
|
self.assertTrue(os.path.exists(first_cover))
|
||||||
|
|
||||||
|
cover_reset = db.reset_generated(
|
||||||
|
second.id,
|
||||||
|
reset_title=False,
|
||||||
|
reset_cover=True,
|
||||||
|
delete_file=True,
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
after_cover = cover_reset["after"]
|
||||||
|
self.assertEqual("generated", after_cover.stage)
|
||||||
|
self.assertEqual("success", after_cover.status)
|
||||||
|
self.assertEqual("手动标题B", after_cover.new_title)
|
||||||
|
self.assertIsNone(after_cover.new_cover_path)
|
||||||
|
self.assertEqual(second_cover, cover_reset["deleted_file"])
|
||||||
|
self.assertFalse(os.path.exists(second_cover))
|
||||||
|
|
||||||
|
with self.assertRaises(db.DbError):
|
||||||
|
db.reset_generated(first.id, reset_title=False, reset_cover=False, path=db_path)
|
||||||
|
with self.assertRaises(db.DbError):
|
||||||
|
db.reset_generated(first.id, reset_title=True, reset_cover=False, delete_file=True, path=db_path)
|
||||||
|
|
||||||
|
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:
|
||||||
|
|||||||
+141
-4
@@ -17,6 +17,7 @@ from app import accounts, ai, appconfig, db, prompts
|
|||||||
if gui.QT_IMPORT_ERROR is not None:
|
if gui.QT_IMPORT_ERROR is not None:
|
||||||
raise unittest.SkipTest("PySide6 未安装")
|
raise unittest.SkipTest("PySide6 未安装")
|
||||||
|
|
||||||
|
from PySide6.QtCore import QItemSelectionModel
|
||||||
from PySide6.QtGui import QTextCursor
|
from PySide6.QtGui import QTextCursor
|
||||||
from PySide6.QtWidgets import QApplication, QCheckBox, QLineEdit, QPlainTextEdit, QProgressBar, QTableView
|
from PySide6.QtWidgets import QApplication, QCheckBox, QLineEdit, QPlainTextEdit, QProgressBar, QTableView
|
||||||
|
|
||||||
@@ -1540,14 +1541,15 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
self.addCleanup(tab.close)
|
self.addCleanup(tab.close)
|
||||||
tab.task_table.selectRow(0)
|
tab.task_table.selectRow(0)
|
||||||
|
|
||||||
with mock.patch("app.gui.QMessageBox.question", return_value=gui.QMessageBox.Yes) as question:
|
message_box, boxes = self.make_fake_message_box("重置全部")
|
||||||
|
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box):
|
||||||
tab.reset_generated_result()
|
tab.reset_generated_result()
|
||||||
|
|
||||||
message = question.call_args[0][2]
|
message = boxes[0].text
|
||||||
self.assertIn("默认不删除本地新封面文件", message)
|
self.assertIn("默认不删除本地新封面文件", message)
|
||||||
self.assertIn(new_cover, message)
|
self.assertIn("重置全部", message)
|
||||||
updated = db.get_task(task.id, path=cfg["db_path"])
|
updated = db.get_task(task.id, path=cfg["db_path"])
|
||||||
self.assertEqual("collected", updated.stage)
|
self.assertEqual("generated", updated.stage)
|
||||||
self.assertEqual("success", updated.status)
|
self.assertEqual("success", updated.status)
|
||||||
self.assertIsNone(updated.new_title)
|
self.assertIsNone(updated.new_title)
|
||||||
self.assertIsNone(updated.new_cover_path)
|
self.assertIsNone(updated.new_cover_path)
|
||||||
@@ -1555,12 +1557,147 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
run_log = db.list_run_logs(limit=1, run_type="reset", path=cfg["db_path"])[0]
|
run_log = db.list_run_logs(limit=1, run_type="reset", path=cfg["db_path"])[0]
|
||||||
self.assertEqual("done", run_log.status)
|
self.assertEqual("done", run_log.status)
|
||||||
self.assertEqual("reset_generated", run_log.options["action"])
|
self.assertEqual("reset_generated", run_log.options["action"])
|
||||||
|
self.assertEqual("all", run_log.options["mode"])
|
||||||
events = db.list_run_log_events(run_log.id, path=cfg["db_path"])
|
events = db.list_run_log_events(run_log.id, path=cfg["db_path"])
|
||||||
self.assertTrue(any("action=reset_generated" in event.message for event in events))
|
self.assertTrue(any("action=reset_generated" in event.message for event in events))
|
||||||
self.assertIn("已重置生成结果", statuses[-1])
|
self.assertIn("已重置生成结果", statuses[-1])
|
||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_generate_tab_resets_titles_for_current_filtered_tasks(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
cfg = self.make_config(temp_dir)
|
||||||
|
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||||||
|
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||||||
|
db.insert_tasks(
|
||||||
|
batch_id,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||||
|
"source_sheet": "商品",
|
||||||
|
"source_row": 2,
|
||||||
|
"account_name": "Excel主店",
|
||||||
|
"alias": "alias-a",
|
||||||
|
"item_id": "51100639510",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||||
|
"source_sheet": "商品",
|
||||||
|
"source_row": 3,
|
||||||
|
"account_name": "Excel主店",
|
||||||
|
"alias": "alias-a",
|
||||||
|
"item_id": "51100639511",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
path=cfg["db_path"],
|
||||||
|
)
|
||||||
|
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||||
|
for index, task in enumerate(tasks):
|
||||||
|
db.set_collected(task.id, f"旧标题{index}", f"old-{index}.jpg", path=cfg["db_path"])
|
||||||
|
db.set_generated(task.id, f"新标题{index}", f"new-{index}.jpg", path=cfg["db_path"])
|
||||||
|
statuses = []
|
||||||
|
tab = GenerateTab(config=cfg, status_callback=statuses.append)
|
||||||
|
self.addCleanup(tab.close)
|
||||||
|
tab.task_table.clearSelection()
|
||||||
|
|
||||||
|
message_box, boxes = self.make_fake_message_box("重置标题")
|
||||||
|
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box):
|
||||||
|
tab.reset_generated_result()
|
||||||
|
|
||||||
|
self.assertIn("当前筛选结果 2 条", boxes[0].text)
|
||||||
|
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||||
|
self.assertEqual([None, None], [task.new_title for task in updated])
|
||||||
|
self.assertEqual(["new-0.jpg", "new-1.jpg"], [task.new_cover_path for task in updated])
|
||||||
|
self.assertTrue(all(task.stage == "generated" for task in updated))
|
||||||
|
run_log = db.list_run_logs(limit=1, run_type="reset", path=cfg["db_path"])[0]
|
||||||
|
self.assertEqual("filtered", run_log.options["scope"])
|
||||||
|
self.assertEqual("title", run_log.options["mode"])
|
||||||
|
self.assertTrue(run_log.options["reset_title"])
|
||||||
|
self.assertFalse(run_log.options["reset_cover"])
|
||||||
|
self.assertEqual(2, run_log.total)
|
||||||
|
self.assertIn("已重置生成结果:2 条,内容:标题", statuses[-1])
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_generate_tab_resets_selected_covers_and_keeps_manual_titles(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
cfg = self.make_config(temp_dir)
|
||||||
|
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||||||
|
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||||||
|
db.insert_tasks(
|
||||||
|
batch_id,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||||
|
"source_sheet": "商品",
|
||||||
|
"source_row": 2,
|
||||||
|
"account_name": "Excel主店",
|
||||||
|
"alias": "alias-a",
|
||||||
|
"item_id": "51100639510",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||||
|
"source_sheet": "商品",
|
||||||
|
"source_row": 3,
|
||||||
|
"account_name": "Excel主店",
|
||||||
|
"alias": "alias-a",
|
||||||
|
"item_id": "51100639511",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
path=cfg["db_path"],
|
||||||
|
)
|
||||||
|
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||||
|
for index, task in enumerate(tasks):
|
||||||
|
db.set_collected(task.id, f"旧标题{index}", f"old-{index}.jpg", path=cfg["db_path"])
|
||||||
|
db.set_generated(task.id, f"手动标题{index}", f"new-{index}.jpg", path=cfg["db_path"])
|
||||||
|
db.set_applied(tasks[0].id, True, path=cfg["db_path"])
|
||||||
|
tab = GenerateTab(config=cfg)
|
||||||
|
self.addCleanup(tab.close)
|
||||||
|
selection = tab.task_table.selectionModel()
|
||||||
|
for row in [0, 1]:
|
||||||
|
selection.select(
|
||||||
|
tab.model.index(row, 0),
|
||||||
|
QItemSelectionModel.Select | QItemSelectionModel.Rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
message_box, boxes = self.make_fake_message_box("重置封面")
|
||||||
|
with mock.patch("app.gui.tabs.generate.QMessageBox", message_box):
|
||||||
|
tab.reset_generated_result()
|
||||||
|
|
||||||
|
message = boxes[0].text
|
||||||
|
self.assertIn("选中任务 2 条", message)
|
||||||
|
self.assertIn("已经提交过线上", message)
|
||||||
|
self.assertIn("重生成后再更新会再次提交线上", message)
|
||||||
|
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||||
|
self.assertEqual(["手动标题0", "手动标题1"], [task.new_title for task in updated])
|
||||||
|
self.assertEqual([None, None], [task.new_cover_path for task in updated])
|
||||||
|
self.assertEqual("generated", updated[0].stage)
|
||||||
|
self.assertEqual(1, updated[0].committed)
|
||||||
|
run_log = db.list_run_logs(limit=1, run_type="reset", path=cfg["db_path"])[0]
|
||||||
|
self.assertEqual("selected", run_log.options["scope"])
|
||||||
|
self.assertEqual("cover", run_log.options["mode"])
|
||||||
|
self.assertFalse(run_log.options["reset_title"])
|
||||||
|
self.assertTrue(run_log.options["reset_cover"])
|
||||||
|
self.assertEqual(1, run_log.options["committed_count"])
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_generate_tab_disables_reset_while_generation_running(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_generate_running(True)
|
||||||
|
self.assertFalse(tab.reset_generate_button.isEnabled())
|
||||||
|
tab.generate_thread = object()
|
||||||
|
tab.reset_generated_result()
|
||||||
|
|
||||||
|
self.assertIn("AI 生成正在进行,不能重置", statuses[-1])
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
def test_generate_worker_calls_generate_batch_and_emits_signals(self):
|
def test_generate_worker_calls_generate_batch_and_emits_signals(self):
|
||||||
with self.make_temp_dir() as temp_dir:
|
with self.make_temp_dir() as temp_dir:
|
||||||
cfg = self.make_config(temp_dir)
|
cfg = self.make_config(temp_dir)
|
||||||
|
|||||||
Reference in New Issue
Block a user