feat(gui): improve status scope dialogs

This commit is contained in:
chengma
2026-07-18 17:23:22 +08:00
parent 89f1b085dc
commit 7538009f94
8 changed files with 218 additions and 62 deletions
+17 -22
View File
@@ -750,30 +750,25 @@ class CollectTab(QWidget):
thread.start() thread.start()
def _choose_collect_scope(self): def _choose_collect_scope(self):
box = QMessageBox(self) box = ProductStatusScopeDialog(
box.setIcon(QMessageBox.Question) title="选择采集范围",
box.setWindowTitle("选择采集范围") text="请选择本轮要采集的商品范围。",
box.setText("请选择本轮要采集的商品范围。") informative_text=(
box.setInformativeText( "程序会逐个打开商品详情页检测状态并保存结果。"
"程序会逐个打开商品详情页检测状态并保存结果。" "采集所有状态商品包含未上架、审核中和状态未知商品,"
"采集所有状态商品包含未上架、审核中和状态未知商品," "可能增加采集时间,但本步骤不消耗 AI 点数。"
"可能增加采集时间,但本步骤不消耗 AI 点数。" ),
normal_text="只采集状态正常的商品",
all_text="采集所有状态的商品",
normal_value=product_status.COLLECT_SCOPE_NORMAL_ONLY,
all_value=product_status.COLLECT_SCOPE_ALL,
normal_object_name="collectNormalOnlyButton",
all_object_name="collectAllStatusesButton",
cancel_object_name="collectScopeCancelButton",
parent=self,
) )
normal_button = box.addButton("只采集状态正常的商品", QMessageBox.AcceptRole)
normal_button.setObjectName("collectNormalOnlyButton")
all_button = box.addButton("采集所有状态的商品", QMessageBox.DestructiveRole)
all_button.setObjectName("collectAllStatusesButton")
all_button.setStyleSheet("color: #cf222e; font-weight: 600;")
cancel_button = box.addButton("取消", QMessageBox.RejectRole)
cancel_button.setObjectName("collectScopeCancelButton")
box.setDefaultButton(normal_button)
box.setEscapeButton(cancel_button)
box.exec() box.exec()
if box.clickedButton() is normal_button: return box.choice()
return product_status.COLLECT_SCOPE_NORMAL_ONLY
if box.clickedButton() is all_button:
return product_status.COLLECT_SCOPE_ALL
return None
def stop_collect(self, checked=False): def stop_collect(self, checked=False):
if self.collect_worker is not None: if self.collect_worker is not None:
+24 -29
View File
@@ -1828,37 +1828,32 @@ class GenerateTab(QWidget):
total = len(plan.get("base_candidates") or []) total = len(plan.get("base_candidates") or [])
non_normal_count = total - normal_count non_normal_count = total - normal_count
box = QMessageBox(self) box = ProductStatusScopeDialog(
box.setIcon(QMessageBox.Question) title="选择生成范围",
box.setWindowTitle("选择生成范围") text="请选择本轮要生成的商品范围。",
box.setText("请选择本轮要生成的商品范围。") informative_text=(
box.setInformativeText( "真实候选共{total}条:正常{normal},未上架{unlisted},审核中{reviewing},状态未知{unknown}。\n"
"真实候选共{total}条:正常{normal},未上架{unlisted},审核中{reviewing},状态未知{unknown}。\n" "生成所有状态的商品会让非正常状态商品也调用 AI,可能额外消耗点数。"
"生成所有状态的商品会让非正常状态商品也调用 AI,可能额外消耗点数。" "状态未知商品请优先回到①重新采集确认。".format(
"状态未知商品请优先回到①重新采集确认。".format( total=total,
total=total, normal=normal_count,
normal=normal_count, unlisted=unlisted_count,
unlisted=unlisted_count, reviewing=reviewing_count,
reviewing=reviewing_count, unknown=unknown_count,
unknown=unknown_count, )
) ),
normal_text="只生成状态正常的商品",
all_text="生成所有状态的商品",
normal_value=product_status.SCOPE_NORMAL_ONLY,
all_value=product_status.SCOPE_ALL,
normal_object_name="generateNormalOnlyButton",
all_object_name="generateAllStatusesButton",
cancel_object_name="generateScopeCancelButton",
all_enabled=non_normal_count > 0,
parent=self,
) )
normal_button = box.addButton("只生成状态正常的商品", QMessageBox.AcceptRole)
normal_button.setObjectName("generateNormalOnlyButton")
all_button = box.addButton("生成所有状态的商品", QMessageBox.DestructiveRole)
all_button.setObjectName("generateAllStatusesButton")
all_button.setStyleSheet("color: #cf222e; font-weight: 600;")
all_button.setEnabled(non_normal_count > 0)
cancel_button = box.addButton("取消", QMessageBox.RejectRole)
cancel_button.setObjectName("generateScopeCancelButton")
box.setDefaultButton(normal_button)
box.setEscapeButton(cancel_button)
box.exec() box.exec()
if box.clickedButton() is normal_button: return box.choice()
return product_status.SCOPE_NORMAL_ONLY
if all_button.isEnabled() and box.clickedButton() is all_button:
return product_status.SCOPE_ALL
return None
def stop_generate(self, checked=False): def stop_generate(self, checked=False):
if self.generate_worker is not None: if self.generate_worker is not None:
+104
View File
@@ -292,6 +292,110 @@ def _danger_outline_button_style(object_name):
) )
def _primary_button_style(object_name):
return (
f"QPushButton#{object_name} {{ "
f"background-color: {COLOR_INFO}; border-color: {COLOR_INFO}; "
"color: white; font-weight: 600; "
"}"
f"QPushButton#{object_name}:hover {{ "
"background-color: #0550ae; border-color: #0550ae; "
"}"
f"QPushButton#{object_name}:pressed {{ "
"background-color: #033d8b; border-color: #033d8b; "
"}"
)
class ProductStatusScopeDialog(QDialog):
"""供采集和生成复用的稳定中文范围选择框。"""
MINIMUM_WIDTH = 520
def __init__(
self,
title,
text,
informative_text,
normal_text,
all_text,
normal_value,
all_value,
normal_object_name,
all_object_name,
cancel_object_name,
all_enabled=True,
parent=None,
):
super().__init__(parent)
self._choice = None
self._informative_text = str(informative_text or "")
self.setWindowTitle(str(title or "选择范围"))
self.setModal(True)
self.setMinimumWidth(self.MINIMUM_WIDTH)
self.setStyleSheet(BUTTON_BASE_STYLE)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 18, 20, 18)
layout.setSpacing(10)
title_label = QLabel(str(text or ""))
title_label.setObjectName("productStatusScopeTitleLabel")
title_label.setWordWrap(True)
title_label.setStyleSheet("font-weight: 600; color: #24292f;")
layout.addWidget(title_label)
detail_label = QLabel(self._informative_text)
detail_label.setObjectName("productStatusScopeDetailLabel")
detail_label.setWordWrap(True)
detail_label.setStyleSheet("color: #57606a;")
layout.addWidget(detail_label)
layout.addSpacing(2)
self.normal_button = QPushButton(str(normal_text or ""))
self.normal_button.setObjectName(str(normal_object_name))
self.normal_button.setMinimumWidth(self.MINIMUM_WIDTH - 40)
self.normal_button.setMinimumHeight(34)
self.normal_button.setDefault(True)
self.normal_button.setFocus()
self.normal_button.setStyleSheet(_primary_button_style(normal_object_name))
self.normal_button.clicked.connect(lambda: self._accept_choice(normal_value))
layout.addWidget(self.normal_button)
self.all_button = QPushButton(str(all_text or ""))
self.all_button.setObjectName(str(all_object_name))
self.all_button.setMinimumWidth(self.MINIMUM_WIDTH - 40)
self.all_button.setMinimumHeight(34)
self.all_button.setEnabled(bool(all_enabled))
self.all_button.setStyleSheet(_warning_outline_button_style(all_object_name))
self.all_button.clicked.connect(lambda: self._accept_choice(all_value))
layout.addWidget(self.all_button)
self.cancel_button = QPushButton("取消")
self.cancel_button.setObjectName(str(cancel_object_name))
self.cancel_button.setMinimumWidth(self.MINIMUM_WIDTH - 40)
self.cancel_button.setMinimumHeight(32)
self.cancel_button.clicked.connect(self.reject)
layout.addWidget(self.cancel_button)
def defaultButton(self):
"""供调用方和 GUI 测试读取明确的默认按钮。"""
return self.normal_button
def informativeText(self):
"""按 QMessageBox 兼容接口返回说明文字。"""
return self._informative_text
def choice(self):
return self._choice
def _accept_choice(self, value):
self._choice = value
self.accept()
def _login_status_display(status): def _login_status_display(status):
return f"● {status or '未知'}" return f"● {status or '未知'}"
+1 -1
View File
@@ -431,7 +431,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
单个「开始生成」按钮,按本轮「生成内容」模式生成标题、封面或图文: 单个「开始生成」按钮,按本轮「生成内容」模式生成标题、封面或图文:
- 点击后先从当前筛选结果按生成模式构造真实候选,再在主线程按 `normal_only/all` 选择范围:默认只生成 `normal`,红色危险选项才允许未上架、审核中、状态未知(历史 `NULL` 同未知)商品进入 Worker。范围确认显示四类数量;取消、无候选或确认期间候选指纹变化时不创建 Worker、不请求 AI、不改变 DB。危险选择不跨轮记忆,且没有非正常候选时禁用。范围只决定本轮生成,不修改商品状态,也不授权③更新。 - 点击后先从当前筛选结果按生成模式构造真实候选,再在主线程按 `normal_only/all` 选择范围:①②共用最小宽度 520px、纵向全宽按钮的中文范围选择框,默认只处理 `normal`;“所有状态”用警示橙色描边表示扩大范围/可能增加成本,不使用删除红色或 `DestructiveRole`。它才允许未上架、审核中、状态未知(历史 `NULL` 同未知)商品进入 Worker。范围确认显示四类数量;取消、无候选或确认期间候选指纹变化时不创建 Worker、不请求 AI、不改变 DB。警示选择不跨轮记忆,且没有非正常候选时禁用。范围只决定本轮生成,不修改商品状态,也不授权③更新。
- 共享 `product_status.build_generation_plan()` 对候选按稳定任务 ID 去重并冻结 `task_id/updated_at/product_status/new_title/new_cover_path/生成内容模式` 指纹。`GenerateWorker` 接收确认后的精确任务清单和范围代码,在 `normal_only` 下再次防御性排除非正常状态,`ai.generate_batch()` 只能收到最终执行清单。`run_logs.options` 与汇总记录选择范围、四类数量、按范围排除数和计划指纹;用户日志只显示中文范围摘要。 - 共享 `product_status.build_generation_plan()` 对候选按稳定任务 ID 去重并冻结 `task_id/updated_at/product_status/new_title/new_cover_path/生成内容模式` 指纹。`GenerateWorker` 接收确认后的精确任务清单和范围代码,在 `normal_only` 下再次防御性排除非正常状态,`ai.generate_batch()` 只能收到最终执行清单。`run_logs.options` 与汇总记录选择范围、四类数量、按范围排除数和计划指纹;用户日志只显示中文范围摘要。
1. **并发生成标题**:线程池大小 = `title_concurrency`,调 `gen_title(标题提示词, old_title)` → new_title。默认 `backend=cmhub`,调用 `POST /api/v1/generate/title` 并使用 `title_alias`;`backend=direct` 仅内部兼容时使用 `default_text_model`。 1. **并发生成标题**:线程池大小 = `title_concurrency`,调 `gen_title(标题提示词, old_title)` → new_title。默认 `backend=cmhub`,调用 `POST /api/v1/generate/title` 并使用 `title_alias`;`backend=direct` 仅内部兼容时使用 `default_text_model`。
+3 -2
View File
@@ -455,6 +455,7 @@ main() -> int # 创建 QApplication + MainWindow
class MainWindow(QMainWindow) # QTabWidget: ①、②、③、账号管理、设置、商品套图;支持注入 db_path/config/config_path/ai_models_path 便于测试 class MainWindow(QMainWindow) # QTabWidget: ①、②、③、账号管理、设置、商品套图;支持注入 db_path/config/config_path/ai_models_path 便于测试
class CollectTab(QWidget) # ① 导入采集:导入 Excel + 汇总栏 + QTableView 任务列表 + 未匹配略过标记 class CollectTab(QWidget) # ① 导入采集:导入 Excel + 汇总栏 + QTableView 任务列表 + 未匹配略过标记
class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 状态范围确认 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志 class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 状态范围确认 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志
class ProductStatusScopeDialog(QDialog) # ①②复用:纵向商品状态范围选择、中文说明、默认正常范围、警示扩展范围
class ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成任务 + 商品状态/内容预检剔除 + 检查本轮更新 + 确认后分批真实更新 + 运行日志 class ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成任务 + 商品状态/内容预检剔除 + 检查本轮更新 + 确认后分批真实更新 + 运行日志
class SettingsTab(QWidget) # 设置:cmhub 网关配置 + 响应式三列布局 + 角色/生成参数/路径端口 + 蝦皮更新安全 + 未保存状态追踪 class SettingsTab(QWidget) # 设置:cmhub 网关配置 + 响应式三列布局 + 角色/生成参数/路径端口 + 蝦皮更新安全 + 未保存状态追踪
class ProductSuiteTab(QWidget) # 商品套图:多任务、原图、结构配置、AI帮写、cmhub生成、历史结果 class ProductSuiteTab(QWidget) # 商品套图:多任务、原图、结构配置、AI帮写、cmhub生成、历史结果
@@ -520,7 +521,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
- 任务列表使用 `QTableView + TaskTableModel`,列为:账号、别名、商品ID、阶段。 - 任务列表使用 `QTableView + TaskTableModel`,列为:账号、别名、商品ID、阶段。
- 账号列优先显示匹配到的 `accounts.account_name`;未匹配账号时保留 Excel 输入账号名。 - 账号列优先显示匹配到的 `accounts.account_name`;未匹配账号时保留 Excel 输入账号名。
- 别名未匹配 `accounts.alias` 时列表阶段列显示“略过”;点击「采集旧标题/旧封面」后由 `CollectWorker` 逐条写库为 `skipped`,原因 `别名未匹配账号`。 - 别名未匹配 `accounts.alias` 时列表阶段列显示“略过”;点击「采集旧标题/旧封面」后由 `CollectWorker` 逐条写库为 `skipped`,原因 `别名未匹配账号`。
- 「采集旧标题/旧封面」先弹范围确认框,默认安全策略 `normal_only`,红色危险选项为 `all`,取消不创建 Worker。`CollectWorker` 只处理 `stage=imported` 的任务;采集前先做账号就绪预检。无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去账号管理,不进入逐条采集、不写 skipped/failed。预检通过后,已匹配任务调用 `editor.collect()` 先检测和保存页面状态;默认范围仅正常商品下载旧封面到 `image_dir/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg` 并 `db.set_collected()`,其他状态 `set_product_status()` 后按范围 `mark_skipped` 且保留原旧内容;选择全部范围时四类状态均采集。别名未匹配任务仍逐条 `mark_skipped`;单条技术失败 `mark_failed(..., "collect", error)` 后继续。采集完成且本轮成功采集数量大于 0 时,自动触发当前批次旧字段回写;锁文件失败时只提示,不回滚 SQLite。 - 「采集旧标题/旧封面」先弹 `ProductStatusScopeDialog` 范围确认框,默认安全策略 `normal_only`,`all` 是警示橙色的扩大范围操作而非删除操作;对话框最小宽度 520px,三个中文选项纵向全宽显示,取消不创建 Worker。`CollectWorker` 只处理 `stage=imported` 的任务;采集前先做账号就绪预检。无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去账号管理,不进入逐条采集、不写 skipped/failed。预检通过后,已匹配任务调用 `editor.collect()` 先检测和保存页面状态;默认范围仅正常商品下载旧封面到 `image_dir/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg` 并 `db.set_collected()`,其他状态 `set_product_status()` 后按范围 `mark_skipped` 且保留原旧内容;选择全部范围时四类状态均采集。别名未匹配任务仍逐条 `mark_skipped`;单条技术失败 `mark_failed(..., "collect", error)` 后继续。采集完成且本轮成功采集数量大于 0 时,自动触发当前批次旧字段回写;锁文件失败时只提示,不回滚 SQLite。
- 采集打开商品页时,若本轮自动新建 tab,采集完成后会关闭该 tab,并在最多 2 秒内确认 target 已从 `/json` 消失;确认超时只写 warning/诊断,不把采集成功改成失败。若失败发生在 `open_product()` 内部且尚未返回 `cdp`,也要关闭本轮自动新建 tab;若复用用户已打开的商品页,只断开 CDP 连接不关闭页面。 - 采集打开商品页时,若本轮自动新建 tab,采集完成后会关闭该 tab,并在最多 2 秒内确认 target 已从 `/json` 消失;确认超时只写 warning/诊断,不把采集成功改成失败。若失败发生在 `open_product()` 内部且尚未返回 `cdp`,也要关闭本轮自动新建 tab;若复用用户已打开的商品页,只断开 CDP 连接不关闭页面。
- 采集中途登录检测必须快速跳过正在销毁的旧商品 target,改连其他有效 Shopee 页面。Cookie API 调用失败返回 `LOGIN_CHECK_TARGET_UNAVAILABLE`,只有 Cookie API 成功返回空会话时才返回 `NO_SESSION_COOKIE`;两者都不按明确掉登录批量略过,显式 `LOGIN_PAGE` 仍按账号需登录处理。retry/recovered 运行日志包含当前任务 ID 和商品 ID,避免与上一条采集成功日志混淆。 - 采集中途登录检测必须快速跳过正在销毁的旧商品 target,改连其他有效 Shopee 页面。Cookie API 调用失败返回 `LOGIN_CHECK_TARGET_UNAVAILABLE`,只有 Cookie API 成功返回空会话时才返回 `NO_SESSION_COOKIE`;两者都不按明确掉登录批量略过,显式 `LOGIN_PAGE` 仍按账号需登录处理。retry/recovered 运行日志包含当前任务 ID 和商品 ID,避免与上一条采集成功日志混淆。
- 采集打开商品页失败时,`CollectWorker` 应把 `open_product()` 捕获到的 Shopee toast 文案写入 `run_log_events` 和 `tasks.last_error`;商品 ID 失效、无权限、店铺不匹配等场景不得只显示泛化超时。① `TaskTableModel` 的“阶段”列只在 `last_error` 明确为商品失效类错误时显示“商品失效”,否则仍按 `status=failed` 显示“失败”;底层不新增 `stage` 枚举。 - 采集打开商品页失败时,`CollectWorker` 应把 `open_product()` 捕获到的 Shopee toast 文案写入 `run_log_events` 和 `tasks.last_error`;商品 ID 失效、无权限、店铺不匹配等场景不得只显示泛化超时。① `TaskTableModel` 的“阶段”列只在 `last_error` 明确为商品失效类错误时显示“商品失效”,否则仍按 `status=failed` 显示“失败”;底层不新增 `stage` 枚举。
@@ -538,7 +539,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
- 筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部;状态支持全部/待生成/已生成/失败/略过/已更新。 - 筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部;状态支持全部/待生成/已生成/失败/略过/已更新。
- 任务列表使用 `QTableView + GenerateTaskTableModel`,列为:店铺、商品ID、旧标题、新标题、状态。`stage=collected` 显示“待生成”,`stage=generated` 显示“已生成”,`status=failed/skipped/running` 优先显示对应状态;已生成、未提交线上、非运行中的「新标题」列可双击编辑,调用 `db.update_generated_title()` 写回本地并清空 `last_error`。 - 任务列表使用 `QTableView + GenerateTaskTableModel`,列为:店铺、商品ID、旧标题、新标题、状态。`stage=collected` 显示“待生成”,`stage=generated` 显示“已生成”,`status=failed/skipped/running` 优先显示对应状态;已生成、未提交线上、非运行中的「新标题」列可双击编辑,调用 `db.update_generated_title()` 写回本地并清空 `last_error`。
- 双击「新标题」列进入本地编辑;双击其他列弹窗展示旧封面与新封面路径对应图片;图片不存在时显示空态/路径提示,只做查看,不做审核。 - 双击「新标题」列进入本地编辑;双击其他列弹窗展示旧封面与新封面路径对应图片;图片不存在时显示空态/路径提示,只做查看,不做审核。
- 底部「开始生成」只处理当前筛选结果里可补齐的任务;批次/店铺/商品ID/状态筛选共同决定当前筛选结果;②「生成内容」下拉支持只生成标题、只生成封面、生成标题和封面。只生成封面时不调用生文,任务有新标题则使用新标题,否则使用已采集旧标题作为封面prompt回退;新旧标题都为空才不纳入。候选存在时由 `product_status.build_generation_plan()` 统计正常/未上架/审核中/未知并冻结指纹,弹出范围确认:默认 `normal_only`,红色危险选项 `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`,`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.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` 事件。
+2 -2
View File
@@ -72,7 +72,7 @@
- 导入:openpyxl 解析**输入列**(账号名/别名/商品id)入 SQLite。 - 导入:openpyxl 解析**输入列**(账号名/别名/商品id)入 SQLite。
- **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。 - **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。
- 采集:点击后先选择范围,默认「只采集状态正常的商品」,红色危险选项为「采集所有状态的商品」。随后为本轮匹配账号确保 Chrome 就绪(已开复用、未开启动),再检测登录;明确未登录账号的任务整组略过并汇总提示。`NO_SESSION_COOKIE`、登录检测超时或 CDP 短暂异常会重试,连续不确定时不批量略过,继续打开商品页由真实页面结果决定成功/失败。登录账号用对应 Chrome 只读打开商品页,先检测并保存商品状态;默认范围下只有正常商品才读旧标题、下载旧封面到 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`,未上架、审核中和状态未知商品按范围略过且不覆盖已有内容。选择全部范围时四类商品均继续采集。采集不主动把商品页切到前台;程序自动新建商品页 tab 时尽量后台创建,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。采集结束不关闭账号 Chrome,用户可自行关闭。 - 采集:点击后先从专用范围选择框选择范围,默认「只采集状态正常的商品」,「采集所有状态的商品」为警示橙色描边而非删除红色;三个选项纵向全宽显示,常见 Windows 缩放下不截断。随后为本轮匹配账号确保 Chrome 就绪(已开复用、未开启动),再检测登录;明确未登录账号的任务整组略过并汇总提示。`NO_SESSION_COOKIE`、登录检测超时或 CDP 短暂异常会重试,连续不确定时不批量略过,继续打开商品页由真实页面结果决定成功/失败。登录账号用对应 Chrome 只读打开商品页,先检测并保存商品状态;默认范围下只有正常商品才读旧标题、下载旧封面到 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`,未上架、审核中和状态未知商品按范围略过且不覆盖已有内容。选择全部范围时四类商品均继续采集。采集不主动把商品页切到前台;程序自动新建商品页 tab 时尽量后台创建,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。采集结束不关闭账号 Chrome,用户可自行关闭。
- 若商品 ID 已失效、无权限或店铺不匹配,Shopee 可能只弹出短暂错误 toast;采集失败时界面日志应显示捕获到的 toast 文案,并把 toast HTML/URL 写入本地诊断日志,避免用户手动抢复制。只有明确捕获商品失效/商品不存在/无权限类 toast 时,①列表“阶段”列显示“商品失效”;其他商品页打开失败仍显示“失败”。如果失败发生在 `open_product()` 内部,本轮自动新建的商品 tab 必须关闭,复用用户已有 tab 不关闭。 - 若商品 ID 已失效、无权限或店铺不匹配,Shopee 可能只弹出短暂错误 toast;采集失败时界面日志应显示捕获到的 toast 文案,并把 toast HTML/URL 写入本地诊断日志,避免用户手动抢复制。只有明确捕获商品失效/商品不存在/无权限类 toast 时,①列表“阶段”列显示“商品失效”;其他商品页打开失败仍显示“失败”。如果失败发生在 `open_product()` 内部,本轮自动新建的商品 tab 必须关闭,复用用户已有 tab 不关闭。
- 回写:采集完成后自动把旧标题/旧封面路径批量回写原 Excel;保留「回写旧数据到 Excel」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。 - 回写:采集完成后自动把旧标题/旧封面路径批量回写原 Excel;保留「回写旧数据到 Excel」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。
@@ -106,7 +106,7 @@
- 右上:按导入批次 / 店铺 / 商品ID / 状态筛选任务;商品ID输入框按包含匹配 `item_id`,清空表示全部。 - 右上:按导入批次 / 店铺 / 商品ID / 状态筛选任务;商品ID输入框按包含匹配 `item_id`,清空表示全部。
- 筛选行提供「打开图片文件夹」按钮,用于只读打开本地图片目录:选中某行时打开该商品所在账号图片文件夹(优先打开已有新/旧封面文件的真实父目录,缺失时回退到规范账号目录);未选行且选择具体批次时打开该批次图片文件夹;未选行且为全部批次时打开图片根目录。目录不存在只中文提示,不自动创建目录,不修改任务状态。 - 筛选行提供「打开图片文件夹」按钮,用于只读打开本地图片目录:选中某行时打开该商品所在账号图片文件夹(优先打开已有新/旧封面文件的真实父目录,缺失时回退到规范账号目录);未选行且选择具体批次时打开该批次图片文件夹;未选行且为全部批次时打开图片根目录。目录不存在只中文提示,不自动创建目录,不修改任务状态。
- 右下:任务列表(店铺名、商品id、旧标题、新标题、标题状态、图片状态)+ AI生成运行日志;标题/图片状态由 `new_title`、`new_cover_path`、`stage/status` 和失败步骤推导,帮助用户区分“标题未生成 / 图片未生成 / 标题成功但图片失败”。商品ID列按原等分宽度约 50% 显示;标题状态和图片状态列在 T-554 基础上再缩到约 33%,缩出的宽度平均给旧标题和新标题。已生成、未提交线上、非运行中的任务可双击「新标题」列本地微调,写回 `tasks.new_title`,清空 `last_error` 并回到可更新;双击其他列弹窗展示旧封面、新封面和历史候选图。 - 右下:任务列表(店铺名、商品id、旧标题、新标题、标题状态、图片状态)+ AI生成运行日志;标题/图片状态由 `new_title`、`new_cover_path`、`stage/status` 和失败步骤推导,帮助用户区分“标题未生成 / 图片未生成 / 标题成功但图片失败”。商品ID列按原等分宽度约 50% 显示;标题状态和图片状态列在 T-554 基础上再缩到约 33%,缩出的宽度平均给旧标题和新标题。已生成、未提交线上、非运行中的任务可双击「新标题」列本地微调,写回 `tasks.new_title`,清空 `last_error` 并回到可更新;双击其他列弹窗展示旧封面、新封面和历史候选图。
- 底部**单个「开始生成」+「停止」**,并增加「生成内容」下拉:默认只生成标题,可选只生成封面或生成标题和封面;只生成封面不调用生文,有新标题时优先使用,没有时用已采集旧标题作为封面prompt参考,新旧标题都为空才不纳入。开始前先从真实候选重新分组商品状态,弹出范围确认框:默认「只生成状态正常的商品」,红色危险选项为「生成所有状态的商品」;未上架、审核中、状态未知(含历史未采集状态)默认不入队、不请求 AI、不消耗点数,用户明确选择全部范围才可入队。范围确认不跨轮记忆,取消或候选在确认期间变化均不启动生成;无异常候选时危险选项禁用。标题/图片两条进度条右侧分别显示同宽用时标签(`生标题用时 N 秒` / `生图用时 N 秒`),运行中每秒递增,完成/停止后冻结;原图片进度条右侧的失败数和 cmhub 余额不再占用该位置。cmhub 模式会把用户设置的图片并发内部限制到最大 5,并用同样最大 5 的独立下载线程池拉取 `image_url`,不新增用户可见下载并发配置;运行日志显示用户设置并发和实际并发。下拉状态持久化到 `config.json` 的 `ai.generate_mode`,并继续写回旧兼容 `ai.generate_cover`。 - 底部**单个「开始生成」+「停止」**,并增加「生成内容」下拉:默认只生成标题,可选只生成封面或生成标题和封面;只生成封面不调用生文,有新标题时优先使用,没有时用已采集旧标题作为封面prompt参考,新旧标题都为空才不纳入。开始前先从真实候选重新分组商品状态,弹出与①共用的纵向范围确认框:默认「只生成状态正常的商品」,「生成所有状态的商品」为警示橙色描边;未上架、审核中、状态未知(含历史未采集状态)默认不入队、不请求 AI、不消耗点数,用户明确选择全部范围才可入队。范围确认不跨轮记忆,取消或候选在确认期间变化均不启动生成;无异常候选时该警示选项禁用。标题/图片两条进度条右侧分别显示同宽用时标签(`生标题用时 N 秒` / `生图用时 N 秒`),运行中每秒递增,完成/停止后冻结;原图片进度条右侧的失败数和 cmhub 余额不再占用该位置。cmhub 模式会把用户设置的图片并发内部限制到最大 5,并用同样最大 5 的独立下载线程池拉取 `image_url`,不新增用户可见下载并发配置;运行日志显示用户设置并发和实际并发。下拉状态持久化到 `config.json` 的 `ai.generate_mode`,并继续写回旧兼容 `ai.generate_cover`。
- 生成参数(标题/图片并发数、失败重试、分辨率、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,默认不删除本地新封面文件。若范围内包含已提交线上记录,必须提示本地重置不回滚蝦皮,重生成后再更新会再次提交线上。
+6 -2
View File
@@ -1,7 +1,7 @@
--- ---
id: T-663 id: T-663
title: 采集与生成范围确认弹窗可读性与语义色 title: 采集与生成范围确认弹窗可读性与语义色
status: TODO status: DONE
phase: 7 phase: 7
deps: [T-662b, T-662c] deps: [T-662b, T-662c]
created: 2026-07-18 created: 2026-07-18
@@ -55,4 +55,8 @@ git diff --check
## 执行记录 ## 执行记录
- 待实现。 - 新增 `ProductStatusScopeDialog`,供①导入采集和②AI生成复用;窗口最小宽度 520px,说明文字换行,三个中文选项纵向全宽显示。
- 默认“正常状态”选项改为主操作蓝色;“所有状态”改为 `#bc4c00` 警示橙色描边,不再使用错误红色或 `DestructiveRole`;取消保持中性,回车默认选择正常范围,Esc 取消。
- 保留既有按钮 objectName 和 `normal_only/all/None` 返回值;不改 Worker、状态范围、任务计划、运行日志、计费或③更新蝦皮逻辑。
- 同步更新 `docs/routes.md`、`docs/04-architecture.md`、`docs/api.md`。
- 验证通过:`py -3.10 -m unittest tests.test_gui`(202 项)、`py -3.10 -m unittest discover -s tests`(617 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。
+61 -4
View File
@@ -30,6 +30,7 @@ if gui.QT_IMPORT_ERROR is not None:
from PySide6.QtCore import QItemSelectionModel, QModelIndex, QRect, QSize, QTimer, Qt from PySide6.QtCore import QItemSelectionModel, QModelIndex, QRect, QSize, QTimer, Qt
from PySide6.QtGui import QImage, QKeyEvent, QTextCursor from PySide6.QtGui import QImage, QKeyEvent, QTextCursor
from PySide6.QtTest import QTest
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
QApplication, QApplication,
@@ -6126,7 +6127,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
def test_generate_scope_dialog_uses_safe_default_and_red_all_status_choice(self): def test_generate_scope_dialog_uses_safe_default_and_warning_all_status_choice(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
tab = GenerateTab(config=self.make_config(temp_dir)) tab = GenerateTab(config=self.make_config(temp_dir))
self.addCleanup(tab.close) self.addCleanup(tab.close)
@@ -6149,8 +6150,19 @@ class GuiTests(TempDirMixin, unittest.TestCase):
captured["all_style"] = all_button.styleSheet() captured["all_style"] = all_button.styleSheet()
captured["all_enabled"] = all_button.isEnabled() captured["all_enabled"] = all_button.isEnabled()
captured["detail"] = box.informativeText() captured["detail"] = box.informativeText()
captured["minimum_width"] = box.minimumWidth()
captured["normal_button_width"] = normal_button.width()
captured["all_button_width"] = all_button.width()
captured["normal_text_width"] = normal_button.fontMetrics().horizontalAdvance(
normal_button.text()
)
captured["all_text_width"] = all_button.fontMetrics().horizontalAdvance(
all_button.text()
)
self.assertIsNotNone(normal_button) self.assertIsNotNone(normal_button)
self.assertIsNotNone(all_button) self.assertIsNotNone(all_button)
self.assertEqual("只生成状态正常的商品", normal_button.text())
self.assertEqual("生成所有状态的商品", all_button.text())
all_button.click() all_button.click()
QTimer.singleShot(0, click_all_statuses) QTimer.singleShot(0, click_all_statuses)
@@ -6159,7 +6171,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual("all", scope) self.assertEqual("all", scope)
self.assertEqual("generateNormalOnlyButton", captured["default"]) self.assertEqual("generateNormalOnlyButton", captured["default"])
self.assertTrue(captured["all_enabled"]) self.assertTrue(captured["all_enabled"])
self.assertIn("#cf222e", captured["all_style"]) self.assertGreaterEqual(captured["minimum_width"], 520)
self.assertGreaterEqual(captured["normal_button_width"], 480)
self.assertGreaterEqual(captured["all_button_width"], 480)
self.assertGreater(captured["normal_button_width"], captured["normal_text_width"])
self.assertGreater(captured["all_button_width"], captured["all_text_width"])
self.assertIn("#bc4c00", captured["all_style"])
self.assertNotIn("#cf222e", captured["all_style"])
self.assertIn("状态未知1", captured["detail"]) self.assertIn("状态未知1", captured["detail"])
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
@@ -8353,7 +8371,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
def test_collect_scope_dialog_uses_safe_default_and_red_all_status_choice(self): def test_collect_scope_dialog_uses_safe_default_and_warning_all_status_choice(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
tab = CollectTab(config=self.make_config(temp_dir)) tab = CollectTab(config=self.make_config(temp_dir))
self.addCleanup(tab.close) self.addCleanup(tab.close)
@@ -8365,8 +8383,19 @@ class GuiTests(TempDirMixin, unittest.TestCase):
all_button = box.findChild(QPushButton, "collectAllStatusesButton") all_button = box.findChild(QPushButton, "collectAllStatusesButton")
captured["default"] = box.defaultButton().objectName() captured["default"] = box.defaultButton().objectName()
captured["all_style"] = all_button.styleSheet() captured["all_style"] = all_button.styleSheet()
captured["minimum_width"] = box.minimumWidth()
captured["normal_button_width"] = normal_button.width()
captured["all_button_width"] = all_button.width()
captured["normal_text_width"] = normal_button.fontMetrics().horizontalAdvance(
normal_button.text()
)
captured["all_text_width"] = all_button.fontMetrics().horizontalAdvance(
all_button.text()
)
self.assertIsNotNone(normal_button) self.assertIsNotNone(normal_button)
self.assertIsNotNone(all_button) self.assertIsNotNone(all_button)
self.assertEqual("只采集状态正常的商品", normal_button.text())
self.assertEqual("采集所有状态的商品", all_button.text())
all_button.click() all_button.click()
QTimer.singleShot(0, click_all_statuses) QTimer.singleShot(0, click_all_statuses)
@@ -8374,7 +8403,35 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual("all", scope) self.assertEqual("all", scope)
self.assertEqual("collectNormalOnlyButton", captured["default"]) self.assertEqual("collectNormalOnlyButton", captured["default"])
self.assertIn("#cf222e", captured["all_style"]) self.assertGreaterEqual(captured["minimum_width"], 520)
self.assertGreaterEqual(captured["normal_button_width"], 480)
self.assertGreaterEqual(captured["all_button_width"], 480)
self.assertGreater(captured["normal_button_width"], captured["normal_text_width"])
self.assertGreater(captured["all_button_width"], captured["all_text_width"])
self.assertIn("#bc4c00", captured["all_style"])
self.assertNotIn("#cf222e", captured["all_style"])
self.assert_removed(temp_dir)
def test_collect_scope_dialog_accepts_default_and_escape_cancels(self):
with self.make_temp_dir() as temp_dir:
tab = CollectTab(config=self.make_config(temp_dir))
self.addCleanup(tab.close)
def press_return():
box = QApplication.activeModalWidget()
button = box.findChild(QPushButton, "collectNormalOnlyButton")
QTest.keyClick(button, Qt.Key_Return)
QTimer.singleShot(0, press_return)
self.assertEqual("normal_only", tab._choose_collect_scope())
def press_escape():
box = QApplication.activeModalWidget()
QTest.keyClick(box, Qt.Key_Escape)
QTimer.singleShot(0, press_escape)
self.assertIsNone(tab._choose_collect_scope())
self.assert_removed(temp_dir) self.assert_removed(temp_dir)