From 7538009f94620d234914eabaf761d92d77e958ac Mon Sep 17 00:00:00 2001 From: chengma Date: Sat, 18 Jul 2026 17:23:22 +0800 Subject: [PATCH] feat(gui): improve status scope dialogs --- app/gui/tabs/collect.py | 39 +++++++-------- app/gui/tabs/generate.py | 53 +++++++++----------- app/gui/widgets.py | 104 +++++++++++++++++++++++++++++++++++++++ docs/04-architecture.md | 2 +- docs/api.md | 5 +- docs/routes.md | 4 +- docs/tasks/T-663.md | 8 ++- tests/test_gui.py | 65 ++++++++++++++++++++++-- 8 files changed, 218 insertions(+), 62 deletions(-) diff --git a/app/gui/tabs/collect.py b/app/gui/tabs/collect.py index 0d32036..b4753ca 100644 --- a/app/gui/tabs/collect.py +++ b/app/gui/tabs/collect.py @@ -750,30 +750,25 @@ class CollectTab(QWidget): thread.start() def _choose_collect_scope(self): - box = QMessageBox(self) - box.setIcon(QMessageBox.Question) - box.setWindowTitle("选择采集范围") - box.setText("请选择本轮要采集的商品范围。") - box.setInformativeText( - "程序会逐个打开商品详情页检测状态并保存结果。" - "采集所有状态商品包含未上架、审核中和状态未知商品," - "可能增加采集时间,但本步骤不消耗 AI 点数。" + box = ProductStatusScopeDialog( + title="选择采集范围", + text="请选择本轮要采集的商品范围。", + informative_text=( + "程序会逐个打开商品详情页检测状态并保存结果。" + "采集所有状态商品包含未上架、审核中和状态未知商品," + "可能增加采集时间,但本步骤不消耗 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() - if box.clickedButton() is normal_button: - return product_status.COLLECT_SCOPE_NORMAL_ONLY - if box.clickedButton() is all_button: - return product_status.COLLECT_SCOPE_ALL - return None + return box.choice() def stop_collect(self, checked=False): if self.collect_worker is not None: diff --git a/app/gui/tabs/generate.py b/app/gui/tabs/generate.py index 6ab8537..9578f0b 100644 --- a/app/gui/tabs/generate.py +++ b/app/gui/tabs/generate.py @@ -1828,37 +1828,32 @@ class GenerateTab(QWidget): total = len(plan.get("base_candidates") or []) non_normal_count = total - normal_count - box = QMessageBox(self) - box.setIcon(QMessageBox.Question) - box.setWindowTitle("选择生成范围") - box.setText("请选择本轮要生成的商品范围。") - box.setInformativeText( - "真实候选共{total}条:正常{normal},未上架{unlisted},审核中{reviewing},状态未知{unknown}。\n" - "生成所有状态的商品会让非正常状态商品也调用 AI,可能额外消耗点数。" - "状态未知商品请优先回到①重新采集确认。".format( - total=total, - normal=normal_count, - unlisted=unlisted_count, - reviewing=reviewing_count, - unknown=unknown_count, - ) + box = ProductStatusScopeDialog( + title="选择生成范围", + text="请选择本轮要生成的商品范围。", + informative_text=( + "真实候选共{total}条:正常{normal},未上架{unlisted},审核中{reviewing},状态未知{unknown}。\n" + "生成所有状态的商品会让非正常状态商品也调用 AI,可能额外消耗点数。" + "状态未知商品请优先回到①重新采集确认。".format( + total=total, + normal=normal_count, + unlisted=unlisted_count, + reviewing=reviewing_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() - if box.clickedButton() is normal_button: - return product_status.SCOPE_NORMAL_ONLY - if all_button.isEnabled() and box.clickedButton() is all_button: - return product_status.SCOPE_ALL - return None + return box.choice() def stop_generate(self, checked=False): if self.generate_worker is not None: diff --git a/app/gui/widgets.py b/app/gui/widgets.py index 97e5a70..4e3618d 100644 --- a/app/gui/widgets.py +++ b/app/gui/widgets.py @@ -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): return f"● {status or '未知'}" diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 1a39a59..b547b85 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -431,7 +431,7 @@ data/images///__new. # 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` 与汇总记录选择范围、四类数量、按范围排除数和计划指纹;用户日志只显示中文范围摘要。 1. **并发生成标题**:线程池大小 = `title_concurrency`,调 `gen_title(标题提示词, old_title)` → new_title。默认 `backend=cmhub`,调用 `POST /api/v1/generate/title` 并使用 `title_alias`;`backend=direct` 仅内部兼容时使用 `default_text_model`。 diff --git a/docs/api.md b/docs/api.md index 621cbb6..aa2ae5f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -455,6 +455,7 @@ main() -> int # 创建 QApplication + MainWindow class MainWindow(QMainWindow) # QTabWidget: ①、②、③、账号管理、设置、商品套图;支持注入 db_path/config/config_path/ai_models_path 便于测试 class CollectTab(QWidget) # ① 导入采集:导入 Excel + 汇总栏 + QTableView 任务列表 + 未匹配略过标记 class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 状态范围确认 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志 +class ProductStatusScopeDialog(QDialog) # ①②复用:纵向商品状态范围选择、中文说明、默认正常范围、警示扩展范围 class ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成任务 + 商品状态/内容预检剔除 + 检查本轮更新 + 确认后分批真实更新 + 运行日志 class SettingsTab(QWidget) # 设置:cmhub 网关配置 + 响应式三列布局 + 角色/生成参数/路径端口 + 蝦皮更新安全 + 未保存状态追踪 class ProductSuiteTab(QWidget) # 商品套图:多任务、原图、结构配置、AI帮写、cmhub生成、历史结果 @@ -520,7 +521,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负 - 任务列表使用 `QTableView + TaskTableModel`,列为:账号、别名、商品ID、阶段。 - 账号列优先显示匹配到的 `accounts.account_name`;未匹配账号时保留 Excel 输入账号名。 - 别名未匹配 `accounts.alias` 时列表阶段列显示“略过”;点击「采集旧标题/旧封面」后由 `CollectWorker` 逐条写库为 `skipped`,原因 `别名未匹配账号`。 -- 「采集旧标题/旧封面」先弹范围确认框,默认安全策略 `normal_only`,红色危险选项为 `all`,取消不创建 Worker。`CollectWorker` 只处理 `stage=imported` 的任务;采集前先做账号就绪预检。无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去账号管理,不进入逐条采集、不写 skipped/failed。预检通过后,已匹配任务调用 `editor.collect()` 先检测和保存页面状态;默认范围仅正常商品下载旧封面到 `image_dir///__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///__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 连接不关闭页面。 - 采集中途登录检测必须快速跳过正在销毁的旧商品 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` 枚举。 @@ -538,7 +539,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负 - 筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部;状态支持全部/待生成/已生成/失败/略过/已更新。 - 任务列表使用 `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()` 组件结果保留,可再次开始生成剩余任务。 - 进度标签显示标题/图片/失败;只生成标题时图片进度显示本轮未生成,标题成功即 `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` 事件。 diff --git a/docs/routes.md b/docs/routes.md index ac23010..813f5ab 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -72,7 +72,7 @@ - 导入:openpyxl 解析**输入列**(账号名/别名/商品id)入 SQLite。 - **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。 -- 采集:点击后先选择范围,默认「只采集状态正常的商品」,红色危险选项为「采集所有状态的商品」。随后为本轮匹配账号确保 Chrome 就绪(已开复用、未开启动),再检测登录;明确未登录账号的任务整组略过并汇总提示。`NO_SESSION_COOKIE`、登录检测超时或 CDP 短暂异常会重试,连续不确定时不批量略过,继续打开商品页由真实页面结果决定成功/失败。登录账号用对应 Chrome 只读打开商品页,先检测并保存商品状态;默认范围下只有正常商品才读旧标题、下载旧封面到 `data/images///__old.jpg`,未上架、审核中和状态未知商品按范围略过且不覆盖已有内容。选择全部范围时四类商品均继续采集。采集不主动把商品页切到前台;程序自动新建商品页 tab 时尽量后台创建,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。采集结束不关闭账号 Chrome,用户可自行关闭。 +- 采集:点击后先从专用范围选择框选择范围,默认「只采集状态正常的商品」,「采集所有状态的商品」为警示橙色描边而非删除红色;三个选项纵向全宽显示,常见 Windows 缩放下不截断。随后为本轮匹配账号确保 Chrome 就绪(已开复用、未开启动),再检测登录;明确未登录账号的任务整组略过并汇总提示。`NO_SESSION_COOKIE`、登录检测超时或 CDP 短暂异常会重试,连续不确定时不批量略过,继续打开商品页由真实页面结果决定成功/失败。登录账号用对应 Chrome 只读打开商品页,先检测并保存商品状态;默认范围下只有正常商品才读旧标题、下载旧封面到 `data/images///__old.jpg`,未上架、审核中和状态未知商品按范围略过且不覆盖已有内容。选择全部范围时四类商品均继续采集。采集不主动把商品页切到前台;程序自动新建商品页 tab 时尽量后台创建,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。采集结束不关闭账号 Chrome,用户可自行关闭。 - 若商品 ID 已失效、无权限或店铺不匹配,Shopee 可能只弹出短暂错误 toast;采集失败时界面日志应显示捕获到的 toast 文案,并把 toast HTML/URL 写入本地诊断日志,避免用户手动抢复制。只有明确捕获商品失效/商品不存在/无权限类 toast 时,①列表“阶段”列显示“商品失效”;其他商品页打开失败仍显示“失败”。如果失败发生在 `open_product()` 内部,本轮自动新建的商品 tab 必须关闭,复用用户已有 tab 不关闭。 - 回写:采集完成后自动把旧标题/旧封面路径批量回写原 Excel;保留「回写旧数据到 Excel」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。 @@ -106,7 +106,7 @@ - 右上:按导入批次 / 店铺 / 商品ID / 状态筛选任务;商品ID输入框按包含匹配 `item_id`,清空表示全部。 - 筛选行提供「打开图片文件夹」按钮,用于只读打开本地图片目录:选中某行时打开该商品所在账号图片文件夹(优先打开已有新/旧封面文件的真实父目录,缺失时回退到规范账号目录);未选行且选择具体批次时打开该批次图片文件夹;未选行且为全部批次时打开图片根目录。目录不存在只中文提示,不自动创建目录,不修改任务状态。 - 右下:任务列表(店铺名、商品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。 - 只生成标题时标题成功即写库并进入 `generated`,保留已有封面;只生成封面时通过组件级写库只保存 `new_cover_path`,不覆盖已有标题,也不把旧标题写入空的 `new_title`;生成标题和封面时按缺失组件增量补齐。“有新封面、无新标题”时标题状态为待生成、图片状态为已生成,后续补标题不重复生图。三种模式都写 `run_type=generate` 的 `run_logs/run_log_events` 和用户可读滚动日志;日志开头明确显示本轮生成内容。点击「开始生成」时先清空②界面可见日志并写入本轮开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`。进入页面默认可显示“本轮日志会在开始运行后显示”,历史日志不自动混入当前运行界面。「停止」取消未开始项,可再次「开始生成」对剩余继续。 - 「重置生成结果」支持选中任务或当前筛选结果,运行中禁用;确认框提供「重置标题 / 重置封面 / 重置全部」,只改本地 DB,默认不删除本地新封面文件。若范围内包含已提交线上记录,必须提示本地重置不回滚蝦皮,重生成后再更新会再次提交线上。 diff --git a/docs/tasks/T-663.md b/docs/tasks/T-663.md index 1ac8f83..69e58b7 100644 --- a/docs/tasks/T-663.md +++ b/docs/tasks/T-663.md @@ -1,7 +1,7 @@ --- id: T-663 title: 采集与生成范围确认弹窗可读性与语义色 -status: TODO +status: DONE phase: 7 deps: [T-662b, T-662c] 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`。 diff --git a/tests/test_gui.py b/tests/test_gui.py index e8b1457..6441820 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -30,6 +30,7 @@ if gui.QT_IMPORT_ERROR is not None: from PySide6.QtCore import QItemSelectionModel, QModelIndex, QRect, QSize, QTimer, Qt from PySide6.QtGui import QImage, QKeyEvent, QTextCursor +from PySide6.QtTest import QTest from PySide6.QtWidgets import ( QAbstractItemView, QApplication, @@ -6126,7 +6127,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): 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: tab = GenerateTab(config=self.make_config(temp_dir)) self.addCleanup(tab.close) @@ -6149,8 +6150,19 @@ class GuiTests(TempDirMixin, unittest.TestCase): captured["all_style"] = all_button.styleSheet() captured["all_enabled"] = all_button.isEnabled() 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(all_button) + self.assertEqual("只生成状态正常的商品", normal_button.text()) + self.assertEqual("生成所有状态的商品", all_button.text()) all_button.click() QTimer.singleShot(0, click_all_statuses) @@ -6159,7 +6171,13 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual("all", scope) self.assertEqual("generateNormalOnlyButton", captured["default"]) 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.assert_removed(temp_dir) @@ -8353,7 +8371,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): 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: tab = CollectTab(config=self.make_config(temp_dir)) self.addCleanup(tab.close) @@ -8365,8 +8383,19 @@ class GuiTests(TempDirMixin, unittest.TestCase): all_button = box.findChild(QPushButton, "collectAllStatusesButton") captured["default"] = box.defaultButton().objectName() 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(all_button) + self.assertEqual("只采集状态正常的商品", normal_button.text()) + self.assertEqual("采集所有状态的商品", all_button.text()) all_button.click() QTimer.singleShot(0, click_all_statuses) @@ -8374,7 +8403,35 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual("all", scope) 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)