feat: complete T-534 generated reset enhancements

This commit is contained in:
chengma
2026-07-06 20:35:47 +08:00
parent 46995b301d
commit 71f8beae57
9 changed files with 488 additions and 63 deletions
+5 -1
View File
@@ -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)
+24 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)。
+45
View File
@@ -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
View File
@@ -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
View File
@@ -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)