feat(gui): improve status scope dialogs
This commit is contained in:
+17
-22
@@ -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:
|
||||
|
||||
+24
-29
@@ -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:
|
||||
|
||||
@@ -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 '未知'}"
|
||||
|
||||
|
||||
@@ -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` 与汇总记录选择范围、四类数量、按范围排除数和计划指纹;用户日志只显示中文范围摘要。
|
||||
|
||||
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
@@ -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/<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 连接不关闭页面。
|
||||
- 采集中途登录检测必须快速跳过正在销毁的旧商品 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` 事件。
|
||||
|
||||
+2
-2
@@ -72,7 +72,7 @@
|
||||
|
||||
- 导入: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 不关闭。
|
||||
|
||||
- 回写:采集完成后自动把旧标题/旧封面路径批量回写原 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,默认不删除本地新封面文件。若范围内包含已提交线上记录,必须提示本地重置不回滚蝦皮,重生成后再更新会再次提交线上。
|
||||
|
||||
+6
-2
@@ -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`。
|
||||
|
||||
+61
-4
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user