feat(gui): add task deletion menus
This commit is contained in:
@@ -143,6 +143,7 @@ class MainWindow(QMainWindow):
|
|||||||
title_prompt_path=appconfig.title_prompt_path(self.config),
|
title_prompt_path=appconfig.title_prompt_path(self.config),
|
||||||
cover_prompts_dir=appconfig.cover_prompts_dir(self.config),
|
cover_prompts_dir=appconfig.cover_prompts_dir(self.config),
|
||||||
open_accounts_callback=lambda: self.open_accounts_tab(),
|
open_accounts_callback=lambda: self.open_accounts_tab(),
|
||||||
|
refresh_workflow_callback=lambda: self.refresh_task_tabs(),
|
||||||
)
|
)
|
||||||
if title == "③ 更新蝦皮":
|
if title == "③ 更新蝦皮":
|
||||||
return ApplyTab(
|
return ApplyTab(
|
||||||
@@ -151,6 +152,7 @@ class MainWindow(QMainWindow):
|
|||||||
status_callback=self.show_status,
|
status_callback=self.show_status,
|
||||||
open_accounts_callback=lambda: self.open_accounts_tab(),
|
open_accounts_callback=lambda: self.open_accounts_tab(),
|
||||||
open_settings_callback=lambda: self.open_settings_tab(),
|
open_settings_callback=lambda: self.open_settings_tab(),
|
||||||
|
refresh_workflow_callback=lambda: self.refresh_task_tabs(),
|
||||||
)
|
)
|
||||||
if title == "账号管理":
|
if title == "账号管理":
|
||||||
return AccountsTab(
|
return AccountsTab(
|
||||||
|
|||||||
+78
-3
@@ -39,6 +39,7 @@ class ApplyTab(QWidget):
|
|||||||
status_callback=None,
|
status_callback=None,
|
||||||
open_accounts_callback=None,
|
open_accounts_callback=None,
|
||||||
open_settings_callback=None,
|
open_settings_callback=None,
|
||||||
|
refresh_workflow_callback=None,
|
||||||
):
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.config = appconfig.load_config() if config is None else config
|
self.config = appconfig.load_config() if config is None else config
|
||||||
@@ -47,6 +48,7 @@ class ApplyTab(QWidget):
|
|||||||
self.status_callback = status_callback
|
self.status_callback = status_callback
|
||||||
self.open_accounts_callback = open_accounts_callback
|
self.open_accounts_callback = open_accounts_callback
|
||||||
self.open_settings_callback = open_settings_callback
|
self.open_settings_callback = open_settings_callback
|
||||||
|
self.refresh_workflow_callback = refresh_workflow_callback
|
||||||
self.apply_worker = None
|
self.apply_worker = None
|
||||||
self.apply_thread = None
|
self.apply_thread = None
|
||||||
self.result_write_back_worker = None
|
self.result_write_back_worker = None
|
||||||
@@ -357,18 +359,91 @@ class ApplyTab(QWidget):
|
|||||||
|
|
||||||
def show_task_context_menu(self, position):
|
def show_task_context_menu(self, position):
|
||||||
index = self.task_table.indexAt(position)
|
index = self.task_table.indexAt(position)
|
||||||
if index.isValid():
|
if not index.isValid():
|
||||||
|
return
|
||||||
|
self.task_table.selectRow(index.row())
|
||||||
self.task_table.setCurrentIndex(index)
|
self.task_table.setCurrentIndex(index)
|
||||||
menu = QMenu(self)
|
task = self.model.task_at(index.row())
|
||||||
|
if task is None:
|
||||||
|
return
|
||||||
|
menu = QMenu(self.task_table)
|
||||||
reset_action = menu.addAction("重置更新状态")
|
reset_action = menu.addAction("重置更新状态")
|
||||||
reset_action.setEnabled(
|
reset_action.setEnabled(
|
||||||
self.apply_thread is None
|
self.apply_thread is None
|
||||||
and self.result_write_back_thread is None
|
and self.result_write_back_thread is None
|
||||||
and self._selected_task() is not None
|
and getattr(task, "status", "") != "running"
|
||||||
)
|
)
|
||||||
reset_action.triggered.connect(self.reset_apply_status)
|
reset_action.triggered.connect(self.reset_apply_status)
|
||||||
|
menu.addSeparator()
|
||||||
|
delete_action = menu.addAction("删除本条记录")
|
||||||
|
delete_action.setEnabled(
|
||||||
|
self.apply_thread is None
|
||||||
|
and self.result_write_back_thread is None
|
||||||
|
and getattr(task, "status", "") != "running"
|
||||||
|
)
|
||||||
|
delete_action.triggered.connect(self.delete_selected_task)
|
||||||
menu.exec(self.task_table.viewport().mapToGlobal(position))
|
menu.exec(self.task_table.viewport().mapToGlobal(position))
|
||||||
|
|
||||||
|
def delete_selected_task(self, checked=False):
|
||||||
|
if self.apply_thread is not None or self.result_write_back_thread is not None:
|
||||||
|
self._set_status("更新或回写正在进行,不能删除记录", level="warning")
|
||||||
|
return
|
||||||
|
task = self._selected_task()
|
||||||
|
if task is None:
|
||||||
|
self._set_status("请选择要删除的记录", level="warning")
|
||||||
|
return
|
||||||
|
if getattr(task, "status", "") == "running":
|
||||||
|
self._set_status("任务正在处理,不能删除", level="warning")
|
||||||
|
return
|
||||||
|
batch = db.get_batch(task.batch_id, path=self.db_path)
|
||||||
|
batch_text = self._batch_label(batch) if batch is not None else task.batch_id
|
||||||
|
lines = [
|
||||||
|
"确定要删除本条本地记录吗?",
|
||||||
|
f"商品ID:{task.item_id}",
|
||||||
|
f"店铺:{self.model.account_name_for(task)}({task.alias})",
|
||||||
|
f"所属批次:{batch_text}",
|
||||||
|
]
|
||||||
|
if int(getattr(task, "committed", 0) or 0) == 1:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"该商品已提交过蝦皮。本次仅删除本地记录,不会回滚蝦皮线上商品。",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"删除后,该记录不会出现在①、②、③的列表、筛选、采集、生成、更新或回写范围。",
|
||||||
|
"不会删除原 Excel、本地图片或账号 Chrome 数据。",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
answer = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"删除本条记录",
|
||||||
|
"\n".join(lines),
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
QMessageBox.No,
|
||||||
|
)
|
||||||
|
if answer != QMessageBox.Yes:
|
||||||
|
self._set_status("已取消删除本条记录")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
result = db.delete_task(
|
||||||
|
task.id,
|
||||||
|
reason="用户在更新蝦皮页删除单条记录",
|
||||||
|
path=self.db_path,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
QMessageBox.warning(self, "删除本条记录", str(exc))
|
||||||
|
self._set_status(f"删除本条记录失败:{exc}", level="danger")
|
||||||
|
return
|
||||||
|
self.refresh_tasks()
|
||||||
|
if self.refresh_workflow_callback is not None:
|
||||||
|
self.refresh_workflow_callback()
|
||||||
|
message = f"已删除本地记录:商品 {result['item_id']}"
|
||||||
|
self._set_status(message, level="success")
|
||||||
|
QMessageBox.information(self, "删除本条记录", message)
|
||||||
|
|
||||||
def reset_apply_status(self, checked=False):
|
def reset_apply_status(self, checked=False):
|
||||||
if self.apply_thread is not None or self.result_write_back_thread is not None:
|
if self.apply_thread is not None or self.result_write_back_thread is not None:
|
||||||
self._set_status("更新或回写正在进行,不能重置")
|
self._set_status("更新或回写正在进行,不能重置")
|
||||||
|
|||||||
@@ -982,6 +982,7 @@ class GenerateTab(QWidget):
|
|||||||
cover_prompts_dir=None,
|
cover_prompts_dir=None,
|
||||||
title_templates_dir=None,
|
title_templates_dir=None,
|
||||||
open_accounts_callback=None,
|
open_accounts_callback=None,
|
||||||
|
refresh_workflow_callback=None,
|
||||||
):
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config
|
self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config
|
||||||
@@ -993,6 +994,7 @@ class GenerateTab(QWidget):
|
|||||||
self.db_path = _database_path(db_path, self.config)
|
self.db_path = _database_path(db_path, self.config)
|
||||||
self.status_callback = status_callback
|
self.status_callback = status_callback
|
||||||
self.open_accounts_callback = open_accounts_callback
|
self.open_accounts_callback = open_accounts_callback
|
||||||
|
self.refresh_workflow_callback = refresh_workflow_callback
|
||||||
self.title_prompt_path = title_prompt_path or appconfig.title_prompt_path(self.config)
|
self.title_prompt_path = title_prompt_path or appconfig.title_prompt_path(self.config)
|
||||||
self.cover_prompts_dir = cover_prompts_dir or appconfig.cover_prompts_dir(self.config)
|
self.cover_prompts_dir = cover_prompts_dir or appconfig.cover_prompts_dir(self.config)
|
||||||
self.title_templates_dir = title_templates_dir or appconfig.title_templates_dir(self.config)
|
self.title_templates_dir = title_templates_dir or appconfig.title_templates_dir(self.config)
|
||||||
@@ -1185,6 +1187,7 @@ class GenerateTab(QWidget):
|
|||||||
self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||||
self.task_table.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
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.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||||
self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive)
|
self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive)
|
||||||
self.task_table.horizontalHeader().setStretchLastSection(False)
|
self.task_table.horizontalHeader().setStretchLastSection(False)
|
||||||
self.task_table.verticalHeader().setVisible(False)
|
self.task_table.verticalHeader().setVisible(False)
|
||||||
@@ -1271,6 +1274,7 @@ class GenerateTab(QWidget):
|
|||||||
self.stop_generate_button.clicked.connect(self.stop_generate)
|
self.stop_generate_button.clicked.connect(self.stop_generate)
|
||||||
self.reset_generate_button.clicked.connect(self.reset_generated_result)
|
self.reset_generate_button.clicked.connect(self.reset_generated_result)
|
||||||
self.task_table.leftDoubleClicked.connect(self.show_task_images)
|
self.task_table.leftDoubleClicked.connect(self.show_task_images)
|
||||||
|
self.task_table.customContextMenuRequested.connect(self.show_task_context_menu)
|
||||||
|
|
||||||
self.refresh_title_templates()
|
self.refresh_title_templates()
|
||||||
self.refresh_cover_templates()
|
self.refresh_cover_templates()
|
||||||
@@ -1937,6 +1941,83 @@ class GenerateTab(QWidget):
|
|||||||
f"已重置生成结果:{len(target_tasks)} 条,内容:{label},run_id={run_id}"
|
f"已重置生成结果:{len(target_tasks)} 条,内容:{label},run_id={run_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def show_task_context_menu(self, position):
|
||||||
|
index = self.task_table.indexAt(position)
|
||||||
|
if not index.isValid():
|
||||||
|
return
|
||||||
|
self.task_table.selectRow(index.row())
|
||||||
|
self.task_table.setCurrentIndex(index)
|
||||||
|
task = self.model.task_at(index.row())
|
||||||
|
if task is None:
|
||||||
|
return
|
||||||
|
menu = QMenu(self.task_table)
|
||||||
|
delete_action = menu.addAction("删除本条记录")
|
||||||
|
delete_action.setEnabled(
|
||||||
|
self.generate_thread is None and getattr(task, "status", "") != "running"
|
||||||
|
)
|
||||||
|
delete_action.triggered.connect(self.delete_selected_task)
|
||||||
|
menu.exec(self.task_table.viewport().mapToGlobal(position))
|
||||||
|
|
||||||
|
def delete_selected_task(self, checked=False):
|
||||||
|
if self.generate_thread is not None:
|
||||||
|
self._set_status("AI 生成正在进行,不能删除记录", level="warning")
|
||||||
|
return
|
||||||
|
task = self._selected_task()
|
||||||
|
if task is None:
|
||||||
|
self._set_status("请选择要删除的记录", level="warning")
|
||||||
|
return
|
||||||
|
if getattr(task, "status", "") == "running":
|
||||||
|
self._set_status("任务正在处理,不能删除", level="warning")
|
||||||
|
return
|
||||||
|
batch = db.get_batch(task.batch_id, path=self.db_path)
|
||||||
|
batch_text = self._batch_label(batch) if batch is not None else task.batch_id
|
||||||
|
lines = [
|
||||||
|
"确定要删除本条本地记录吗?",
|
||||||
|
f"商品ID:{task.item_id}",
|
||||||
|
f"店铺:{self.model.account_name_for(task)}({task.alias})",
|
||||||
|
f"所属批次:{batch_text}",
|
||||||
|
]
|
||||||
|
if int(getattr(task, "committed", 0) or 0) == 1:
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"该商品已提交过蝦皮。本次仅删除本地记录,不会回滚蝦皮线上商品。",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"删除后,该记录不会出现在①、②、③的列表、筛选、采集、生成、更新或回写范围。",
|
||||||
|
"不会删除原 Excel、本地图片或账号 Chrome 数据。",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
answer = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"删除本条记录",
|
||||||
|
"\n".join(lines),
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
QMessageBox.No,
|
||||||
|
)
|
||||||
|
if answer != QMessageBox.Yes:
|
||||||
|
self._set_status("已取消删除本条记录")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
result = db.delete_task(
|
||||||
|
task.id,
|
||||||
|
reason="用户在AI生成页删除单条记录",
|
||||||
|
path=self.db_path,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
QMessageBox.warning(self, "删除本条记录", str(exc))
|
||||||
|
self._set_status(f"删除本条记录失败:{exc}", level="danger")
|
||||||
|
return
|
||||||
|
self.refresh_tasks()
|
||||||
|
if self.refresh_workflow_callback is not None:
|
||||||
|
self.refresh_workflow_callback()
|
||||||
|
message = f"已删除本地记录:商品 {result['item_id']}"
|
||||||
|
self._set_status(message, level="success")
|
||||||
|
QMessageBox.information(self, "删除本条记录", message)
|
||||||
|
|
||||||
def _reset_scope_tasks(self):
|
def _reset_scope_tasks(self):
|
||||||
selection = self.task_table.selectionModel()
|
selection = self.task_table.selectionModel()
|
||||||
selected_rows = []
|
selected_rows = []
|
||||||
|
|||||||
@@ -544,6 +544,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
|
|||||||
- 双击「新标题」列进入本地编辑;双击其他列弹窗展示旧封面与新封面路径对应图片;图片不存在时显示空态/路径提示,只做查看,不做审核。
|
- 双击「新标题」列进入本地编辑;双击其他列弹窗展示旧封面与新封面路径对应图片;图片不存在时显示空态/路径提示,只做查看,不做审核。
|
||||||
- 底部「开始生成」只处理当前筛选结果里可补齐的任务;批次/店铺/商品ID/状态筛选共同决定当前筛选结果;②「生成内容」下拉支持只生成标题、只生成封面、生成标题和封面。只生成封面时不调用生文,任务有新标题则使用新标题,否则使用已采集旧标题作为封面prompt回退;新旧标题都为空才不纳入。候选存在时由 `product_status.build_generation_plan()` 统计正常/未上架/审核中/未知并冻结指纹,弹出与①共用的 `ProductStatusScopeDialog`:默认按钮为“生成架上商品”(`normal_only`,仅将检测结果为正常的商品交给 AI),扩展按钮为“生成全部商品”(`all`,还会处理未上架、审核中和状态未知商品,可能额外消耗点数),后者使用警示橙色的扩大范围样式;历史未采集状态按未知处理,默认不入队。取消或指纹变化时不创建 Worker;无异常候选时 `all` 禁用。`GenerateWorker` 接收最终精确任务列表,并在 `normal_only` 下再次排除非正常任务后才调用 `ai.generate_batch()`。点击「开始生成」时先清空 `GenerateTab` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;运行日志 options/summary 记录范围、状态计数、范围排除数和计划指纹。cmhub 模式开始摘要显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”,其中 `Y=min(X,5)`。
|
- 底部「开始生成」只处理当前筛选结果里可补齐的任务;批次/店铺/商品ID/状态筛选共同决定当前筛选结果;②「生成内容」下拉支持只生成标题、只生成封面、生成标题和封面。只生成封面时不调用生文,任务有新标题则使用新标题,否则使用已采集旧标题作为封面prompt回退;新旧标题都为空才不纳入。候选存在时由 `product_status.build_generation_plan()` 统计正常/未上架/审核中/未知并冻结指纹,弹出与①共用的 `ProductStatusScopeDialog`:默认按钮为“生成架上商品”(`normal_only`,仅将检测结果为正常的商品交给 AI),扩展按钮为“生成全部商品”(`all`,还会处理未上架、审核中和状态未知商品,可能额外消耗点数),后者使用警示橙色的扩大范围样式;历史未采集状态按未知处理,默认不入队。取消或指纹变化时不创建 Worker;无异常候选时 `all` 禁用。`GenerateWorker` 接收最终精确任务列表,并在 `normal_only` 下再次排除非正常任务后才调用 `ai.generate_batch()`。点击「开始生成」时先清空 `GenerateTab` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;运行日志 options/summary 记录范围、状态计数、范围排除数和计划指纹。cmhub 模式开始摘要显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”,其中 `Y=min(X,5)`。
|
||||||
- 「停止」调用 worker 的协作式 `cancel()`;未开始的 Future 取消,不记失败;已完成的 `db.set_generated()` / `db.set_generated_cover()` 组件结果保留,可再次开始生成剩余任务。
|
- 「停止」调用 worker 的协作式 `cancel()`;未开始的 Future 取消,不记失败;已完成的 `db.set_generated()` / `db.set_generated_cover()` 组件结果保留,可再次开始生成剩余任务。
|
||||||
|
- ②任务表右键先选中鼠标命中行,仅提供「删除本条记录」;确认后调用 `db.delete_task()`,不物理删除 Excel、图片、账号 Chrome 或蝦皮线上商品。生成中或任务 `running` 时禁用;成功后通过主窗口刷新①②③。
|
||||||
- 进度标签显示标题/图片/失败;只生成标题时图片进度显示本轮未生成,标题成功即 `db.set_generated(task_id, new_title, existing_cover_path)` 并进入 generated;封面成功立即调用 `db.set_generated_cover(task_id, new_cover_path)`,只更新封面、不覆盖或伪造新标题。只生成封面得到“有封面、无新标题”时,②标题状态继续待生成、图片状态已生成。失败 `db.mark_failed(..., "generate", error)`,GUI 刷新任务表。
|
- 进度标签显示标题/图片/失败;只生成标题时图片进度显示本轮未生成,标题成功即 `db.set_generated(task_id, new_title, existing_cover_path)` 并进入 generated;封面成功立即调用 `db.set_generated_cover(task_id, new_cover_path)`,只更新封面、不覆盖或伪造新标题。只生成封面得到“有封面、无新标题”时,②标题状态继续待生成、图片状态已生成。失败 `db.mark_failed(..., "generate", error)`,GUI 刷新任务表。
|
||||||
- T-404a 已实现:「重置生成结果」读取当前选中单条;确认后调用 DB reset,清空本地 AI 结果并退回可生成状态;默认不删除本地新封面文件;写 `run_type=reset` 事件。
|
- T-404a 已实现:「重置生成结果」读取当前选中单条;确认后调用 DB reset,清空本地 AI 结果并退回可生成状态;默认不删除本地新封面文件;写 `run_type=reset` 事件。
|
||||||
|
|
||||||
@@ -558,6 +559,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
|
|||||||
- 点击「开始更新」和「检查本轮更新」都先使用同一 `build_apply_plan()` 预检当前筛选任务:仅商品状态 `normal` 继续按③「更新内容」模式检查,只更新标题要求 `new_title`,只更新封面要求 `new_cover_path`,更新标题和封面要求两者都有。未上架、审核中、状态未知和历史 NULL 均不能传入正式或检查模式的 `ApplyWorker`;状态异常与内容缺失在确认框、状态栏摘要和本轮日志分类列出数量与示例商品ID,不打开其 Chrome、不写其失败状态或 Excel,也不计入 worker 成功/略过/失败统计。所有记录被排除时,若存在状态异常则弹「商品状态不允许更新」,否则弹「更新内容未生成」。确认后按 `task_id/updated_at/product_status/new_title/new_cover_path/更新模式` 重新验证计划指纹,变化即中止。再读取 `shopee_update` 执行参数:普通正式更新不再检查 `test_item_id` 或旧真实提交开关,当前筛选结果可包含多个真实商品 ID;`max_items_per_run` 作为每批最大更新条数,当前可执行任务超过该值时自动分批。通过后才弹窗展示批次/店铺/商品ID/状态/更新内容/可执行任务数、预检跳过项、每批最大条数、预计批次数、提交线上风险和当前执行设置。
|
- 点击「开始更新」和「检查本轮更新」都先使用同一 `build_apply_plan()` 预检当前筛选任务:仅商品状态 `normal` 继续按③「更新内容」模式检查,只更新标题要求 `new_title`,只更新封面要求 `new_cover_path`,更新标题和封面要求两者都有。未上架、审核中、状态未知和历史 NULL 均不能传入正式或检查模式的 `ApplyWorker`;状态异常与内容缺失在确认框、状态栏摘要和本轮日志分类列出数量与示例商品ID,不打开其 Chrome、不写其失败状态或 Excel,也不计入 worker 成功/略过/失败统计。所有记录被排除时,若存在状态异常则弹「商品状态不允许更新」,否则弹「更新内容未生成」。确认后按 `task_id/updated_at/product_status/new_title/new_cover_path/更新模式` 重新验证计划指纹,变化即中止。再读取 `shopee_update` 执行参数:普通正式更新不再检查 `test_item_id` 或旧真实提交开关,当前筛选结果可包含多个真实商品 ID;`max_items_per_run` 作为每批最大更新条数,当前可执行任务超过该值时自动分批。通过后才弹窗展示批次/店铺/商品ID/状态/更新内容/可执行任务数、预检跳过项、每批最大条数、预计批次数、提交线上风险和当前执行设置。
|
||||||
- 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker` 做真实提交。
|
- 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker` 做真实提交。
|
||||||
- `ApplyWorker` 只处理经过 `ApplyTab` 预检后的任务,并在执行层再次拒绝商品状态不是 `normal` 的记录:原候选范围为 `stage=generated`、状态为 `success/pending/failed`,缺少当前 `update_mode` 所需内容的记录不传入 worker;只更新标题时不替换封面,只更新封面时不改标题。预检跳过记录保持原 stage/status/Excel,不被计入本轮 worker 的成功、略过或失败统计。已更新和略过记录仅查看,不会再次提交,除非用户先用 T-404a 的「重置更新状态」把选中记录退回可更新。
|
- `ApplyWorker` 只处理经过 `ApplyTab` 预检后的任务,并在执行层再次拒绝商品状态不是 `normal` 的记录:原候选范围为 `stage=generated`、状态为 `success/pending/failed`,缺少当前 `update_mode` 所需内容的记录不传入 worker;只更新标题时不替换封面,只更新封面时不改标题。预检跳过记录保持原 stage/status/Excel,不被计入本轮 worker 的成功、略过或失败统计。已更新和略过记录仅查看,不会再次提交,除非用户先用 T-404a 的「重置更新状态」把选中记录退回可更新。
|
||||||
|
- ③任务表右键先选中鼠标命中行,保留「重置更新状态」,并以分隔线提供「删除本条记录」。删除调用 `db.delete_task()`,运行中禁用;已提交线上时确认框明确本地删除不回滚蝦皮。成功后刷新①②③,不修改 Excel、图片、账号 Chrome 或线上商品。
|
||||||
- 检查本轮更新:不做账号登录预检,不调用 `editor.apply_task()`,不写任务状态,不回写 Excel;只把每条“将更新/将略过”写入运行日志并弹汇总。
|
- 检查本轮更新:不做账号登录预检,不调用 `editor.apply_task()`,不写任务状态,不回写 Excel;只把每条“将更新/将略过”写入运行日志并弹汇总。
|
||||||
- 真实更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 Chrome 未启动、CDP 端口不可访问、未登录,或本轮涉及账号调试端口冲突时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去账号管理;预检不通过时不调用 `editor.apply_task()`、不写失败状态、不自动启动 Chrome。
|
- 真实更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 Chrome 未启动、CDP 端口不可访问、未登录,或本轮涉及账号调试端口冲突时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去账号管理;预检不通过时不调用 `editor.apply_task()`、不写失败状态、不自动启动 Chrome。
|
||||||
- 预检通过后默认串行;若 `max_parallel_accounts>1`,按账号分组并行执行,不同账号可同时跑,同一账号内仍串行。每条执行 `db.mark_running(..., "apply")` → `editor.apply_task(account, task)` → `db.set_applied()`;成功推进 `stage=applied/status=success/committed=1`,失败保持原 stage、`status=failed/committed=0/last_error`,单条失败继续下一条。
|
- 预检通过后默认串行;若 `max_parallel_accounts>1`,按账号分组并行执行,不同账号可同时跑,同一账号内仍串行。每条执行 `db.mark_running(..., "apply")` → `editor.apply_task(account, task)` → `db.set_applied()`;成功推进 `stage=applied/status=success/committed=1`,失败保持原 stage、`status=failed/committed=0/last_error`,单条失败继续下一条。
|
||||||
|
|||||||
@@ -113,6 +113,7 @@
|
|||||||
- 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、cmhub 网关/Key/别名)在 **设置**;②只暴露本轮生成标题/封面/图文的内容模式。设置不新增“下载并发”控件;cmhub 图片下载并发由程序按实际生图并发自动计算,最大 5。
|
- 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、cmhub 网关/Key/别名)在 **设置**;②只暴露本轮生成标题/封面/图文的内容模式。设置不新增“下载并发”控件;cmhub 图片下载并发由程序按实际生图并发自动计算,最大 5。
|
||||||
- 只生成标题时标题成功即写库并进入 `generated`,保留已有封面;只生成封面时通过组件级写库只保存 `new_cover_path`,不覆盖已有标题,也不把旧标题写入空的 `new_title`;生成标题和封面时按缺失组件增量补齐。“有新封面、无新标题”时标题状态为待生成、图片状态为已生成,后续补标题不重复生图。三种模式都写 `run_type=generate` 的 `run_logs/run_log_events` 和用户可读滚动日志;日志开头明确显示本轮生成内容。点击「开始生成」时先清空②界面可见日志并写入本轮开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`。进入页面默认可显示“本轮日志会在开始运行后显示”,历史日志不自动混入当前运行界面。「停止」取消未开始项,可再次「开始生成」对剩余继续。即使用户通过商品状态筛选查看异常商品,也必须在范围确认框主动选择「生成全部商品」才会入队;默认「生成架上商品」不会因筛选而放宽。
|
- 只生成标题时标题成功即写库并进入 `generated`,保留已有封面;只生成封面时通过组件级写库只保存 `new_cover_path`,不覆盖已有标题,也不把旧标题写入空的 `new_title`;生成标题和封面时按缺失组件增量补齐。“有新封面、无新标题”时标题状态为待生成、图片状态为已生成,后续补标题不重复生图。三种模式都写 `run_type=generate` 的 `run_logs/run_log_events` 和用户可读滚动日志;日志开头明确显示本轮生成内容。点击「开始生成」时先清空②界面可见日志并写入本轮开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`。进入页面默认可显示“本轮日志会在开始运行后显示”,历史日志不自动混入当前运行界面。「停止」取消未开始项,可再次「开始生成」对剩余继续。即使用户通过商品状态筛选查看异常商品,也必须在范围确认框主动选择「生成全部商品」才会入队;默认「生成架上商品」不会因筛选而放宽。
|
||||||
- 「重置生成结果」支持选中任务或当前筛选结果,运行中禁用;确认框提供「重置标题 / 重置封面 / 重置全部」,只改本地 DB,默认不删除本地新封面文件。若范围内包含已提交线上记录,必须提示本地重置不回滚蝦皮,重生成后再更新会再次提交线上。
|
- 「重置生成结果」支持选中任务或当前筛选结果,运行中禁用;确认框提供「重置标题 / 重置封面 / 重置全部」,只改本地 DB,默认不删除本地新封面文件。若范围内包含已提交线上记录,必须提示本地重置不回滚蝦皮,重生成后再更新会再次提交线上。
|
||||||
|
- ②任务表右键仅作用于鼠标命中的单条记录,提供「删除本条记录」。确认后调用任务级软删除:记录立即从①②③的列表、筛选和后续采集/生成/更新/回写范围排除;不删除原 Excel、本地图片、账号 Chrome 或蝦皮线上商品。生成中或任务运行中禁用该入口;已提交线上记录的确认框明确提示不回滚线上。
|
||||||
- **无逐条人工审核环节**;新标题默认使用 AI 输出,但允许对已生成且未提交线上的单行做本地微调。封面画廊内「重置图片」只清当前任务 `new_cover_path` 并归档旧图,不启动单条生图;用户翻看完后用状态筛选「待生成」并点击「开始生成」批量补封面。生成完即可进入 ③,③ 开始更新前会做批量确认。
|
- **无逐条人工审核环节**;新标题默认使用 AI 输出,但允许对已生成且未提交线上的单行做本地微调。封面画廊内「重置图片」只清当前任务 `new_cover_path` 并归档旧图,不启动单条生图;用户翻看完后用状态筛选「待生成」并点击「开始生成」批量补封面。生成完即可进入 ③,③ 开始更新前会做批量确认。
|
||||||
|
|
||||||
## ③ 更新蝦皮
|
## ③ 更新蝦皮
|
||||||
@@ -150,6 +151,7 @@
|
|||||||
- 若设置中开启“成功后关闭本次新开编辑页”,则仅关闭本轮程序自动新开且成功提交的商品页;确认后跳回我的商品列表页时,关闭前等待 2 秒;已进入编辑页后的失败任务和用户原本打开的 tab 不关闭。但如果失败发生在 `open_product()` 内部,程序自动新开的无效商品页 tab 要关闭,避免 Chrome 残留空错误页。
|
- 若设置中开启“成功后关闭本次新开编辑页”,则仅关闭本轮程序自动新开且成功提交的商品页;确认后跳回我的商品列表页时,关闭前等待 2 秒;已进入编辑页后的失败任务和用户原本打开的 tab 不关闭。但如果失败发生在 `open_product()` 内部,程序自动新开的无效商品页 tab 要关闭,避免 Chrome 残留空错误页。
|
||||||
- 「开始更新」是③的主操作按钮,视觉上必须强于其他批处理按钮。
|
- 「开始更新」是③的主操作按钮,视觉上必须强于其他批处理按钮。
|
||||||
- 「重置更新状态」从底部批处理按钮移到任务表右键菜单/高级入口,仅作用当前选中单条,运行中禁用;保留 `new_title/new_cover_path`,只把本地状态退回可更新,用于重复测试上传/提交。若 `committed=1`,确认框必须提示线上已提交过、本地重置不回滚蝦皮、重复更新会再次提交;不得静默清除 committed 历史事实。
|
- 「重置更新状态」从底部批处理按钮移到任务表右键菜单/高级入口,仅作用当前选中单条,运行中禁用;保留 `new_title/new_cover_path`,只把本地状态退回可更新,用于重复测试上传/提交。若 `committed=1`,确认框必须提示线上已提交过、本地重置不回滚蝦皮、重复更新会再次提交;不得静默清除 committed 历史事实。
|
||||||
|
- ③任务表右键保留「重置更新状态」,并以分隔线增加「删除本条记录」。删除只对鼠标命中的一条本地记录生效,复用任务级软删除;更新或结果回写运行中禁用。确认内容须说明商品、店铺、批次及不删除 Excel、图片、账号 Chrome 和蝦皮线上商品;已提交线上记录额外提示本地删除不回滚线上。
|
||||||
- 更新完成后自动回写原 Excel:写入新标题、新封面图片路径、更新状态;原文件被锁时提示关闭后点击「回写结果到 Excel」手动重试。
|
- 更新完成后自动回写原 Excel:写入新标题、新封面图片路径、更新状态;原文件被锁时提示关闭后点击「回写结果到 Excel」手动重试。
|
||||||
- 自动回写完成后弹窗汇总成功/失败/略过数量与 Excel 回写文件/行数。
|
- 自动回写完成后弹窗汇总成功/失败/略过数量与 Excel 回写文件/行数。
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: T-674
|
id: T-674
|
||||||
title: AI生成与更新蝦皮单条任务右键软删除
|
title: AI生成与更新蝦皮单条任务右键软删除
|
||||||
status: TODO
|
status: DONE
|
||||||
phase: 7
|
phase: 7
|
||||||
deps: [T-670, T-672]
|
deps: [T-670, T-672]
|
||||||
created: 2026-07-20
|
created: 2026-07-20
|
||||||
@@ -54,4 +54,8 @@ git diff --check
|
|||||||
|
|
||||||
## 执行记录
|
## 执行记录
|
||||||
|
|
||||||
- 待实现。
|
- ②任务表启用右键菜单:先选中命中行,仅提供「删除本条记录」;②生成中或任务运行中禁用/拒绝删除。
|
||||||
|
- ③右键菜单保留「重置更新状态」,以分隔线增加「删除本条记录」;更新或回写运行中禁用/拒绝删除。
|
||||||
|
- 两个 Tab 均复用 `db.delete_task()`,确认框显示商品、店铺、批次与本地软删除边界;已提交线上记录明确提示不回滚蝦皮。成功后经主窗口刷新回调同步①②③。
|
||||||
|
- 更新路由/API 文档,并新增②/③右键选中、菜单内容、取消、已提交提示、软删除成功刷新和运行中拦截的 GUI 回归测试。
|
||||||
|
- 验证通过:`py -3.10 -m unittest discover -s tests -p "test_gui.py"`(208 项)、`py -3.10 -m unittest discover -s tests -p "test_db.py"`(21 项)、`py -3.10 -m unittest discover -s tests`(627 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。
|
||||||
|
|||||||
@@ -9026,6 +9026,192 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_generate_tab_context_menu_soft_deletes_only_clicked_task(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": row,
|
||||||
|
"account_name": "主店",
|
||||||
|
"alias": "alias-a",
|
||||||
|
"item_id": item_id,
|
||||||
|
}
|
||||||
|
for row, item_id in ((2, "51100639510"), (3, "51100639511"))
|
||||||
|
],
|
||||||
|
path=cfg["db_path"],
|
||||||
|
)
|
||||||
|
first, second = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||||
|
for task in (first, second):
|
||||||
|
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||||||
|
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||||||
|
db.set_applied(first.id, True, path=cfg["db_path"])
|
||||||
|
statuses = []
|
||||||
|
refresh_calls = []
|
||||||
|
tab = GenerateTab(
|
||||||
|
config=cfg,
|
||||||
|
status_callback=statuses.append,
|
||||||
|
refresh_workflow_callback=lambda: refresh_calls.append(True),
|
||||||
|
)
|
||||||
|
self.addCleanup(tab.close)
|
||||||
|
tab.resize(1000, 600)
|
||||||
|
tab.show()
|
||||||
|
QApplication.processEvents()
|
||||||
|
first_rect = tab.task_table.visualRect(tab.model.index(0, 0))
|
||||||
|
self.assertTrue(first_rect.isValid())
|
||||||
|
|
||||||
|
class FakeMenu:
|
||||||
|
instances = []
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
self.actions = []
|
||||||
|
FakeMenu.instances.append(self)
|
||||||
|
|
||||||
|
def addAction(self, text):
|
||||||
|
action = SimpleNamespace(
|
||||||
|
text=text,
|
||||||
|
enabled=True,
|
||||||
|
triggered=DummySignal(),
|
||||||
|
)
|
||||||
|
action.setEnabled = lambda enabled: setattr(action, "enabled", bool(enabled))
|
||||||
|
self.actions.append(action)
|
||||||
|
return action
|
||||||
|
|
||||||
|
def exec(self, position):
|
||||||
|
return None
|
||||||
|
|
||||||
|
with mock.patch("app.gui.tabs.generate.QMenu", FakeMenu):
|
||||||
|
tab.show_task_context_menu(first_rect.center())
|
||||||
|
self.assertEqual(0, tab.task_table.currentIndex().row())
|
||||||
|
self.assertEqual(["删除本条记录"], [action.text for action in FakeMenu.instances[0].actions])
|
||||||
|
self.assertTrue(FakeMenu.instances[0].actions[0].enabled)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"app.gui.tabs.generate.QMessageBox.question",
|
||||||
|
return_value=gui.QMessageBox.No,
|
||||||
|
) as question:
|
||||||
|
tab.delete_selected_task()
|
||||||
|
self.assertIsNotNone(db.get_task(first.id, path=cfg["db_path"]))
|
||||||
|
self.assertIn("不会回滚蝦皮线上商品", question.call_args[0][2])
|
||||||
|
self.assertIn("已取消删除本条记录", statuses[-1])
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"app.gui.tabs.generate.QMessageBox.question",
|
||||||
|
return_value=gui.QMessageBox.Yes,
|
||||||
|
), mock.patch("app.gui.tabs.generate.QMessageBox.information") as information:
|
||||||
|
tab.delete_selected_task()
|
||||||
|
self.assertIsNone(db.get_task(first.id, path=cfg["db_path"]))
|
||||||
|
self.assertEqual([second.id], [task.id for task in tab.model.tasks])
|
||||||
|
self.assertEqual([True], refresh_calls)
|
||||||
|
self.assertIn("已删除本地记录:商品 51100639510", information.call_args[0][2])
|
||||||
|
|
||||||
|
tab.generate_thread = object()
|
||||||
|
tab.delete_selected_task()
|
||||||
|
self.assertIn("AI 生成正在进行,不能删除记录", statuses[-1])
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_apply_tab_context_menu_keeps_reset_and_soft_deletes_clicked_task(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": row,
|
||||||
|
"account_name": "主店",
|
||||||
|
"alias": "alias-a",
|
||||||
|
"item_id": item_id,
|
||||||
|
}
|
||||||
|
for row, item_id in ((2, "51100639510"), (3, "51100639511"))
|
||||||
|
],
|
||||||
|
path=cfg["db_path"],
|
||||||
|
)
|
||||||
|
first, second = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||||
|
for task in (first, second):
|
||||||
|
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||||||
|
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
|
||||||
|
db.set_applied(first.id, True, path=cfg["db_path"])
|
||||||
|
statuses = []
|
||||||
|
refresh_calls = []
|
||||||
|
tab = ApplyTab(
|
||||||
|
config=cfg,
|
||||||
|
status_callback=statuses.append,
|
||||||
|
refresh_workflow_callback=lambda: refresh_calls.append(True),
|
||||||
|
)
|
||||||
|
self.addCleanup(tab.close)
|
||||||
|
tab.status_filter.setCurrentIndex(tab.status_filter.findData("all"))
|
||||||
|
tab.resize(1000, 600)
|
||||||
|
tab.show()
|
||||||
|
QApplication.processEvents()
|
||||||
|
first_rect = tab.task_table.visualRect(tab.model.index(0, 0))
|
||||||
|
self.assertTrue(first_rect.isValid())
|
||||||
|
|
||||||
|
class FakeMenu:
|
||||||
|
instances = []
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
self.actions = []
|
||||||
|
self.separator_count = 0
|
||||||
|
FakeMenu.instances.append(self)
|
||||||
|
|
||||||
|
def addAction(self, text):
|
||||||
|
action = SimpleNamespace(
|
||||||
|
text=text,
|
||||||
|
enabled=True,
|
||||||
|
triggered=DummySignal(),
|
||||||
|
)
|
||||||
|
action.setEnabled = lambda enabled: setattr(action, "enabled", bool(enabled))
|
||||||
|
self.actions.append(action)
|
||||||
|
return action
|
||||||
|
|
||||||
|
def addSeparator(self):
|
||||||
|
self.separator_count += 1
|
||||||
|
|
||||||
|
def exec(self, position):
|
||||||
|
return None
|
||||||
|
|
||||||
|
with mock.patch("app.gui.tabs.apply.QMenu", FakeMenu):
|
||||||
|
tab.show_task_context_menu(first_rect.center())
|
||||||
|
self.assertEqual(0, tab.task_table.currentIndex().row())
|
||||||
|
menu = FakeMenu.instances[0]
|
||||||
|
self.assertEqual(["重置更新状态", "删除本条记录"], [action.text for action in menu.actions])
|
||||||
|
self.assertEqual(1, menu.separator_count)
|
||||||
|
self.assertTrue(all(action.enabled for action in menu.actions))
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"app.gui.tabs.apply.QMessageBox.question",
|
||||||
|
return_value=gui.QMessageBox.No,
|
||||||
|
) as question:
|
||||||
|
tab.delete_selected_task()
|
||||||
|
self.assertIsNotNone(db.get_task(first.id, path=cfg["db_path"]))
|
||||||
|
self.assertIn("不会回滚蝦皮线上商品", question.call_args[0][2])
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"app.gui.tabs.apply.QMessageBox.question",
|
||||||
|
return_value=gui.QMessageBox.Yes,
|
||||||
|
), mock.patch("app.gui.tabs.apply.QMessageBox.information") as information:
|
||||||
|
tab.delete_selected_task()
|
||||||
|
self.assertIsNone(db.get_task(first.id, path=cfg["db_path"]))
|
||||||
|
self.assertEqual([second.id], [task.id for task in tab.model.tasks])
|
||||||
|
self.assertEqual([True], refresh_calls)
|
||||||
|
self.assertIn("已删除本地记录:商品 51100639510", information.call_args[0][2])
|
||||||
|
|
||||||
|
tab.apply_thread = object()
|
||||||
|
tab.delete_selected_task()
|
||||||
|
self.assertIn("更新或回写正在进行,不能删除记录", statuses[-1])
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
def test_collect_tab_can_filter_unmatched_tasks_from_summary_bar(self):
|
def test_collect_tab_can_filter_unmatched_tasks_from_summary_bar(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