T-580 精简设置页更新执行配置

This commit is contained in:
chengma
2026-07-10 10:12:15 +08:00
parent c078e68e98
commit e70e831afa
12 changed files with 259 additions and 287 deletions
+40 -8
View File
@@ -46,6 +46,8 @@ AI_CONCURRENCY_MIN = 1
AI_CONCURRENCY_MAX = 5
AI_RETRY_MIN = 0
AI_RETRY_MAX = 10
SHOPEE_PARALLEL_ACCOUNTS_MIN = 1
SHOPEE_PARALLEL_ACCOUNTS_MAX = 5
CMHUB_CONNECT_TIMEOUT_DEFAULT = 66
CMHUB_CONNECT_TIMEOUT_OLD_DEFAULT = 10
RUNTIME_CONFIG_KEYS = {
@@ -105,14 +107,10 @@ DEFAULT_CONFIG = {
},
"shopee_update": {
"test_item_id": "51100639510",
"allow_real_submit": False,
"allow_cover_update": False,
"update_mode": "",
"max_items_per_run": 1,
"close_success_tab": False,
"dry_run": False,
"parallel_accounts": False,
"max_parallel_accounts": 2,
"max_parallel_accounts": 1,
},
}
@@ -396,11 +394,30 @@ def _normalize_config_values(config, migrate_old_cmhub_connect_timeout=False):
)
update = config.get("shopee_update")
if isinstance(update, dict):
old_parallel_accounts = update.pop("parallel_accounts", None)
old_allow_cover_update = update.get("allow_cover_update", False)
update.pop("allow_real_submit", None)
update.pop("allow_cover_update", None)
update.pop("close_success_tab", None)
update["update_mode"] = normalize_update_mode(
update.get("update_mode"),
allow_cover_update=update.get("allow_cover_update", False),
allow_cover_update=old_allow_cover_update,
)
if old_parallel_accounts is False:
update["max_parallel_accounts"] = SHOPEE_PARALLEL_ACCOUNTS_MIN
else:
update["max_parallel_accounts"] = _clamp_int(
update.get("max_parallel_accounts"),
SHOPEE_PARALLEL_ACCOUNTS_MIN,
SHOPEE_PARALLEL_ACCOUNTS_MAX,
DEFAULT_CONFIG["shopee_update"]["max_parallel_accounts"],
)
update["max_items_per_run"] = _clamp_int(
update.get("max_items_per_run"),
1,
9999,
DEFAULT_CONFIG["shopee_update"]["max_items_per_run"],
)
update["allow_cover_update"] = update_mode_includes_cover(update["update_mode"])
return config
@@ -693,7 +710,22 @@ def shopee_update_config(config=None) -> dict:
merged.get("update_mode"),
allow_cover_update=merged.get("allow_cover_update", False),
)
merged["allow_cover_update"] = update_mode_includes_cover(merged["update_mode"])
if merged.get("parallel_accounts") is False:
merged["max_parallel_accounts"] = SHOPEE_PARALLEL_ACCOUNTS_MIN
else:
merged["max_parallel_accounts"] = _clamp_int(
merged.get("max_parallel_accounts"),
SHOPEE_PARALLEL_ACCOUNTS_MIN,
SHOPEE_PARALLEL_ACCOUNTS_MAX,
DEFAULT_CONFIG["shopee_update"]["max_parallel_accounts"],
)
for removed_key in (
"allow_real_submit",
"allow_cover_update",
"close_success_tab",
"parallel_accounts",
):
merged.pop(removed_key, None)
return merged
+4 -4
View File
@@ -1509,15 +1509,15 @@ def apply_task(account, task, close_success_tab=False, on_step=None, bring_to_fr
_notify_apply_step(on_step, current_step, "failed", str(exc))
return {"committed": False, "error": str(exc)}
finally:
_close_applied_product(cdp, committed=committed, close_success_tab=close_success_tab)
_close_applied_product(cdp, committed=committed)
def _close_applied_product(cdp, committed=False, close_success_tab=False):
def _close_applied_product(cdp, committed=False):
target_id = getattr(cdp, "target_id", None)
created_by_app = bool(getattr(cdp, "created_by_app", False))
host = getattr(cdp, "cdp_host", None)
should_close_tab = bool(created_by_app and target_id and (not committed or close_success_tab))
should_wait_before_close = bool(committed and close_success_tab)
should_close_tab = bool(created_by_app and target_id)
should_wait_before_close = bool(committed)
try:
cdp.close()
finally:
+8 -37
View File
@@ -259,11 +259,6 @@ class ApplyTab(QWidget):
self._set_status(content_error.replace("\n", " "))
return
dry_run = bool(dry_run)
safety_error = self._update_safety_error(tasks, dry_run=dry_run)
if safety_error:
self._show_update_safety_error(safety_error)
self._set_status(safety_error.replace("\n", " "))
return
answer = QMessageBox.question(
self,
"确认检查本轮更新" if dry_run else "确认开始更新",
@@ -279,10 +274,8 @@ class ApplyTab(QWidget):
tasks,
db_path=self.db_path,
config=self.config,
close_success_tab=bool(update_cfg.get("close_success_tab", False)),
dry_run=dry_run,
update_mode=update_mode,
parallel_accounts=bool(update_cfg.get("parallel_accounts", False)),
max_parallel_accounts=max(
1,
int(update_cfg.get("max_parallel_accounts", 1) or 1),
@@ -492,12 +485,15 @@ class ApplyTab(QWidget):
update_mode = self._current_update_mode()
update_mode_text = self._update_mode_label(update_mode)
cover_warning = "\n本轮将更新线上封面,请确认封面内容无误。" if appconfig.update_mode_includes_cover(update_mode) else ""
close_text = "是" if update_cfg.get("close_success_tab") else "否"
batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
batch_count = (len(tasks) + batch_size - 1) // batch_size if tasks else 0
max_parallel_accounts = max(
1,
int(update_cfg.get("max_parallel_accounts", 1) or 1),
)
parallel_text = (
f"开启,最多 {update_cfg.get('max_parallel_accounts', 1)} 个账号"
if update_cfg.get("parallel_accounts")
f"最多 {max_parallel_accounts} 个账号"
if max_parallel_accounts > 1
else "关闭"
)
intro = (
@@ -514,9 +510,9 @@ class ApplyTab(QWidget):
+ f"更新内容:{update_mode_text}\n"
+ f"任务数:{len(tasks)}\n"
+ f"预计批次:{batch_count}\n\n"
+ "安全设置:"
+ "执行设置:"
+ f"每批最大更新条数={batch_size},"
+ f"成功后关闭新页={close_text},"
+ "成功后自动关闭程序新开编辑页,"
+ f"多账号并行={parallel_text}\n\n"
+ cover_warning
+ ("\n\n" if cover_warning else "")
@@ -527,30 +523,6 @@ class ApplyTab(QWidget):
)
)
def _update_safety_error(self, tasks, dry_run=False):
update_cfg = self._shopee_update_config()
if dry_run:
return None
if not update_cfg.get("allow_real_submit", False):
return (
"设置未开启「允许真实提交线上商品」,已阻止本次更新。\n"
"请到⑤设置 > 蝦皮更新安全开启该开关后再开始更新。"
)
return None
def _show_update_safety_error(self, message):
box = QMessageBox(self)
box.setIcon(QMessageBox.Warning)
box.setWindowTitle("更新安全开关")
box.setText(str(message))
settings_button = None
if self.open_settings_callback is not None:
settings_button = box.addButton("前往设置", QMessageBox.ActionRole)
box.addButton(QMessageBox.Ok)
box.exec()
if settings_button is not None and box.clickedButton() is settings_button:
self.open_settings_callback()
def _shopee_update_config(self):
return appconfig.shopee_update_config(self.config)
@@ -584,7 +556,6 @@ class ApplyTab(QWidget):
update_mode = self._current_update_mode()
update_cfg = self._shopee_update_config()
update_cfg["update_mode"] = update_mode
update_cfg["allow_cover_update"] = appconfig.update_mode_includes_cover(update_mode)
payload = {
key: value
for key, value in self.config.items()
+15 -50
View File
@@ -161,9 +161,6 @@ class SettingsTab(QWidget):
for resolution in self.RESOLUTION_ITEMS:
self.resolution_combo.addItem(resolution, resolution)
self.response_timeout_label = QLabel("")
self.jpg_quality_spin = QSpinBox()
self.jpg_quality_spin.setObjectName("jpgQualitySpin")
self.jpg_quality_spin.setRange(1, 100)
self.chrome_path_edit = QLineEdit()
self.chrome_path_edit.setObjectName("chromePathEdit")
self.chrome_path_browse_button = QPushButton("选择...")
@@ -204,32 +201,17 @@ class SettingsTab(QWidget):
self.unsaved_changes_label.setObjectName("settingsUnsavedChangesLabel")
self.unsaved_changes_label.setStyleSheet("color: #bc4c00; font-weight: 600;")
self.unsaved_changes_label.setVisible(False)
self.allow_real_submit_checkbox = QCheckBox("允许真实提交线上商品")
self.allow_real_submit_checkbox.setObjectName("allowRealSubmitCheckbox")
self.allow_cover_update_checkbox = QCheckBox("允许更新封面")
self.allow_cover_update_checkbox.setObjectName("allowCoverUpdateCheckbox")
self.allow_cover_update_checkbox.setVisible(False)
self.max_items_per_run_spin = QSpinBox()
self.max_items_per_run_spin.setObjectName("maxItemsPerRunSpin")
self.max_items_per_run_spin.setRange(1, 9999)
self.max_items_per_run_spin.setToolTip("作为每批最大更新条数;正式更新会分批处理当前筛选全部可更新记录。")
self.close_success_tab_checkbox = QCheckBox("成功后关闭本次新开编辑页")
self.close_success_tab_checkbox.setObjectName("closeSuccessTabCheckbox")
self.parallel_accounts_checkbox = QCheckBox("多账号并行更新")
self.parallel_accounts_checkbox.setObjectName("parallelAccountsCheckbox")
self.max_parallel_accounts_spin = QSpinBox()
self.max_parallel_accounts_spin.setObjectName("maxParallelAccountsSpin")
self.max_parallel_accounts_spin.setRange(1, 16)
self.max_parallel_accounts_label = QLabel("最大并行账号数")
self.parallel_accounts_group = QWidget()
self.parallel_accounts_group.setObjectName("parallelAccountsGroup")
parallel_accounts_layout = QHBoxLayout(self.parallel_accounts_group)
parallel_accounts_layout.setContentsMargins(0, 0, 0, 0)
parallel_accounts_layout.setSpacing(12)
parallel_accounts_layout.addWidget(self.parallel_accounts_checkbox)
parallel_accounts_layout.addWidget(self.max_parallel_accounts_label)
parallel_accounts_layout.addWidget(self.max_parallel_accounts_spin)
parallel_accounts_layout.addStretch(1)
self.max_parallel_accounts_spin.setRange(
appconfig.SHOPEE_PARALLEL_ACCOUNTS_MIN,
appconfig.SHOPEE_PARALLEL_ACCOUNTS_MAX,
)
self.max_parallel_accounts_spin.setToolTip("1=逐个更新,不并行;大于1时按账号并行更新,最多同时5个账号。")
model_picker_layout = QHBoxLayout()
model_picker_layout.addWidget(self.model_combo, 1)
@@ -268,7 +250,6 @@ class SettingsTab(QWidget):
("失败重试次数", self.retry_spin),
("分辨率", self.resolution_combo),
("返回超时", self.response_timeout_label),
("jpg质量", self.jpg_quality_spin),
]
)
@@ -285,7 +266,7 @@ class SettingsTab(QWidget):
("Chrome路径", self.chrome_path_widget, True),
("默认调试端口", self.default_debug_port_spin),
("调试端口范围", port_range_widget),
("CDP就绪超时(秒)", self.cdp_ready_timeout_spin),
("Chrome 就绪超时(秒)", self.cdp_ready_timeout_spin),
]
)
self.infrastructure_form_layout = path_form
@@ -293,9 +274,7 @@ class SettingsTab(QWidget):
self.shopee_update_form_layout = self._three_column_form(
[
("每批最大更新条数", self.max_items_per_run_spin),
("", self.allow_real_submit_checkbox),
("", self.close_success_tab_checkbox),
("", self.parallel_accounts_group, 2),
("同时更新蝦皮账号", self.max_parallel_accounts_spin),
]
)
@@ -357,7 +336,7 @@ class SettingsTab(QWidget):
"settingsGenerationSectionTitle",
)
self.shopee_update_section_title = self._section_title(
"蝦皮更新安全 / 执行模式",
"蝦皮更新执行",
"settingsShopeeUpdateSectionTitle",
)
self.infrastructure_section_title = self._section_title(
@@ -507,7 +486,6 @@ class SettingsTab(QWidget):
self.title_concurrency_spin,
self.image_concurrency_spin,
self.retry_spin,
self.jpg_quality_spin,
self.default_debug_port_spin,
self.debug_port_start_spin,
self.debug_port_end_spin,
@@ -518,9 +496,6 @@ class SettingsTab(QWidget):
checkboxes = (
self.cmhub_check_balance_checkbox,
self.enabled_checkbox,
self.allow_real_submit_checkbox,
self.close_success_tab_checkbox,
self.parallel_accounts_checkbox,
)
for widget in line_edits:
widget.textChanged.connect(self._mark_dirty)
@@ -768,7 +743,6 @@ class SettingsTab(QWidget):
"title_concurrency": self.title_concurrency_spin.value(),
"image_concurrency": self.image_concurrency_spin.value(),
"retry": self.retry_spin.value(),
"jpg_quality": self.jpg_quality_spin.value(),
"resolution": self.resolution_combo.currentData() or "1k",
"resolution_timeouts": dict(ai_cfg.get("resolution_timeouts", {})),
}
@@ -802,13 +776,9 @@ class SettingsTab(QWidget):
"ai": ai_cfg,
"shopee_update": {
"test_item_id": str(self._compat_test_item_id or ""),
"allow_real_submit": self.allow_real_submit_checkbox.isChecked(),
"allow_cover_update": appconfig.update_mode_includes_cover(update_mode),
"update_mode": update_mode,
"max_items_per_run": self.max_items_per_run_spin.value(),
"close_success_tab": self.close_success_tab_checkbox.isChecked(),
"dry_run": False,
"parallel_accounts": self.parallel_accounts_checkbox.isChecked(),
"max_parallel_accounts": self.max_parallel_accounts_spin.value(),
},
}
@@ -899,7 +869,6 @@ class SettingsTab(QWidget):
self.resolution_combo,
str(ai_cfg.get("resolution", "1k")),
)
self.jpg_quality_spin.setValue(int(ai_cfg.get("jpg_quality", 90) or 90))
self.chrome_path_edit.setText(appconfig.chrome_path(self.config))
self.user_data_root_edit.setText(
str(self.config.get("user_data_root", "chrome_user_data_dir") or "")
@@ -917,21 +886,17 @@ class SettingsTab(QWidget):
)
update_cfg = self._shopee_update_config()
self._compat_test_item_id = str(update_cfg.get("test_item_id", ""))
self.allow_real_submit_checkbox.setChecked(
bool(update_cfg.get("allow_real_submit", False))
)
self.allow_cover_update_checkbox.setChecked(bool(update_cfg.get("allow_cover_update", False)))
self.max_items_per_run_spin.setValue(
max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
)
self.close_success_tab_checkbox.setChecked(
bool(update_cfg.get("close_success_tab", False))
)
self.parallel_accounts_checkbox.setChecked(
bool(update_cfg.get("parallel_accounts", False))
)
self.max_parallel_accounts_spin.setValue(
max(1, int(update_cfg.get("max_parallel_accounts", 2) or 2))
max(
appconfig.SHOPEE_PARALLEL_ACCOUNTS_MIN,
min(
appconfig.SHOPEE_PARALLEL_ACCOUNTS_MAX,
int(update_cfg.get("max_parallel_accounts", 1) or 1),
),
)
)
self._update_response_timeout_label()
self._on_backend_changed()
+8 -11
View File
@@ -573,10 +573,8 @@ class ApplyWorker(BaseWorker):
db_path=None,
config=None,
preflight=True,
close_success_tab=False,
dry_run=False,
update_mode=None,
parallel_accounts=False,
max_parallel_accounts=1,
batch_size=None,
diagnostic_log_dir=None,
@@ -586,14 +584,15 @@ class ApplyWorker(BaseWorker):
self.db_path = db_path
self.config = config
self.preflight = preflight
self.close_success_tab = close_success_tab
self.dry_run = bool(dry_run)
self.update_mode = appconfig.normalize_update_mode(
update_mode,
allow_cover_update=appconfig.shopee_update_config(config).get("allow_cover_update", False),
)
self.parallel_accounts = bool(parallel_accounts)
self.max_parallel_accounts = max(1, int(max_parallel_accounts or 1))
self.max_parallel_accounts = max(
appconfig.SHOPEE_PARALLEL_ACCOUNTS_MIN,
min(appconfig.SHOPEE_PARALLEL_ACCOUNTS_MAX, int(max_parallel_accounts or 1)),
)
self.batch_size = None if batch_size is None else max(1, int(batch_size or 1))
self._current_batch_size = None
self._batch_count = 0
@@ -633,7 +632,7 @@ class ApplyWorker(BaseWorker):
batch_count=len(batches),
parallel=(
f"多账号并行最多{self.max_parallel_accounts}"
if self.parallel_accounts
if self.max_parallel_accounts > 1
else "串行"
),
)
@@ -671,7 +670,7 @@ class ApplyWorker(BaseWorker):
break
outcome = self._preview_task(task, account_by_alias)
self._record_outcome(counters, total, outcome)
elif self.parallel_accounts and self.max_parallel_accounts > 1:
elif self.max_parallel_accounts > 1:
self._run_parallel_by_account(batch_tasks, account_by_alias, counters, total)
else:
for task in batch_tasks:
@@ -936,7 +935,6 @@ class ApplyWorker(BaseWorker):
result = editor.apply_task(
account,
task,
close_success_tab=self.close_success_tab,
on_step=on_step,
bring_to_front=bring_to_front,
update_mode=self.update_mode,
@@ -1103,7 +1101,7 @@ class ApplyWorker(BaseWorker):
"failed": counters["failed"],
"batch_ids": batch_ids,
"dry_run": self.dry_run,
"parallel_accounts": self.parallel_accounts,
"account_parallel": self.max_parallel_accounts > 1,
"batch_size": self._current_batch_size,
"batch_count": self._batch_count,
"update_mode": self.update_mode,
@@ -1123,9 +1121,8 @@ class ApplyWorker(BaseWorker):
total=len(eligible),
options={
"batch_ids": batch_ids,
"close_success_tab": self.close_success_tab,
"dry_run": self.dry_run,
"parallel_accounts": self.parallel_accounts,
"account_parallel": self.max_parallel_accounts > 1,
"max_parallel_accounts": self.max_parallel_accounts,
"batch_size": self._current_batch_size,
"batch_count": self._batch_count,
+10 -18
View File
@@ -126,14 +126,10 @@ T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源
},
"shopee_update": {
"test_item_id": "51100639510",
"allow_real_submit": false,
"allow_cover_update": false,
"update_mode": "title",
"max_items_per_run": 1,
"close_success_tab": false,
"dry_run": false,
"parallel_accounts": false,
"max_parallel_accounts": 2
"max_parallel_accounts": 1
}
}
```
@@ -150,19 +146,15 @@ T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源
- direct 内部兼容模式模型本身的定义(url/key/类型/连接超时…)在 `data/config/ai_models.json`,见 5.1b。
- 密钥不在 `config.json`:cmhub API Key 存于 `data/config/cmhub.json`;direct 内部兼容模式每个模型的 `api_key` 存于 `data/config/ai_models.json`。两者均本地明文保存、保存/变更时弹窗提示、UI 打码、gitignore、不入日志/导出。
`shopee_update` 段放**真实更新前的安全开关**:
`shopee_update` 段放**③ 更新蝦皮执行参数**:
- `test_item_id`:历史/调试兼容字段;默认 `51100639510`。普通正式更新不再以该字段限制商品 ID,也不因当前筛选结果包含非测试商品而阻断;后续如需要调试模式,可单独启用测试商品限制。
- `allow_real_submit`:是否允许 ③ 创建 `ApplyWorker` 并点击「更新」提交线上;默认 `false`,未开启时 ③ 在确认弹窗前阻断。
- `update_mode`:③「更新内容」下拉的主字段,取值 `title` / `cover` / `title_cover`,分别表示只更新标题、只更新封面、更新标题和封面;默认 `title`。
- `allow_cover_update`:旧兼容字段;保存配置时仍写回,值由 `update_mode` 是否包含封面推导。⑤设置页不再展示「允许更新封面」,封面是否参与本轮真实更新由③左下角「更新内容」下拉决定。
- `max_items_per_run`:每批最大更新任务数;默认 `1`,当前筛选结果超过该值时自动分批,不再按总数阻断。
- `close_success_tab`:成功提交后是否关闭本轮程序自动新开的商品编辑页;默认 `false`。成功提交时只在该开关开启且 tab 为 `open_product()` 本轮新建时关闭,并在 Shopee 确认成功跳回商品列表页后等待 2 秒再关。更新失败时不受该开关控制:若 tab 是本轮程序自动新建则直接关闭,复用用户原本打开的 tab 只断开 CDP、不关闭页面。
- `dry_run`:内部兼容字段;普通用户界面不展示该开关,③「检查本轮更新」按钮触发检查模式,只写运行日志,不打开 Shopee、不点击「更新」、不改任务状态;默认 `false`。
- `parallel_accounts`:是否按账号并行执行③真实更新;默认 `false`,即保持串行。
- `max_parallel_accounts`:最多同时执行的账号数;同一账号内仍按任务串行,默认 `2`。
- `max_parallel_accounts`:最多同时执行的账号数,范围 `1..5`;`1` 表示逐个账号串行,`>=2` 表示按账号分组并行,同一账号内仍按任务串行;默认 `1`。旧配置中的并行布尔开关为关闭时会迁移为 `max_parallel_accounts=1`,开启时会保留旧数量并夹紧到 `1..5`,保存后不再写回旧并行布尔字段。
该段不是替代 ③ 确认弹窗的常驻授权;③ 仍必须弹窗确认,用户点是后才执行。`dry_run=true` 时不会真实提交;`dry_run=false` 时仍必须先通过真实更新安全开关检查。普通正式更新的安全检查不读取 `test_item_id` 做阻断。
该段不是替代 ③ 确认弹窗的常驻授权;③「开始更新」仍必须弹窗确认,用户点是后才执行。`dry_run=true` 时不会真实提交;`dry_run=false` 时以③确认弹窗作为线上提交前的唯一显式确认边界。普通正式更新不读取 `test_item_id` 做阻断。旧配置中的真实提交、封面更新、成功关页等开关只作迁移兼容读取,保存后不再写回。
### 5.1b AI 模型清单 `data/config/ai_models.json`
@@ -434,20 +426,20 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
- ③ 顶部筛选确定本次作用范围;点击「开始更新」后弹窗展示筛选条件、任务数量和“将提交线上”的风险提示。
- ③ 左下角提供「更新内容」下拉:`只更新标题` / `只更新封面` / `更新标题和封面`。点击「开始更新」或「检查本轮更新」时,先按当前模式检查当前筛选任务是否已经具备 `new_title` / `new_cover_path`;缺少所选内容时直接中文弹窗阻断,不创建 `ApplyWorker`、不打开 Chrome、不写失败状态。
- ③ 提供「检查本轮更新」按钮:只读取当前筛选结果和写运行日志,不打开 Shopee、不提交、不改任务状态;检查汇总展示总数、店铺分布、每批最大条数、预计批次数、更新内容和略过原因。
- 弹确认前先读取 `data/config.json` 的 `shopee_update`:未开启 `allow_real_submit` 时,直接弹警告阻断,不创建真实更新 `ApplyWorker`。普通正式更新不再检查 `test_item_id`,当前筛选结果可以包含多个真实商品 ID。`max_items_per_run` 作为每批最大任务数,当前筛选总数超过该值时自动分批,不再按总数阻断。
- 用户点「是/确认」才开始批量更新;点「否/取消」不执行、不改库。
- 弹确认前先读取 `data/config.json` 的 `shopee_update` 执行参数。普通正式更新不再检查 `test_item_id` 或旧真实提交开关,当前筛选结果可以包含多个真实商品 ID。`max_items_per_run` 作为每批最大任务数,当前筛选总数超过该值时自动分批,不再按总数阻断。
- 用户在③确认弹窗点「是/确认」才开始批量更新;点「否/取消」不执行、不改库。
- 真实更新前必须做账号就绪预检:按当前筛选结果汇总需要的账号;无账号、账号 Chrome 未启动、CDP 端口不可访问、未登录或本轮账号端口冲突时,整体返回 `blocked` 并由 GUI 弹窗列出账号/原因、引导去④账号管理。预检不通过时不创建商品编辑页、不调用 `editor.apply_task()`、不写失败状态、不自动调用「启动登录」或静默打开 Chrome。
- 对确认后的**已生成(generated)任务**:按③「更新内容」模式执行 `open_product` → `change_title(new_title)`(选择标题时)→ `replace_cover(new_cover_path)`(选择封面时)→ `click_update` 提交。选择 `只更新标题` 时即使任务有 `new_cover_path` 也不会替换封面;选择 `只更新封面` 时即使任务有 `new_title` 也不会改标题。
- `open_product` 打开商品详情页失败时,要把页面 toast 中的错误原因上浮到③运行日志和任务失败原因;商品 ID 失效、无权限或店铺不匹配时应能看到 Shopee 原始提示,而不是只看到等待详情页超时;如果此时 tab 是本轮自动新建的,`open_product` 要负责关闭该失败 tab。
- `click_update` 点击页面「更新」后必须处理 Shopee 站点侧二次确认框。2026-06-29 真实测试商品实测:页面会出现 `.eds-modal__content` / `.eds-modal__box`,标题为 `確定您要更新商品嗎?`,正文提示建议优化,底部两个按钮分别是 `立即優化` 与主按钮 `更新`。实现时只允许在标题匹配该确认框、且按钮位于可见 modal footer 内时点击 `button.eds-button--primary` / 文案 `更新`;不得点击 `立即優化`。如果弹窗出现但未成功点击主按钮,当前任务必须视为未提交失败,不得写 `committed=1`。
- 更新封面时,`replace_cover()` 必须按“替换第一张”语义执行:无论当前商品图片是 8 张还是 9 张,只要本次有新封面,就先确认该任务已有本地旧封面备份(`old_cover_path` 非空且文件存在),再删除当前线上第一张图、等待图片管理器稳定、上传新图并拖到第一位。缺失备份时不删除线上第一张图,直接返回明确错误,要求先回到①采集旧封面或修复本地备份。
- 默认串行、单条失败继续;⑤ 开启 `parallel_accounts` 后按账号分组并行,不同账号可同时跑,同一账号内仍串行。真实更新前检查本轮账号 `debug_port`,端口冲突直接阻断。
- 默认串行、单条失败继续;⑤「同时更新蝦皮账号」设为 `1` 时逐个账号执行,设为 `2..5` 时按账号分组并行,不同账号可同时跑,同一账号内仍串行。真实更新前检查本轮账号 `debug_port`,端口冲突直接阻断。
- 「检查本轮更新」只写 `run_logs/run_log_events` 和弹窗/状态栏检查汇总,不调用 `editor.apply_task()`,不做账号登录预检,不写任务状态,不回写 Excel。
- 真实更新按每批最大条数分批执行,每条立即写 SQLite;全部完成回写 Excel(新字段+状态)+ 弹窗汇总。真实更新与检查都写运行日志,日志 payload 走脱敏工具。
- 分批更新停止语义为协作式停止:点击停止后设置取消标记;当前正在执行的商品跑到安全边界后写库结束,不再开始新商品,也不进入下一批。未开始任务保持原状态,后续可继续。
- T-404a 已在③提供「重置更新状态」:仅当前选中单条,保留 `new_title/new_cover_path`,本地退回 `stage=generated/status=pending` 以便重复测试上传/提交;若 `committed=1`,必须提示线上已提交过、本地重置不回滚蝦皮、重复更新会再次提交,并保留 committed 历史事实/运行日志。
- 若商品页是本轮程序自动新建且更新失败,`apply_task()` 结束时关闭该商品编辑页;复用用户已有 tab 时只断开 CDP,不关闭页面。若已成功提交,则仅在 `close_success_tab=true` 时关闭本轮程序自动新建页;确认后已跳回商品列表页时,关闭前等待 2 秒。`open_product` 内部打开失败的新建 tab 仍由 `open_product` 自行关闭。
- 若商品页是本轮程序自动新建,`apply_task()` 结束时成功/失败都关闭该商品编辑页;成功提交后关闭前等待 2 秒,便于 Shopee 成功状态渲染。复用用户已有 tab 时只断开 CDP,不关闭页面。`open_product` 内部打开失败的新建 tab 仍由 `open_product` 自行关闭。
### 6.4 登录检测
@@ -461,7 +453,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
| Chrome 启动参数 | 全关后带 `--remote-debugging-port=<port> --remote-allow-origins=* --user-data-dir=<dir>`;缺 allow-origins 则 WebSocket 403 |
| 代理干扰 | 清除 `*_proxy`(requests `trust_env=False`),否则连本地 CDP 超时 |
| WebSocket Origin | `websocket-client` `suppress_origin=True` |
| 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。采集只关闭本轮自动新建的商品页,复用的用户已有 tab 不关闭;③ 更新失败时关闭本轮自动新建的商品页、复用页不关闭;③ 成功提交时仅在设置 `close_success_tab=true` 且 tab 为本轮自动新建时关闭,确认成功跳回商品列表页时关闭前等待 2 秒 |
| 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。采集只关闭本轮自动新建的商品页,复用的用户已有 tab 不关闭;③ 更新时程序自动新建的商品页成功/失败都关闭,成功提交且确认跳回商品列表页时关闭前等待 2 秒;③ 复用用户已有商品页时不关闭页面 |
| 前台激活 | ①采集只读打开商品页时不主动 `Page.bringToFront`;新建 tab 尝试 `Target.createTarget(background=true)`,不支持时退回普通新建。③更新真实提交仍需要 UI 交互稳定性,但为降低抢焦点,本轮每账号只在首条任务主动前台一次;后台态封面上传/拖拽遇到疑似遮挡节流失败时,再提前台做一次非破坏性安全恢复,不完整重跑删图上传流程 |
| SPA 就绪 | 不用 load 事件;轮询“标题输入框 + 图片 itembox + 上传输入框”三者都在 |
| 商品页错误 toast | Shopee 错误提示使用 `.eds-toasts` / `.eds-toast__content`,可能很快隐藏或 `display:none`。打开商品页/等待 SPA 就绪前应注入 `MutationObserver` 或等价监听,把 toast 文本、`outerHTML`、当前 URL、时间、可见状态保存到页面缓存(如 `window.__cmshopee_toasts`);等待详情页关键元素超时时,再兜底读取当前 DOM 中的 toast。最近错误 toast 应优先成为 `open_product` 失败原因,并写入 DB 运行日志和本地脱敏诊断日志。只有明确商品失效/不存在/无权限类 toast 才驱动①阶段列显示“商品失效”;网络、CDP、未登录、页面超时、风控等其他失败仍显示“失败” |
@@ -476,7 +468,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
| 更新按钮 | 页面主更新按钮为 `button.eds-button` 中 `<span>更新</span>`;③ 批量确认后逐条点提交;禁用态(校验未过)记为失败。2026-06-29 实测点击后会弹 Shopee 站点侧确认框:可见 `.eds-modal__content` / `.eds-modal__box`,标题 `確定您要更新商品嗎?`,footer 中 `立即優化` 为次按钮,`更新` 为 `eds-button--primary` 主按钮;代码必须点击确认框内主按钮 `更新` 才算提交,不点 `立即優化`。2026-06-30 实测确认成功后会跳回 `https://seller.shopee.tw/portal/product/list/all?operationSortBy=modified_time` 商品列表页,代码需记录 `post_update.url/redirected_to_list` 作为提交后观测结果;若随后要关闭本轮自动新开 tab,必须先暂停 2 秒再关闭。判断优先级:跳转到 `/portal/product/list/` 是强成功信号,应优先于残留/短暂 error toast;只有在未跳转列表页、无成功 toast,且错误 toast 持续存在时,才判 `POST_UPDATE_ERROR`。未处理确认框时不得认为已提交 |
| 登录检测 | 重定向到登录页(含 `accounts.shopee.tw/seller/login`)或缺 `SPC_ST`/`SPC_U` → 未登录 |
高风险动作(删除线上封面、点更新提交、AI 图上线)先在测试商品验证。删除线上第一张封面前必须已有本地旧封面备份,不能在备份缺失时盲删线上图片。注意:本设计无逐条人工审核阶段、无常驻提交开关;③ 点击「开始更新」后必须先通过 `shopee_update` 安全开关,再弹窗确认,确认后才把当前筛选结果中的 AI 标题/封面提交线上。新图本地留档+回写 Excel 是事后追溯手段。
高风险动作(删除线上封面、点更新提交、AI 图上线)先在测试商品验证。删除线上第一张封面前必须已有本地旧封面备份,不能在备份缺失时盲删线上图片。注意:本设计无逐条人工审核阶段、无常驻提交开关;③ 点击「开始更新」后必须弹窗确认,确认后才把当前筛选结果中的 AI 标题/封面提交线上。新图本地留档+回写 Excel 是事后追溯手段。
## 八、推荐开发顺序
+13 -13
View File
@@ -39,7 +39,7 @@ cmhub_request_url(base_url, endpoint) -> str # 先规整 base_url,再拼 /ap
response_timeout(config=None) -> int # = resolution_timeouts[resolution](返回超时,随分辨率)
```
`default_config()` / `load_config()` 包含 `shopee_update` 安全配置段:历史/调试兼容测试商品 ID、是否允许真实提交、更新内容模式 `update_mode`、旧兼容 `allow_cover_update`、每批最大更新条数、成功后是否关闭本轮新开编辑页、内部兼容 `dry_run`、多账号并行、最大并行账号数。普通正式更新不再用测试商品 ID 阻断当前筛选结果;封面是否参与本轮更新由③「更新内容」下拉决定。`config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `*_token` / `password` / `*_password` 等敏感字段时抛 `ConfigError`。普通产品默认 cmhub,AI Key 存 `data/config/cmhub.json`;`data/config/ai_models.json` 仅为 direct 内部兼容路径。T-538 后,配置中默认仍保存 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时解析到 `data/` 下,保持免安装目录可移动。
`default_config()` / `load_config()` 包含 `shopee_update` 执行配置段:历史/调试兼容测试商品 ID、更新内容模式 `update_mode`、每批最大更新条数、内部兼容 `dry_run`、同时更新蝦皮账号数 `max_parallel_accounts`。普通正式更新不再用测试商品 ID 或旧真实提交开关阻断当前筛选结果;封面是否参与本轮更新由③「更新内容」下拉决定;线上提交前的显式确认边界是③「开始更新」确认弹窗。`config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `*_token` / `password` / `*_password` 等敏感字段时抛 `ConfigError`。普通产品默认 cmhub,AI Key 存 `data/config/cmhub.json`;`data/config/ai_models.json` 仅为 direct 内部兼容路径。T-538 后,配置中默认仍保存 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时解析到 `data/` 下,保持免安装目录可移动。
敏感信息展示/日志辅助:
@@ -269,8 +269,8 @@ replace_cover(cdp, image_win_path, old_cover_path=None) -> dict
# 更新封面统一先确认 old_cover_path 非空且文件存在,再删除当前第一张、上传、等 Shopee CDN 地址、拖第一位;失败返回 upload_state 诊断;备份缺失时返回 OLD_COVER_BACKUP_MISSING,不删除线上图片
click_update(cdp, confirm_timeout=3) -> dict # {clicked, reason, toasts, confirm?};禁用或 Shopee 二次确认未完成则记失败
apply_task(account, task, close_success_tab=False) -> dict
# 对已生成任务:换标题+换封面+点页面「更新」;若出现 Shopee 确认框,只点弹窗主按钮「更新」,不点「立即優化」(调用前必须已通过安全开关和批量确认)
# 失败时关闭本轮自动新开的商品编辑页,复用用户已有页不关闭;成功提交时仅 close_success_tab=True 且商品页为本轮自动新开才关闭,确认后跳回商品列表页时关闭前等待 2 秒
# 对已生成任务:换标题+换封面+点页面「更新」;若出现 Shopee 确认框,只点弹窗主按钮「更新」,不点「立即優化」(调用前必须已经由③确认弹窗确认本轮真实更新)
# 程序自动新开的商品编辑页成功/失败都关闭,成功提交后关闭前等待 2 秒;复用用户已有页不关闭。close_success_tab 为旧调用兼容参数,不再控制当前行为
# -> {committed, error}
```
@@ -283,7 +283,7 @@ apply_task(account, task, close_success_tab=False) -> dict
- `open_product()` 进入/刷新商品编辑页后要安装 toast 监听;若标题输入框、图片管理器、上传入口等关键元素等待超时,或页面明显不是商品编辑页,应读取最近 `.eds-toast__content`。如果存在错误 toast,例如 `please input correct product id`,返回/抛出的错误信息必须包含该文案,并把 toast 文本、`outerHTML`、URL、时间、可见状态交给上层运行日志/诊断日志;不得记录 Cookie、密码、token。调用方只在明确商品失效/商品不存在/无权限类 toast 时写 `last_error=商品失效:<原始toast>`,数据库 `stage/status` 仍使用既有流程值。若失败发生在 `open_product()` 返回 `cdp` 前,`open_product()` 自己负责清理:自动新建 tab 断开 CDP 后关闭浏览器 target,复用用户已有 tab 只断开 CDP。
- `collect()` 结束时只关闭本轮自动新建的商品编辑页 tab;如果 `open_product()` 尚未返回就失败,也由 `open_product()` 关闭本轮自动新建 tab;用户原本打开的商品 tab 不关闭。
- ③ 更新流程失败时关闭本轮自动新建的商品编辑页,复用用户原本打开的 tab 只断开 CDP、不关闭页面;`open_product()` 内部打开失败的新建 tab 仍由 `open_product()` 自行关闭。成功提交时若 `close_success_tab=True`,只在商品页为本轮自动新开时关闭。Shopee 确认成功后可能把当前 tab 跳回 `/portal/product/list/all?operationSortBy=modified_time`,`click_update()` 会把该 URL 记录到 `post_update.url` 并标记 `redirected_to_list=true`;若本次成功路径会关闭该自动新开 tab,关闭前等待 2 秒。
- ③ 更新流程中程序自动新建的商品编辑页成功/失败都关闭,复用用户原本打开的 tab 只断开 CDP、不关闭页面;`open_product()` 内部打开失败的新建 tab 仍由 `open_product()` 自行关闭。Shopee 确认成功后可能把当前 tab 跳回 `/portal/product/list/all?operationSortBy=modified_time`,`click_update()` 会把该 URL 记录到 `post_update.url` 并标记 `redirected_to_list=true`;自动新建页成功关闭前等待 2 秒。
- `click_update()` 的提交成功定义:页面主「更新」按钮已点击,且 Shopee 站点侧确认框未出现或已在可见 `.eds-modal__content` / `.eds-modal__box` 内点击主按钮「更新」。如果确认框仍停留、只点到页面主按钮、或误入「立即優化」,必须返回失败;若 tab 是本轮自动新建,失败后由 `apply_task()` 关闭该 tab。
- T-404/T-502 封面更新删除前,`apply_task()` 应把任务的 `old_cover_path` 传给 `replace_cover()`;`replace_cover()` 只有在本地旧封面备份存在时才允许进入删第一张流程。更新封面统一先删当前第一张,不再只在满 9 张时删除;8 张商品图也按替换语义先删再上传。
- T-404 封面上传稳定性:`replace_cover()` 上传前必须模拟人工路径,先点击 `.shopee-image-manager__upload` 上传块,短暂等待并重新获取最新 `input[type=file]` 后,再用 CDP `DOM.setFileInputFiles` 注入本地图片并派发 `input`/`change`。该策略用于处理手动上传成功但直接注入文件后 Shopee 前端一直转圈、迟迟不生成 `susercontent` CDN 地址的场景。`有1張重複的圖片` / `重複` / `重复` / `duplicate` 属于封面上传错误,必须立即返回明确失败,不继续等超时。
@@ -362,11 +362,11 @@ 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 ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成任务 + 检查本轮更新 + 安全开关拦截 + 确认后分批真实更新 + 运行日志
class ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成任务 + 检查本轮更新 + 缺失内容校验 + 确认后分批真实更新 + 运行日志
class SettingsTab(QWidget) # ⑤ 设置:cmhub 网关配置 + 响应式三列布局 + 角色/生成参数/路径端口 + 蝦皮更新安全 + 未保存状态追踪
class CollectWorker(BaseWorker) # ① 后台采集:账号就绪预检 -> editor.collect -> db.set_collected/mark_skipped/mark_failed
class GenerateWorker(BaseWorker) # ② 后台生成:ai.generate_batch -> db.set_generated/mark_failed + 进度
class ApplyWorker(BaseWorker) # ③ 后台更新:账号就绪预检 -> 检查或按批调用 editor.apply_task(close_success_tab=...) -> db.set_applied/mark_skipped
class ApplyWorker(BaseWorker) # ③ 后台更新:账号就绪预检 -> 检查或按批调用 editor.apply_task(...) -> db.set_applied/mark_skipped
class WriteBackWorker(BaseWorker) # ①/③ 后台回写:旧字段或更新结果写回原 Excel
class AIModelTestWorker(BaseWorker) # ⑤ 后台测试 AI 模型连接:appconfig.test_ai_model
class TaskTableModel(QAbstractTableModel) # 任务表格模型:账号/别名/商品ID/阶段;未匹配别名显示“略过”
@@ -403,9 +403,9 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
- 「测试连接」创建 `AIModelTestWorker` 后台调用 `appconfig.test_ai_model()`,GUI 主线程不直接发网络请求。
- 角色与生成参数读写 `config.json`,并按 3 个组件一组排列:标题大模型(仅 text)、图片大模型(仅 image)、标题/图片并发、失败重试、分辨率、jpg 质量。
- 分辨率下拉固定 `512/1k/2k/4k`;普通默认 cmhub 模式下,返回超时标签只读展示实际等待口径「标题 600 秒 / 图片 900 秒」,分辨率只控制生成图片尺寸。direct 兼容路径仍使用 `resolution_timeouts[resolution]`。
- 路径与端口读写 `config.json`,并按 3 个组件一组排列:默认调试端口、调试端口范围、CDP 就绪超时等短字段一格;Chrome 路径、账号数据根目录、图片目录、DB 路径等长字段跨整行或跨 2/3 列。保存时校验端口范围和默认端口。
- 蝦皮更新安全读写 `config.json` 的 `shopee_update` 段,并按 3 个组件一组排列:允许真实提交、每批最大更新条数、成功后关闭本次新开编辑页、多账号并行、最大并行账号数;其中「多账号并行更新」与「最大并行账号数」必须合并为同一个横向组件,最大并行账号数紧跟在多账号并行更新后面,不允许被三列表单排到下一行。`dry_run` 字段可保留为内部兼容,但普通用户界面不再展示 dry-run 开关,③ 使用「检查本轮更新」按钮触发检查模式;测试商品 ID 和 `allow_cover_update` 仅作为历史/调试兼容字段保留,普通设置页已隐藏入口。
- 真实提交默认关闭;更新内容默认只更新标题。③ 左下角「更新内容」下拉选择只更新标题、只更新封面或更新标题和封面,开始前必须先通过缺失内容校验和真实提交安全开关检查,并弹窗确认后才会创建更新 worker。
- 路径与端口读写 `config.json`,并按 3 个组件一组排列:默认调试端口、调试端口范围、Chrome 就绪超时等短字段一格;Chrome 路径、账号数据根目录、图片目录、DB 路径等长字段跨整行或跨 2/3 列。保存时校验端口范围和默认端口。
- 蝦皮更新执行读写 `config.json` 的 `shopee_update` 段,并按 3 个组件一组排列:每批最大更新条数、同时更新蝦皮账号。`dry_run` 字段可保留为内部兼容,但普通用户界面不再展示 dry-run 开关,③ 使用「检查本轮更新」按钮触发检查模式;测试商品 ID 和旧封面开关仅作为历史/调试兼容字段读取,普通设置页无入口,保存后不再写回。
- 更新内容默认只更新标题。③ 左下角「更新内容」下拉选择只更新标题、只更新封面或更新标题和封面,开始前必须先通过缺失内容校验,并弹窗确认后才会创建更新 worker。
① 导入采集当前要点(T-202/T-202b):
@@ -445,16 +445,16 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
- 任务列表使用 `QTableView + ApplyTaskTableModel`,列为:店铺、商品ID、新标题、新封面、阶段、结果。
- 「开始更新」只读取当前筛选结果;无任务时只提示,不弹确认、不改库;该按钮是③的主操作,视觉上强于检查、停止和回写。
- 「检查本轮更新」只读取当前筛选结果并创建检查运行日志,不打开 Shopee、不调用 `editor.apply_task()`、不写任务状态、不回写 Excel;检查内容包含任务总数、店铺分布、每批最大条数、预计批次数、更新内容、会更新字段和略过原因。
- 点击「开始更新」先按③「更新内容」模式校验当前筛选任务:只更新标题必须有 `new_title`,只更新封面必须有 `new_cover_path`,更新标题和封面必须两者都有;缺失时弹「更新内容未生成」并阻断,不打开 Chrome、不写失败状态。再读取 `shopee_update`:未允许真实提交时弹警告并阻断;拦截弹窗写明具体设置项并提供「前往设置」跳到⑤;普通正式更新不再检查 `test_item_id`,当前筛选结果可包含多个真实商品 ID;`max_items_per_run` 作为每批最大更新条数,当前筛选结果超过该值时自动分批。通过后才弹窗展示批次/店铺/商品ID/状态/更新内容/任务总数、每批最大条数、预计批次数、提交线上风险和当前安全设置。
- 点击「开始更新」先按③「更新内容」模式校验当前筛选任务:只更新标题必须有 `new_title`,只更新封面必须有 `new_cover_path`,更新标题和封面必须两者都有;缺失时弹「更新内容未生成」并阻断,不打开 Chrome、不写失败状态。再读取 `shopee_update` 执行参数:普通正式更新不再检查 `test_item_id` 或旧真实提交开关,当前筛选结果可包含多个真实商品 ID;`max_items_per_run` 作为每批最大更新条数,当前筛选结果超过该值时自动分批。通过后才弹窗展示批次/店铺/商品ID/状态/更新内容/任务总数、每批最大条数、预计批次数、提交线上风险和当前执行设置。
- 用户点否/取消时不执行、不改库;用户点是后才创建 `ApplyWorker` 做真实提交。
- `ApplyWorker` 只处理当前筛选结果里 `stage=generated`、状态为 `success/pending/failed`,且满足当前 `update_mode` 所需内容的任务;只更新标题时不替换封面,只更新封面时不改标题。已更新和略过记录仅查看,不会再次提交,除非用户先用 T-404a 的「重置更新状态」把选中记录退回可更新。
- 检查本轮更新:不做账号登录预检,不调用 `editor.apply_task()`,不写任务状态,不回写 Excel;只把每条“将更新/将略过”写入运行日志并弹汇总。
- 真实更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 Chrome 未启动、CDP 端口不可访问、未登录,或本轮涉及账号调试端口冲突时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理;预检不通过时不调用 `editor.apply_task()`、不写失败状态、不自动启动 Chrome。
- 预检通过后默认串行;若 `parallel_accounts=true` 且 `max_parallel_accounts>1`,按账号分组并行执行,不同账号可同时跑,同一账号内仍串行。每条执行 `db.mark_running(..., "apply")` → `editor.apply_task(account, task, close_success_tab=设置值)` → `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`,单条失败继续下一条。
- 真实更新打开商品页失败时,`ApplyWorker` 应把 `open_product()` 捕获到的 Shopee toast 文案上浮到③可见运行日志、`run_log_events` 和任务失败原因;若失败发生在 `open_product()` 内部,本轮自动新建 tab 要关闭,复用用户已有 tab 不关闭;本地 `data/logs/` 可保存失败现场 HTML/toast JSON 片段供开发排查,但必须脱敏。
- 检查和真实更新都会创建 `run_logs`,并把逐条事件写入 `run_log_events`;点击「检查本轮更新」或「开始更新」时先清空 `ApplyTab` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;③ 页面不自动把上一轮历史日志混入当前运行界面。
- `editor.apply_task()` 失败时关闭本轮自动新开的商品页,复用用户已有 tab 不关闭;成功提交时仅在 `close_success_tab=true` 且 tab 为本轮自动新建时关闭,确认后跳回商品列表页时关闭前等待 2 秒。`open_product()` 内部打开失败的新建 tab 由 `open_product()` 自行关闭。
- `editor.apply_task()` 对本轮自动新开的商品页成功/失败都关闭,成功提交后关闭前等待 2 秒;复用用户已有 tab 不关闭。`open_product()` 内部打开失败的新建 tab 由 `open_product()` 自行关闭。
- 别名未匹配账号的任务逐条 `db.mark_skipped()`,原因 `别名未匹配账号`;「停止」调用 worker 协作式 `cancel()`,已开始单条跑到安全边界后结束。
- T-404a/T-508 已实现:「重置更新状态」从底部批处理按钮移到任务表右键菜单,读取当前选中单条,运行中禁用;确认后保留 `new_title/new_cover_path`,本地退回 `stage=generated/status=pending` 供重复更新;`committed=1` 时必须提示线上已提交过且不回滚蝦皮,并保留 committed 历史事实/运行日志。
- ③ 没有常驻提交开关;确认弹窗是提交线上前的边界。
@@ -493,7 +493,7 @@ run_worker(worker: BaseWorker, thread_name=None, start=True) -> QThread
- 采集、AI 生成、更新、Excel 回写都通过 worker 执行,用 signal 回传进度。
- 每个 worker/线程按需创建自己的 SQLite connection,不跨线程共享连接。
- ③ 的批量确认弹窗在 GUI 主线程完成;用户确认后才创建 `ApplyWorker`。
- `ApplyWorker` 支持检查、默认串行和按账号并行;真实更新按 `max_items_per_run` 分批调用 `editor.apply_task(..., close_success_tab=...)`,逐条 `set_applied()`,失败继续;账号未就绪或端口冲突时整体阻断并引导④,不进入逐条提交,也不静默启动账号 Chrome。点击停止为协作式停止:当前商品完成后不再开始新商品或下一批。
- `ApplyWorker` 支持检查、默认串行和按账号并行;真实更新按 `max_items_per_run` 分批调用 `editor.apply_task(...)`,逐条 `set_applied()`,失败继续;账号未就绪或端口冲突时整体阻断并引导④,不进入逐条提交,也不静默启动账号 Chrome。点击停止为协作式停止:当前商品完成后不再开始新商品或下一批。
- `WriteBackWorker` 默认 `mode="old"` 回写旧字段;③ 使用 `mode="results"` 回写新标题/新封面/更新状态,支持单批次或多批次列表。
- `execute()` 未捕获异常会发 `failed(-1, error)` 与 `finished({"ok": False, "error": ...})`;普通单行失败由业务 worker 自己发 `failed(task_id, error)` 后继续处理。
+16 -18
View File
@@ -14,9 +14,9 @@
| ② AI生成 | 左侧标题/封面**提示词**;右侧按批次/店铺/商品ID/状态筛选任务列表;AI 生成新标题,并按本轮开关可选生成新封面;表格拆分显示「标题状态 / 图片状态」;已生成任务可本地微调新标题;双击看新旧封面 | 不触线上,中 |
| ③ 更新蝦皮 | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** |
| ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式;启动登录必须复用已打开的同账号 Chrome,避免重复开窗口;检测登录遇到 `accounts.shopee.tw/seller/login` 必须显示未登录 | 中 |
| ⑤ 设置 | cmhub 网关/API Key、生文/生图别名、生成参数、本地图片目录、Chrome 路径、默认端口、蝦皮更新安全开关等 | — |
| ⑤ 设置 | cmhub 网关/API Key、生文/生图别名、生成参数、Chrome 路径、默认端口、蝦皮更新执行参数等 | — |
任务的**阶段状态**贯穿各 Tab:`imported → collected → generated → applied`(或 `failed/skipped`)。② 不设逐条人工确认阶段;③ 无常驻提交开关,但点击「开始更新」后必须先通过 ⑤ 的蝦皮更新安全开关,再弹窗确认当前筛选范围和任务数量。各 Tab 聚焦各自阶段的列与按钮,但操作同一批任务(同一 batch)。
任务的**阶段状态**贯穿各 Tab:`imported → collected → generated → applied`(或 `failed/skipped`)。② 不设逐条人工确认阶段;③ 无常驻提交开关,点击「开始更新」后必须弹窗确认当前筛选范围、任务数量和线上提交风险。各 Tab 聚焦各自阶段的列与按钮,但操作同一批任务(同一 batch)。
## 全局 Tab 栏可用性
@@ -124,15 +124,15 @@
- 状态筛选:`已生成` 只跑未更新的;`失败` 用于**失败重试**;`已更新成功/略过` 仅查看。
- 「更新内容」下拉支持只更新标题、只更新封面、更新标题和封面;点击「检查本轮更新」或「开始更新」前先按当前模式检查 `new_title` / `new_cover_path`,缺失时中文弹窗阻断,不打开 Chrome、不改任务状态。
- 「检查本轮更新」只读取当前筛选结果并写运行日志,不打开 Shopee、不提交、不改任务状态;弹窗/日志展示总任务数、店铺分布、当前更新内容、会更新标题/封面、略过原因、每批最大条数和预计批次数。
- 缺失内容校验通过后,点击「开始更新」读取 ⑤ `shopee_update` 安全设置:未允许真实提交时,直接弹警告并阻断;拦截弹窗必须写明具体未开启的设置项,并提供「前往设置」跳到 ⑤;普通正式更新不再以测试商品 ID 限制当前筛选结果,允许当前筛选结果包含多个真实商品 ID;`max_items_per_run` 作为**每批最大更新条数**,当前筛选总数超过该值时不阻断,而是自动分批执行。
- 安全开关通过后,弹窗展示本次筛选条件、更新内容、任务总数、每批最大条数、预计批次数、安全设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。
- 缺失内容校验通过后,点击「开始更新」读取 ⑤ `shopee_update` 执行设置:普通正式更新不再以测试商品 ID 或旧真实提交开关限制当前筛选结果,允许当前筛选结果包含多个真实商品 ID;`max_items_per_run` 作为**每批最大更新条数**,当前筛选总数超过该值时不阻断,而是自动分批执行。
- 弹窗展示本次筛选条件、更新内容、任务总数、每批最大条数、预计批次数、执行设置和“将提交线上”的风险提示;用户点「是/确认」才开始,点「否/取消」不执行。
- 真实更新第一条商品前做账号就绪预检:按当前筛选结果汇总需要的账号;无账号、Chrome 未启动、CDP 端口不可访问、未登录或端口冲突时,弹窗列出具体账号/原因并中止本轮,不自动调用「启动登录」或静默打开 Chrome。
- 对确认后的**已生成(generated)任务**按当前更新内容执行:打开编辑页换标题和/或换封面 → 点页面「更新」 → 如 Shopee 弹出“確定您要更新商品嗎?”确认框(`.eds-modal__content` / `.eds-modal__box`),则只点弹窗主按钮「更新」提交,不点「立即優化」。
- 为降低批量更新时 Chrome 抢前台,③本轮每个账号只在第一条任务主动把 Chrome 提到前台,后续同账号任务后台打开;如果后台态封面上传/拖拽出现疑似遮挡节流失败,程序只对当前 tab 提前台做一次安全恢复,不重新执行完整删图上传流程。
- 打开编辑页失败时,如果 Shopee 弹出错误 toast(如商品 ID 不正确、商品不存在、无权限),③运行日志和任务失败原因必须显示该 toast 文案;同时把 toast HTML/URL/时间写入本地诊断日志。用户不需要手动复制瞬时 toast 的 HTML。
- 更新封面时统一按替换第一张执行:删除第一张前必须已有该任务的本地旧封面备份(①采集得到的 `old_cover_path` 且文件存在);备份缺失时阻断该条更新并提示先采集/修复备份,不盲删线上图片。
- 默认串行、单条失败继续;⑤ 可开启多账号并行,不同账号同时执行,同一账号内仍串行;真实更新前若本轮账号调试端口冲突则阻断。
- 默认串行、单条失败继续;⑤「同时更新蝦皮账号」设为 1 时逐个账号执行,设为 2..5 时不同账号同时执行,同一账号内仍串行;真实更新前若本轮账号调试端口冲突则阻断。
- 「检查本轮更新」只写运行日志与检查汇总,不打开 Shopee、不调用 `editor.apply_task()`、不写任务状态、不回写 Excel。
- 真实更新每条立即写回 SQLite(committed/状态/error),失败不阻塞后续任务;检查和真实更新都会写 `run_logs/run_log_events`。点击「检查本轮更新」或「开始更新」时先清空③界面可见日志并写入本轮检查/更新开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`,历史日志不自动混入当前运行界面。
- 分批更新时「停止」为协作式停止:已开始的当前商品跑到安全边界并写库后停止,不再开始新商品、不进入下一批;未开始任务保持原状态,下次可继续。
@@ -165,15 +165,13 @@
- 保存设置固定写 `ai.backend=cmhub`。允许先保存不完整 cmhub 配置,②真正生成时如果缺 Base URL/API Key/别名,会提示去⑤补配置,不静默回退 direct。
- T-531 已完成:⑤设置页任意可编辑控件变更都进入未保存状态,保存按钮旁显示“● 未保存更改”;切换到其它 Tab 或关闭窗口时弹出保存/放弃/取消。放弃会重新从本地配置文件回填控件,避免未保存的 URL/API Key 留在界面上;程序化回填、保存后重载和刷新别名填充下拉不会误触发未保存状态。
- direct 模型清单和 `data/config/ai_models.json` 代码路径保留为内部兼容/手工回滚,不在普通 UI 暴露。
- AI 生成参数:标题并发、图片并发、失败重试、分辨率、返回超时、jpg 质量等短字段按三列排列;标题/图片并发可选 1..5,失败重试可选 0..10,旧配置超限值会自动夹紧。
- AI 生成参数:标题并发、图片并发、失败重试、分辨率、返回超时等短字段按三列排列;标题/图片并发可选 1..5,失败重试可选 0..10,旧配置超限值会自动夹紧;图片保存质量保留内部默认 90,不在普通 UI 展示。
- 分辨率为 `512 / 1k / 2k / 4k`,在 cmhub 默认模式下只控制生成图片尺寸;⑤「返回超时」只读展示当前实际等待口径:标题 600 秒、图片 900 秒,不再随分辨率切换显示 180/240/360/600,避免用户误解生图等待时间。
- 保存写入 `config.json` 的 `ai` 段,供 ② AI生成复用;标题/图片模型角色下拉随 direct UI 一起隐藏。
- 路径与端口(T-501b/T-506/T-539 已接入):组件组改为 3 个组件一组;普通设置页只显示 Chrome 路径、默认端口、端口起止、CDP 就绪超时。T-538 后账号数据根目录、图片目录、DB 路径固定解析到 `data/` 下,普通 UI 不再提供输入框,避免用户误改后数据分裂;`config.json` 中 `user_data_root` / `image_dir` / `db_path` 字段继续作为内部兼容字段保留,手工配置值仍会被读取和保存。
- 蝦皮更新安全(T-501c/T-506/T-576 已接入):组件组改为 3 个组件一组;允许真实提交、每批最大更新条数、成功后关闭本次新开编辑页等短字段三列排列;「多账号并行更新」与「最大并行账号数」必须合并为同一个横向组件,最大并行账号数紧跟在多账号并行更新后面,不允许换到下一行;测试商品 ID 和 `allow_cover_update` 仅作为历史/调试兼容字段保留,普通设置页已隐藏入口。
- 默认关闭真实提交,③「更新内容」默认只更新标题,每批最大更新条数默认 1。
- ③ 点击「开始更新」会先按「更新内容」校验缺失内容,再读取这些设置拦截不符合条件的更新,最后弹确认框。
- 更新执行模式(T-504/T-506 已接入):⑤ 只保留多账号并行更新、最大并行账号数等执行设置;“dry-run”不再作为用户可见开关,改到③成为「检查本轮更新」按钮。
- 默认多账号并行关闭;开启多账号并行后同一账号内仍串行。
- 路径与端口(T-501b/T-506/T-539/T-580 已接入):组件组改为 3 个组件一组;普通设置页只显示 Chrome 路径、默认端口、端口起止、Chrome 就绪超时。T-538 后账号数据根目录、图片目录、DB 路径固定解析到 `data/` 下,普通 UI 不再提供输入框,避免用户误改后数据分裂;`config.json` 中 `user_data_root` / `image_dir` / `db_path` 字段继续作为内部兼容字段保留,手工配置值仍会被读取和保存。
- 蝦皮更新执行(T-580 已接入):组件组改为 3 个组件一组;普通设置页只保留「每批最大更新条数」和「同时更新蝦皮账号(1..5)」两个执行参数。`1` 表示逐个账号串行,`2..5` 表示按账号分组并行;“dry-run”不作为用户可见开关,改到③成为「检查本轮更新」按钮;测试商品 ID 和旧封面开关仅作为历史/调试兼容字段读取,普通设置页无入口,保存后不再写回。
- ③「更新内容」默认只更新标题,每批最大更新条数默认 1,同时更新蝦皮账号默认 1。
- ③ 点击「开始更新」会先按「更新内容」校验缺失内容,再弹确认框。
## 流程导航
@@ -184,13 +182,13 @@
│
② AI生成:提示词 → 选择生成标题/封面/图文(无逐条审核)
│
⑤ 设置:配置蝦皮更新安全、每批最大更新条数和执行模式
⑤ 设置:配置每批最大更新条数和蝦皮更新执行模式
│
③ 更新蝦皮:选择更新标题/封面/图文 → 对已生成任务点击开始更新 → 缺失内容校验 → 安全开关检查 → 弹窗确认 → 账号就绪预检(未启动/未登录则中止) → 按模式换标题/封面 → 点「更新」提交 → 回写结果 → 弹窗汇总
③ 更新蝦皮:选择更新标题/封面/图文 → 对已生成任务点击开始更新 → 缺失内容校验 → 弹窗确认 → 账号就绪预检(未启动/未登录则中止) → 按模式换标题/封面 → 点「更新」提交 → 回写结果 → 弹窗汇总
```
- 未配账号 / 未登录:① 会自动确保匹配账号 Chrome 就绪但不自动登录,未登录账号略过并提示去④;③ 更新前仍只检测账号 Chrome/CDP/登录态,未启动或未登录则中止,不静默启动缺失账号 Chrome。
- 已生成的任务即可进 ③;③ 通过 ⑤ 安全开关并经用户确认批量弹窗后提交线上,无常驻提交开关。
- 已生成的任务即可进 ③;③ 经用户确认批量弹窗后提交线上,无常驻提交开关。
- 任意步骤失败:记入该任务、日志标明,不影响其他任务。
## 组件建议(PySide6)
@@ -200,12 +198,12 @@
| `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、状态栏、全局消息 |
| `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 |
| `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;开始生成/停止/进度;本轮「生成内容」下拉接入 `GenerateWorker` |
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 缺失内容阻断 +「检查本轮更新」+ 蝦皮更新安全拦截 + 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 |
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 缺失内容阻断 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 |
| `AccountsTab(QWidget)` | ④ | 账号增删改、启动登录、检测登录、生成快捷方式;登录检测把 Shopee accounts 登录页判为未登录 |
| `SettingsTab(QWidget)` | ⑤ | cmhub 网关配置 + 响应式三列设置表单 + 生成参数 + Chrome/端口配置 + 蝦皮更新安全;数据路径字段隐藏但保留配置兼容 |
| `SettingsTab(QWidget)` | ⑤ | cmhub 网关配置 + 响应式三列设置表单 + 生成参数 + Chrome/端口配置 + 蝦皮更新执行;数据路径字段隐藏但保留配置兼容 |
| `TaskTableModel(QAbstractTableModel)` | ①②③ | 任务表格数据模型,供 `QTableView` 使用 |
| `BaseWorker(QObject)` | 后台 | 定义 `progress/log/row_updated/failed/finished/cancelled` signals |
| `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、检查本轮更新、按每批最大条数分批、按账号并行或串行调用 `editor.apply_task(..., close_success_tab=...)`、逐条 `set_applied()`,失败继续,写运行日志 |
| `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、检查本轮更新、按每批最大条数分批、按账号并行或串行调用 `editor.apply_task(...)`、逐条 `set_applied()`,失败继续,写运行日志 |
| `AIModelTestWorker(BaseWorker)` | ⑤ | 后台调用 `appconfig.test_ai_model()` 测试模型连接 |
| `WriteBackWorker(BaseWorker)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 |
+18 -2
View File
@@ -3,7 +3,7 @@ id: T-580
title: ⑤设置精简:删真实提交闸/关页开关/jpg质量,合并并行为「同时更新蝦皮账号(≤5)」,改名Chrome就绪超时
phase: 7
deps: [T-571, T-578]
status: TODO
status: DONE
created: 2026-07-10
---
@@ -90,4 +90,20 @@ created: 2026-07-10
## 执行记录
(做完在这里写:改了什么文件、跑了什么验证命令及结果、遇到的阻塞、关键决策。)
- 2026-07-10:完成设置精简与执行语义调整。
- `app/appconfig.py`:`shopee_update` 默认删除真实提交、封面兼容、成功关页、并行 bool 等旧开关;新增 `max_parallel_accounts` 默认 1、范围 1..5;旧配置 `parallel_accounts=false` 迁移为 1,`true` 保留并夹紧;保存后不再写回旧开关。
- `app/gui/tabs/settings.py`:⑤设置页删除「允许真实提交线上商品」「允许更新封面」「成功后关闭本次新开编辑页」「多账号并行更新」「jpg质量」控件;「最大并行账号数」改为「同时更新蝦皮账号」,范围 1..5;「CDP就绪超时」改为「Chrome 就绪超时」;分区标题改为「蝦皮更新执行」。
- `app/gui/tabs/apply.py` / `app/gui/workers.py`:③「开始更新」不再被真实提交开关阻断,仍保留最终确认弹窗;按 `max_parallel_accounts > 1` 决定账号并行;worker 不再传成功关页开关。
- `app/editor.py`:程序自动新开的商品编辑页成功/失败都关闭;成功关闭前等待 2 秒;复用用户原本 tab 不关闭。`close_success_tab` 仅保留为旧调用兼容参数。
- `docs/04-architecture.md`、`docs/api.md`、`docs/routes.md` 同步当前配置 schema、③确认边界、tab 生命周期和⑤设置文案;更新 `tests/test_appconfig.py`、`tests/test_gui.py`、`tests/test_editor_login.py` 覆盖迁移、UI 删除、并行语义和成功关页。
- 验证:
- 当前工作区运行 `python -m ruff check app tests main.py`:通过。
- 当前工作区运行 `py -3.10 -m unittest tests.test_appconfig tests.test_editor_login tests.test_ai`:通过,108 tests OK。
- 当前工作区运行 T-580 相关 GUI 单测 10 项:通过。
- 当前工作区运行 `python -m compileall app main.py`:通过。
- 因当前工作区存在非 T-580 未归属默认封面提示词改名(`papa1.txt` 删除、`默认.txt` 未跟踪),直接跑 `py -3.10 -m unittest tests.test_gui ...` 会有 2 个 T-579 提示词名称断言失败。为隔离该非本任务影响,创建临时干净 worktree,仅应用 T-580 diff 后运行:
- `py -3.10 -m unittest tests.test_gui tests.test_appconfig tests.test_editor_login tests.test_ai`:通过,246 tests OK。
- `python -m ruff check app tests main.py`:通过。
- `py -3.10 -m compileall app main.py`:通过。
- `py -3.10 -m unittest discover -s tests`:通过,318 tests OK。
- `git diff --check`:通过。
+66 -3
View File
@@ -29,7 +29,8 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
self.assertFalse(appconfig.ai_config(config)["generate_cover"])
self.assertEqual("title", appconfig.ai_generate_mode(config))
self.assertEqual("title", appconfig.shopee_update_config(config)["update_mode"])
self.assertFalse(appconfig.shopee_update_config(config)["allow_cover_update"])
self.assertNotIn("allow_cover_update", appconfig.shopee_update_config(config))
self.assertEqual(1, appconfig.shopee_update_config(config)["max_parallel_accounts"])
updated = appconfig.update_config(
{"ai": {"resolution": "2k"}},
@@ -48,7 +49,7 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
self.assertEqual("title_cover", appconfig.ai_generate_mode(legacy))
self.assertTrue(appconfig.ai_config(legacy)["generate_cover"])
self.assertEqual("title_cover", appconfig.shopee_update_config(legacy)["update_mode"])
self.assertTrue(appconfig.shopee_update_config(legacy)["allow_cover_update"])
self.assertNotIn("allow_cover_update", appconfig.shopee_update_config(legacy))
cover_only = appconfig.save_config(
{
@@ -60,7 +61,69 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
self.assertEqual("cover", appconfig.ai_generate_mode(cover_only))
self.assertTrue(appconfig.ai_config(cover_only)["generate_cover"])
self.assertEqual("cover", appconfig.shopee_update_config(cover_only)["update_mode"])
self.assertTrue(appconfig.shopee_update_config(cover_only)["allow_cover_update"])
self.assertNotIn("allow_cover_update", appconfig.shopee_update_config(cover_only))
self.assert_removed(temp_dir)
def test_shopee_update_legacy_parallel_config_is_migrated(self):
with self.make_temp_dir() as temp_dir:
config_path = os.path.join(temp_dir, "config.json")
with open(config_path, "w", encoding="utf-8") as fh:
json.dump(
{
"shopee_update": {
"allow_real_submit": False,
"allow_cover_update": True,
"close_success_tab": False,
"parallel_accounts": False,
"max_parallel_accounts": 2,
}
},
fh,
)
loaded = appconfig.load_config(config_path)
update_cfg = appconfig.shopee_update_config(loaded)
self.assertEqual("title_cover", update_cfg["update_mode"])
self.assertEqual(1, update_cfg["max_parallel_accounts"])
for key in (
"allow_real_submit",
"allow_cover_update",
"close_success_tab",
"parallel_accounts",
):
self.assertNotIn(key, update_cfg)
saved = appconfig.save_config(loaded, path=config_path)
with open(config_path, "r", encoding="utf-8") as fh:
persisted = json.load(fh)
persisted_update = persisted["shopee_update"]
self.assertEqual(1, saved["shopee_update"]["max_parallel_accounts"])
for key in (
"allow_real_submit",
"allow_cover_update",
"close_success_tab",
"parallel_accounts",
):
self.assertNotIn(key, persisted_update)
with open(config_path, "w", encoding="utf-8") as fh:
json.dump(
{
"shopee_update": {
"parallel_accounts": True,
"max_parallel_accounts": 16,
}
},
fh,
)
loaded_parallel = appconfig.load_config(config_path)
self.assertEqual(
5,
appconfig.shopee_update_config(loaded_parallel)["max_parallel_accounts"],
)
self.assert_removed(temp_dir)
+3 -3
View File
@@ -780,7 +780,7 @@ class EditorLoginTests(unittest.TestCase):
sleep.assert_called_once_with(2)
close_tab.assert_called_once_with("target-new", host="127.0.0.1:9222")
def test_apply_task_keeps_auto_created_product_tab_after_success_when_disabled(self):
def test_apply_task_closes_auto_created_product_tab_after_success_even_with_legacy_disabled_flag(self):
cdp = FakeProductCDP("ws-new")
cdp.target_id = "target-new"
cdp.created_by_app = True
@@ -803,8 +803,8 @@ class EditorLoginTests(unittest.TestCase):
self.assertTrue(result["committed"])
self.assertTrue(cdp.closed)
close_tab.assert_not_called()
sleep.assert_not_called()
sleep.assert_called_once_with(2)
close_tab.assert_called_once_with("target-new", host="127.0.0.1:9222")
def test_apply_task_closes_auto_created_product_tab_after_failed_update(self):
cdp = FakeProductCDP("ws-new")
+58 -120
View File
@@ -111,13 +111,14 @@ class GuiTests(TempDirMixin, unittest.TestCase):
allow_cover=True,
max_items=1,
close_success_tab=False,
max_parallel_accounts=1,
):
cfg["shopee_update"] = {
"test_item_id": item_id,
"allow_real_submit": True,
"allow_cover_update": allow_cover,
"update_mode": "title_cover" if allow_cover else "title",
"max_items_per_run": max_items,
"close_success_tab": close_success_tab,
"dry_run": False,
"max_parallel_accounts": max_parallel_accounts,
}
return cfg
@@ -840,7 +841,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertFalse(hasattr(tab, "test_item_id_edit"))
self.assertFalse(hasattr(tab, "dry_run_checkbox"))
self.assertEqual(
"蝦皮更新安全 / 执行模式",
"蝦皮更新执行",
tab.shopee_update_section_title.text(),
)
self.assertEqual("基础设施(路径与端口)", tab.infrastructure_section_title.text())
@@ -848,14 +849,16 @@ class GuiTests(TempDirMixin, unittest.TestCase):
tab.settings_panel_layout.indexOf(tab.shopee_update_section_title),
tab.settings_panel_layout.indexOf(tab.infrastructure_section_title),
)
self.assertFalse(tab.allow_real_submit_checkbox.isChecked())
self.assertFalse(tab.allow_cover_update_checkbox.isChecked())
self.assertTrue(tab.allow_cover_update_checkbox.isHidden())
self.assertEqual(-1, tab.shopee_update_form_layout.indexOf(tab.allow_cover_update_checkbox))
self.assertFalse(hasattr(tab, "allow_real_submit_checkbox"))
self.assertFalse(hasattr(tab, "allow_cover_update_checkbox"))
self.assertFalse(hasattr(tab, "close_success_tab_checkbox"))
self.assertFalse(hasattr(tab, "parallel_accounts_checkbox"))
self.assertFalse(hasattr(tab, "parallel_accounts_group"))
self.assertFalse(hasattr(tab, "jpg_quality_spin"))
self.assertEqual(1, tab.max_items_per_run_spin.value())
self.assertFalse(tab.close_success_tab_checkbox.isChecked())
self.assertFalse(tab.parallel_accounts_checkbox.isChecked())
self.assertEqual(2, tab.max_parallel_accounts_spin.value())
self.assertEqual(1, tab.max_parallel_accounts_spin.value())
self.assertEqual(1, tab.max_parallel_accounts_spin.minimum())
self.assertEqual(5, tab.max_parallel_accounts_spin.maximum())
def widget_position(layout, widget):
for index in range(layout.count()):
@@ -864,27 +867,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
return layout.getItemPosition(index)
self.fail(f"Widget not found in layout: {widget.objectName()}")
group_row, group_col, _row_span, group_col_span = widget_position(
parallel_row, parallel_col, _row_span, parallel_col_span = widget_position(
tab.shopee_update_form_layout,
tab.parallel_accounts_group,
)
self.assertGreaterEqual(group_row, 0)
self.assertGreaterEqual(group_col, 0)
self.assertEqual(4, group_col_span)
group_widgets = [
tab.parallel_accounts_group.layout().itemAt(index).widget()
for index in range(tab.parallel_accounts_group.layout().count())
if tab.parallel_accounts_group.layout().itemAt(index).widget() is not None
]
self.assertEqual(
[
tab.parallel_accounts_checkbox,
tab.max_parallel_accounts_label,
tab.max_parallel_accounts_spin,
],
group_widgets,
tab.max_parallel_accounts_spin,
)
self.assertGreaterEqual(parallel_row, 0)
self.assertGreaterEqual(parallel_col, 0)
self.assertEqual(1, parallel_col_span)
for hidden_widget in (
tab.user_data_root_edit,
tab.image_dir_edit,
@@ -1144,16 +1133,12 @@ class GuiTests(TempDirMixin, unittest.TestCase):
tab.retry_spin.setValue(1)
tab.resolution_combo.setCurrentIndex(tab.resolution_combo.findData("2k"))
self.assertEqual("标题 600 秒 / 图片 900 秒", tab.response_timeout_label.text())
tab.jpg_quality_spin.setValue(86)
tab.chrome_path_edit.setText("D:\\Chrome\\chrome.exe")
tab.default_debug_port_spin.setValue(9300)
tab.debug_port_start_spin.setValue(9300)
tab.debug_port_end_spin.setValue(9350)
tab.cdp_ready_timeout_spin.setValue(45)
tab.allow_real_submit_checkbox.setChecked(True)
tab.max_items_per_run_spin.setValue(2)
tab.close_success_tab_checkbox.setChecked(True)
tab.parallel_accounts_checkbox.setChecked(True)
tab.max_parallel_accounts_spin.setValue(3)
with mock.patch("app.gui.QMessageBox.information") as info:
@@ -1168,7 +1153,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual(2, saved["ai"]["image_concurrency"])
self.assertEqual(1, saved["ai"]["retry"])
self.assertEqual("2k", saved["ai"]["resolution"])
self.assertEqual(86, saved["ai"]["jpg_quality"])
self.assertEqual(90, saved["ai"]["jpg_quality"])
self.assertEqual("D:\\Chrome\\chrome.exe", saved["chrome_path"])
self.assertEqual("manual_profiles", saved["user_data_root"])
self.assertEqual("manual_images", saved["image_dir"])
@@ -1179,13 +1164,9 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual(
{
"test_item_id": "51100639510",
"allow_real_submit": True,
"allow_cover_update": False,
"update_mode": "title",
"max_items_per_run": 2,
"close_success_tab": True,
"dry_run": False,
"parallel_accounts": True,
"max_parallel_accounts": 3,
},
saved["shopee_update"],
@@ -1218,7 +1199,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_settings_save_updates_apply_tab_shared_safety_config(self):
def test_settings_save_updates_apply_tab_shared_update_config(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
cfg["shopee_update"] = dict(appconfig.default_config()["shopee_update"])
@@ -1232,10 +1213,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
settings_tab = window.tabs.widget(TAB_TITLES.index("⑤ 设置"))
apply_tab = window.tabs.widget(TAB_TITLES.index("③ 更新蝦皮"))
settings_tab.allow_real_submit_checkbox.setChecked(True)
settings_tab.max_items_per_run_spin.setValue(3)
settings_tab.close_success_tab_checkbox.setChecked(True)
settings_tab.parallel_accounts_checkbox.setChecked(True)
settings_tab.max_parallel_accounts_spin.setValue(4)
with mock.patch("app.gui.QMessageBox.information") as info:
settings_tab.save_app_settings()
@@ -1243,13 +1221,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
info.assert_called_once_with(settings_tab, "保存设置", "设置已保存")
safety_cfg = apply_tab._shopee_update_config()
self.assertEqual("123456789", safety_cfg["test_item_id"])
self.assertTrue(safety_cfg["allow_real_submit"])
self.assertFalse(safety_cfg["allow_cover_update"])
self.assertNotIn("allow_real_submit", safety_cfg)
self.assertNotIn("allow_cover_update", safety_cfg)
self.assertEqual("title", safety_cfg["update_mode"])
self.assertEqual(3, safety_cfg["max_items_per_run"])
self.assertTrue(safety_cfg["close_success_tab"])
self.assertNotIn("close_success_tab", safety_cfg)
self.assertFalse(safety_cfg["dry_run"])
self.assertTrue(safety_cfg["parallel_accounts"])
self.assertNotIn("parallel_accounts", safety_cfg)
self.assertEqual(4, safety_cfg["max_parallel_accounts"])
self.assert_removed(temp_dir)
@@ -4307,7 +4285,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
def test_apply_tab_start_update_starts_apply_worker_after_confirmation(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
self.allow_shopee_update(cfg, close_success_tab=True)
self.allow_shopee_update(cfg)
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
db.insert_tasks(
@@ -4359,9 +4337,9 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertIsInstance(tab.apply_worker, ApplyWorker)
self.assertIs(tab.apply_thread, fake_thread)
self.assertTrue(fake_thread.started)
self.assertTrue(tab.apply_worker.close_success_tab)
self.assertFalse(hasattr(tab.apply_worker, "close_success_tab"))
self.assertFalse(tab.apply_worker.dry_run)
self.assertFalse(tab.apply_worker.parallel_accounts)
self.assertEqual(1, tab.apply_worker.max_parallel_accounts)
self.assertEqual(1, tab.apply_worker.batch_size)
self.assertEqual("title_cover", tab.apply_worker.update_mode)
log_text = tab.run_log_view.toPlainText()
@@ -4440,7 +4418,6 @@ class GuiTests(TempDirMixin, unittest.TestCase):
warning.assert_not_called()
self.assertTrue(tab.apply_worker.dry_run)
self.assertTrue(tab.apply_worker.parallel_accounts)
self.assertEqual(2, tab.apply_worker.max_parallel_accounts)
log_text = tab.run_log_view.toPlainText()
self.assertNotIn("上一轮检查失败", log_text)
@@ -4449,9 +4426,13 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_apply_tab_blocks_update_when_real_submit_switch_is_off(self):
def test_apply_tab_old_real_submit_switch_does_not_block_final_confirmation(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
cfg["shopee_update"] = {
"allow_real_submit": False,
"max_items_per_run": 1,
}
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
db.insert_tasks(
@@ -4476,17 +4457,31 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.addCleanup(tab.close)
tab.item_filter.setText("51100639510")
with mock.patch.object(tab, "_show_update_safety_error") as safety_error, \
mock.patch("app.gui.QMessageBox.question") as question, \
mock.patch("app.gui.run_worker") as run_worker:
class FakeSignal:
def connect(self, callback):
self.callback = callback
class FakeThread:
def __init__(self):
self.finished = FakeSignal()
self.started = False
def start(self):
self.started = True
fake_thread = FakeThread()
with mock.patch(
"app.gui.QMessageBox.question",
return_value=gui.QMessageBox.Yes,
) as question, mock.patch("app.gui.run_worker", return_value=fake_thread) as run_worker:
tab.start_update()
message = safety_error.call_args[0][0]
self.assertIn("允许真实提交线上商品", message)
self.assertIn("⑤设置", message)
question.assert_not_called()
run_worker.assert_not_called()
self.assertIn("已阻止本次更新", statuses[-1])
message = question.call_args[0][2]
self.assertIn("即将按当前筛选结果分批更新蝦皮线上商品", message)
self.assertIn("任务数:1", message)
run_worker.assert_called_once()
self.assertTrue(fake_thread.started)
self.assertEqual("开始更新:1 条,按每批最多 1 条执行", statuses[-1])
self.assert_removed(temp_dir)
@@ -4533,56 +4528,6 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir)
def test_apply_tab_safety_error_allows_more_than_batch_size_and_non_test_items(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
self.allow_shopee_update(cfg, item_id="51100639510", max_items=1)
tab = ApplyTab(config=cfg)
self.addCleanup(tab.close)
class Task:
def __init__(self, item_id, new_cover_path=None):
self.item_id = item_id
self.new_cover_path = new_cover_path
count_error = tab._update_safety_error(
[
Task("51100639510"),
Task("51100639510"),
]
)
non_test_error = tab._update_safety_error([Task("51100639511")])
cfg["shopee_update"]["test_item_id"] = ""
missing_test_id_error = tab._update_safety_error([Task("26887160467")])
self.assertIsNone(count_error)
self.assertIsNone(non_test_error)
self.assertIsNone(missing_test_id_error)
self.assert_removed(temp_dir)
def test_apply_tab_safety_error_popup_can_open_settings_tab(self):
with self.make_temp_dir() as temp_dir:
opened = []
tab = ApplyTab(
config=self.make_config(temp_dir),
open_settings_callback=lambda: opened.append(True),
)
self.addCleanup(tab.close)
settings_button = object()
box = mock.Mock()
box.addButton.side_effect = [settings_button, object()]
box.clickedButton.return_value = settings_button
with mock.patch("app.gui.QMessageBox", return_value=box) as message_box:
tab._show_update_safety_error("设置未开启")
message_box.assert_called_once_with(tab)
box.setWindowTitle.assert_called_once_with("更新安全开关")
box.setText.assert_called_once_with("设置未开启")
self.assertEqual([True], opened)
self.assert_removed(temp_dir)
def test_apply_worker_applies_success_failure_and_unmatched_serially(self):
with self.make_temp_dir() as temp_dir:
cfg = self.make_config(temp_dir)
@@ -4624,14 +4569,12 @@ class GuiTests(TempDirMixin, unittest.TestCase):
db.set_generated(task.id, "新标题", "new.jpg", path=cfg["db_path"])
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
applied_aliases = []
close_flags = []
foreground_flags = []
progress = []
rows = []
def fake_apply(account, task, close_success_tab=False, on_step=None, bring_to_front=True, update_mode=None):
applied_aliases.append(account.alias)
close_flags.append(close_success_tab)
foreground_flags.append(bring_to_front)
if account.alias == "alias-a":
return {"committed": True, "error": None}
@@ -4648,7 +4591,6 @@ class GuiTests(TempDirMixin, unittest.TestCase):
tasks,
db_path=cfg["db_path"],
config=cfg,
close_success_tab=True,
batch_size=1,
)
worker.progress.connect(progress.append)
@@ -4656,7 +4598,6 @@ class GuiTests(TempDirMixin, unittest.TestCase):
summary = worker.execute()
self.assertEqual(["alias-a", "alias-b"], applied_aliases)
self.assertEqual([True, True], close_flags)
self.assertEqual([True, True], foreground_flags)
self.assertFalse(summary["ok"])
self.assertEqual(3, summary["total"])
@@ -4666,7 +4607,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual(1, summary["failed"])
self.assertEqual([batch_id], summary["batch_ids"])
self.assertFalse(summary["dry_run"])
self.assertFalse(summary["parallel_accounts"])
self.assertFalse(summary["account_parallel"])
self.assertEqual(1, summary["batch_size"])
self.assertEqual(3, summary["batch_count"])
self.assertIsNotNone(summary["run_id"])
@@ -4796,7 +4737,6 @@ class GuiTests(TempDirMixin, unittest.TestCase):
db_path=cfg["db_path"],
config=cfg,
dry_run=True,
parallel_accounts=True,
max_parallel_accounts=2,
)
worker.log.connect(logs.append)
@@ -4875,12 +4815,11 @@ class GuiTests(TempDirMixin, unittest.TestCase):
tasks,
db_path=cfg["db_path"],
config=cfg,
parallel_accounts=True,
max_parallel_accounts=2,
).execute()
self.assertTrue(summary["ok"])
self.assertTrue(summary["parallel_accounts"])
self.assertTrue(summary["account_parallel"])
self.assertEqual(2, summary["applied"])
self.assertGreaterEqual(len(thread_names), 2)
self.assertEqual({"alias-a": True, "alias-b": True}, foreground_by_alias)
@@ -4929,7 +4868,6 @@ class GuiTests(TempDirMixin, unittest.TestCase):
tasks,
db_path=cfg["db_path"],
config=cfg,
parallel_accounts=True,
max_parallel_accounts=2,
).execute()