From e70e831afa3e1da4a566ca9840eea704f8c1c87f Mon Sep 17 00:00:00 2001 From: chengma Date: Fri, 10 Jul 2026 10:12:15 +0800 Subject: [PATCH] =?UTF-8?q?T-580=20=E7=B2=BE=E7=AE=80=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E6=9B=B4=E6=96=B0=E6=89=A7=E8=A1=8C=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/appconfig.py | 48 ++++++++-- app/editor.py | 8 +- app/gui/tabs/apply.py | 45 ++-------- app/gui/tabs/settings.py | 65 ++++---------- app/gui/workers.py | 19 ++-- docs/04-architecture.md | 28 +++--- docs/api.md | 26 +++--- docs/routes.md | 34 ++++--- docs/tasks/T-580.md | 20 ++++- tests/test_appconfig.py | 69 +++++++++++++- tests/test_editor_login.py | 6 +- tests/test_gui.py | 178 ++++++++++++------------------------- 12 files changed, 259 insertions(+), 287 deletions(-) diff --git a/app/appconfig.py b/app/appconfig.py index 2c994cc..444eaea 100644 --- a/app/appconfig.py +++ b/app/appconfig.py @@ -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 diff --git a/app/editor.py b/app/editor.py index 1c478a2..e9e3b4c 100644 --- a/app/editor.py +++ b/app/editor.py @@ -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: diff --git a/app/gui/tabs/apply.py b/app/gui/tabs/apply.py index aa9362f..b8cfcd6 100644 --- a/app/gui/tabs/apply.py +++ b/app/gui/tabs/apply.py @@ -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() diff --git a/app/gui/tabs/settings.py b/app/gui/tabs/settings.py index 0f69a50..fa0369b 100644 --- a/app/gui/tabs/settings.py +++ b/app/gui/tabs/settings.py @@ -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() diff --git a/app/gui/workers.py b/app/gui/workers.py index 8383f5d..a360668 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -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, diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 13a0b94..49f9a3e 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -126,14 +126,10 @@ T-538 后统一数据根为 `data/`:打包版默认 `/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/`:打包版默认 `/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///__new. # 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///__new. # AI 生成的新 | Chrome 启动参数 | 全关后带 `--remote-debugging-port= --remote-allow-origins=* --user-data-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///__new. # AI 生成的新 | 更新按钮 | 页面主更新按钮为 `button.eds-button` 中 `更新`;③ 批量确认后逐条点提交;禁用态(校验未过)记为失败。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 是事后追溯手段。 ## 八、推荐开发顺序 diff --git a/docs/api.md b/docs/api.md index b8ba733..b5c464e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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)` 后继续处理。 diff --git a/docs/routes.md b/docs/routes.md index 703cb67..25c0e87 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -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)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 | diff --git a/docs/tasks/T-580.md b/docs/tasks/T-580.md index 286669f..d8b531d 100644 --- a/docs/tasks/T-580.md +++ b/docs/tasks/T-580.md @@ -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`:通过。 diff --git a/tests/test_appconfig.py b/tests/test_appconfig.py index 7126ddd..3b53b62 100644 --- a/tests/test_appconfig.py +++ b/tests/test_appconfig.py @@ -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) diff --git a/tests/test_editor_login.py b/tests/test_editor_login.py index 6b82254..0f28e80 100644 --- a/tests/test_editor_login.py +++ b/tests/test_editor_login.py @@ -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") diff --git a/tests/test_gui.py b/tests/test_gui.py index 33b7688..14c080b 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -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()