From 27f23a473854852f5d3c7d5e8bc51f1cec554dad Mon Sep 17 00:00:00 2001 From: chengma Date: Sat, 4 Jul 2026 16:29:15 +0800 Subject: [PATCH] feat: add cmhub settings panel --- app/ai.py | 24 +++ app/gui/__init__.py | 1 + app/gui/tabs/settings.py | 389 ++++++++++++++++++++++++++++++++++++++- app/gui/workers.py | 146 +++++++++++++++ docs/06-tasks.md | 2 +- docs/current-state.md | 18 +- progress.md | 13 +- tests/test_ai.py | 14 ++ tests/test_gui.py | 227 +++++++++++++++++++++++ 9 files changed, 813 insertions(+), 21 deletions(-) diff --git a/app/ai.py b/app/ai.py index 9dc8336..986eac0 100644 --- a/app/ai.py +++ b/app/ai.py @@ -491,6 +491,30 @@ def fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30): return [copy.deepcopy(model) for model in models if isinstance(model, dict)] +def fetch_cmhub_balance(base_url, api_key, connect_timeout=10, read_timeout=30): + """Fetch cmhub point balance for settings UI.""" + + base_url = str(base_url or "").strip() + api_key = str(api_key or "") + if not base_url: + raise CMHubError("cmhub_not_configured", "请去⑤设置配置 cmhub Base URL") + if not api_key: + raise CMHubError("cmhub_not_configured", "请去⑤设置配置 cmhub API Key") + data = _cmhub_call_with_retry( + "GET", + appconfig.cmhub_request_url(base_url, "/api/v1/balance"), + api_key, + payload=None, + connect_timeout=max(1, int(connect_timeout or 10)), + read_timeout=max(1, int(read_timeout or 30)), + attempts=1, + on_retry=None, + ) + if "points_balance" not in data: + raise CMHubError("bad_response", "cmhub 余额返回格式错误") + return copy.deepcopy(data) + + def _ai_backend(ai_cfg): backend = str(ai_cfg.get("backend", "direct") or "direct").strip().lower() if backend not in appconfig.AI_BACKENDS: diff --git a/app/gui/__init__.py b/app/gui/__init__.py index aa1ec9e..827a580 100644 --- a/app/gui/__init__.py +++ b/app/gui/__init__.py @@ -17,6 +17,7 @@ if QT_IMPORT_ERROR is None: from .workers import ( AccountLoginCheckWorker, AIModelTestWorker, + CMHubSettingsWorker, ApplyWorker, CollectWorker, GenerateWorker, diff --git a/app/gui/tabs/settings.py b/app/gui/tabs/settings.py index 720d23c..5383be3 100644 --- a/app/gui/tabs/settings.py +++ b/app/gui/tabs/settings.py @@ -4,14 +4,26 @@ from __future__ import annotations from ..widgets import * from ..workers import AIModelTestWorker as _RealAIModelTestWorker +from ..workers import CMHubSettingsWorker as _RealCMHubSettingsWorker + + +PLAINTEXT_CMHUB_API_KEY_WARNING = ( + "cmhub API Key 会以本地明文保存到 config/cmhub.json,仅供本机调用 cmhub 网关使用。" + "该文件已 gitignore,UI 打码显示,日志/导出不记录明文。" +) def AIModelTestWorker(*args, **kwargs): return _call_package_attr("AIModelTestWorker", _RealAIModelTestWorker, *args, **kwargs) + +def CMHubSettingsWorker(*args, **kwargs): + return _call_package_attr("CMHubSettingsWorker", _RealCMHubSettingsWorker, *args, **kwargs) + class SettingsTab(QWidget): """Tab 5: AI model definitions stored in config/ai_models.json.""" + BACKEND_ITEMS = [("直连模型", "direct"), ("cmhub 网关", "cmhub")] CATEGORY_ITEMS = [("文本", "text"), ("图像", "image")] API_TYPE_ITEMS = [("chat", "chat"), ("images_edits", "images_edits"), ("auto", "auto")] RESOLUTION_ITEMS = ["512", "1k", "2k", "4k"] @@ -36,13 +48,54 @@ class SettingsTab(QWidget): or self.config.get("ai_models_path") or appconfig.AI_MODELS_PATH ) + self.cmhub_config_path = ( + self.config.get("cmhub_config_path") + or self._default_cmhub_config_path(self.config_path) + ) self.status_callback = status_callback self.models = [] self.current_model_name = None self.test_worker = None self.test_thread = None + self.cmhub_models = [] + self.cmhub_worker = None + self.cmhub_thread = None + self._loaded_cmhub_api_key = "" + self._cmhub_auto_refresh_done = False self._compat_test_item_id = "" + self.backend_combo = QComboBox() + self.backend_combo.setObjectName("aiBackendCombo") + for label, value in self.BACKEND_ITEMS: + self.backend_combo.addItem(label, value) + self.cmhub_base_url_edit = QLineEdit() + self.cmhub_base_url_edit.setObjectName("cmhubBaseUrlEdit") + self.cmhub_base_url_edit.setPlaceholderText("https://") + self.cmhub_api_key_edit = QLineEdit() + self.cmhub_api_key_edit.setObjectName("cmhubApiKeyEdit") + self.cmhub_api_key_edit.setEchoMode(QLineEdit.Password) + self.cmhub_api_key_edit.setPlaceholderText("从 cmhub 网页端复制 API Key") + self.cmhub_title_alias_combo = QComboBox() + self.cmhub_title_alias_combo.setObjectName("cmhubTitleAliasCombo") + self.cmhub_image_alias_combo = QComboBox() + self.cmhub_image_alias_combo.setObjectName("cmhubImageAliasCombo") + self.cmhub_connect_timeout_spin = QSpinBox() + self.cmhub_connect_timeout_spin.setObjectName("cmhubConnectTimeoutSpin") + self.cmhub_connect_timeout_spin.setRange(1, 3600) + self.cmhub_connect_timeout_spin.setValue(10) + self.cmhub_check_balance_checkbox = QCheckBox("批量生成前检查余额") + self.cmhub_check_balance_checkbox.setObjectName("cmhubCheckBalanceCheckbox") + self.cmhub_refresh_button = QPushButton("刷新别名") + self.cmhub_refresh_button.setObjectName("cmhubRefreshButton") + self.cmhub_test_button = QPushButton("测试连接/查余额") + self.cmhub_test_button.setObjectName("cmhubTestButton") + self.cmhub_result_label = QLabel("") + self.cmhub_result_label.setObjectName("cmhubResultLabel") + self.cmhub_result_label.setWordWrap(True) + self.cmhub_key_hint_label = QLabel("API Key 仅在 cmhub 网页端创建时显示一次;复制到此处后会本地明文保存并打码显示。") + self.cmhub_key_hint_label.setObjectName("cmhubKeyHintLabel") + self.cmhub_key_hint_label.setWordWrap(True) + self.model_combo = QComboBox() self.model_combo.setObjectName("aiModelCombo") self.add_model_button = QPushButton("新增") @@ -165,10 +218,15 @@ class SettingsTab(QWidget): ] ) - ai_form = self._three_column_form( + direct_role_form = self._three_column_form( [ ("标题大模型", self.default_text_model_combo), ("图片大模型", self.default_image_model_combo), + ] + ) + + ai_form = self._three_column_form( + [ ("标题并发数", self.title_concurrency_spin), ("图片并发数", self.image_concurrency_spin), ("失败重试次数", self.retry_spin), @@ -208,6 +266,47 @@ class SettingsTab(QWidget): ] ) + backend_form = self._three_column_form([("AI 后端", self.backend_combo)]) + + self.model_picker_panel = QWidget() + self.model_picker_panel.setLayout(model_picker_layout) + + self.model_detail_panel = QWidget() + model_detail_layout = QVBoxLayout(self.model_detail_panel) + model_detail_layout.setContentsMargins(0, 0, 0, 0) + model_detail_layout.setSpacing(8) + model_detail_layout.addLayout(form) + model_detail_layout.addLayout(action_layout) + model_detail_layout.addWidget(self.test_result_label) + + self.direct_role_panel = QWidget() + self.direct_role_panel.setLayout(direct_role_form) + + cmhub_form = self._three_column_form( + [ + ("网关 Base URL", self.cmhub_base_url_edit, True), + ("API Key", self.cmhub_api_key_edit, True), + ("连接超时(秒)", self.cmhub_connect_timeout_spin), + ("生文别名", self.cmhub_title_alias_combo), + ("生图别名", self.cmhub_image_alias_combo), + ("", self.cmhub_check_balance_checkbox), + ] + ) + cmhub_action_layout = QHBoxLayout() + cmhub_action_layout.setContentsMargins(0, 0, 0, 0) + cmhub_action_layout.addWidget(self.cmhub_refresh_button) + cmhub_action_layout.addWidget(self.cmhub_test_button) + cmhub_action_layout.addStretch(1) + self.cmhub_panel = QWidget() + self.cmhub_panel.setObjectName("cmhubSettingsPanel") + cmhub_panel_layout = QVBoxLayout(self.cmhub_panel) + cmhub_panel_layout.setContentsMargins(0, 0, 0, 0) + cmhub_panel_layout.setSpacing(8) + cmhub_panel_layout.addLayout(cmhub_form) + cmhub_panel_layout.addWidget(self.cmhub_key_hint_label) + cmhub_panel_layout.addLayout(cmhub_action_layout) + cmhub_panel_layout.addWidget(self.cmhub_result_label) + panel = QWidget() panel.setMaximumWidth(1800) panel_layout = QVBoxLayout(panel) @@ -234,14 +333,15 @@ class SettingsTab(QWidget): "settingsInfrastructureSectionTitle", ) panel_layout.addWidget(self.ai_model_section_title) - panel_layout.addLayout(model_picker_layout) + panel_layout.addLayout(backend_form) + panel_layout.addWidget(self.model_picker_panel) panel_layout.addSpacing(14) panel_layout.addWidget(self.model_detail_section_title) - panel_layout.addLayout(form) - panel_layout.addLayout(action_layout) - panel_layout.addWidget(self.test_result_label) + panel_layout.addWidget(self.model_detail_panel) + panel_layout.addWidget(self.cmhub_panel) panel_layout.addSpacing(18) panel_layout.addWidget(self.generation_section_title) + panel_layout.addWidget(self.direct_role_panel) panel_layout.addLayout(ai_form) panel_layout.addSpacing(18) panel_layout.addWidget(self.shopee_update_section_title) @@ -271,6 +371,9 @@ class SettingsTab(QWidget): self.delete_model_button.clicked.connect(self.delete_model) self.save_model_button.clicked.connect(self.save_model) self.test_connection_button.clicked.connect(self.test_connection) + self.backend_combo.currentIndexChanged.connect(self._on_backend_changed) + self.cmhub_refresh_button.clicked.connect(self.refresh_cmhub_models) + self.cmhub_test_button.clicked.connect(self.test_cmhub_connection) self.resolution_combo.currentIndexChanged.connect( self._update_response_timeout_label ) @@ -323,10 +426,43 @@ class SettingsTab(QWidget): label.setStyleSheet("color: #24292f; font-weight: 600; padding-top: 4px;") return label + def showEvent(self, event): + super().showEvent(event) + self._maybe_auto_refresh_cmhub_models() + def _set_status(self, message): if self.status_callback is not None: self.status_callback(message) + def _maybe_auto_refresh_cmhub_models(self): + if self._cmhub_auto_refresh_done: + return + if (self.backend_combo.currentData() or "direct") != "cmhub": + return + if not self.cmhub_base_url_edit.text().strip() or not self.cmhub_api_key_edit.text(): + return + self._cmhub_auto_refresh_done = True + self.refresh_cmhub_models() + + def _default_cmhub_config_path(self, config_path): + if config_path and config_path != appconfig.CONFIG_PATH: + return os.path.join( + os.path.dirname(os.path.abspath(config_path)), + "config", + "cmhub.json", + ) + return appconfig.CMHUB_CONFIG_PATH + + def _on_backend_changed(self, index=None): + is_cmhub = (self.backend_combo.currentData() or "direct") == "cmhub" + self.model_picker_panel.setVisible(not is_cmhub) + self.model_detail_section_title.setVisible(not is_cmhub) + self.model_detail_panel.setVisible(not is_cmhub) + self.direct_role_panel.setVisible(not is_cmhub) + self.cmhub_panel.setVisible(is_cmhub) + self._set_cmhub_running(self.cmhub_thread is not None) + self._update_button_state() + def refresh_models(self, selected=None): try: self.models = appconfig.list_ai_models( @@ -459,7 +595,14 @@ class SettingsTab(QWidget): settings = self._app_settings_values() if settings is None: return + cmhub_key = self.cmhub_api_key_edit.text() + if self._should_warn_plaintext_cmhub_api_key(cmhub_key): + self._show_plaintext_cmhub_api_key_warning() try: + appconfig.save_cmhub_config( + {"api_key": cmhub_key}, + path=self.cmhub_config_path, + ) saved = appconfig.save_config(settings, path=self.config_path) except Exception as exc: self._show_error(exc) @@ -479,17 +622,23 @@ class SettingsTab(QWidget): if not (start_port <= default_port <= end_port): self._show_error("默认调试端口必须在调试端口范围内") return None - text_model = self.default_text_model_combo.currentData() - image_model = self.default_image_model_combo.currentData() - if not text_model or not image_model: + ai_cfg = appconfig.ai_config(self.config) + backend = self.backend_combo.currentData() or "direct" + text_model = self.default_text_model_combo.currentData() or ai_cfg.get("default_text_model") + image_model = self.default_image_model_combo.currentData() or ai_cfg.get("default_image_model") + if backend == "direct" and (not text_model or not image_model): self._show_error("标题大模型和图片大模型不能为空") return None + cmhub_cfg = self._cmhub_settings_values(backend) + if cmhub_cfg is None: + return None - ai_cfg = appconfig.ai_config(self.config) ai_cfg.update( { + "backend": backend, "default_text_model": text_model, "default_image_model": image_model, + "cmhub": cmhub_cfg, "title_concurrency": self.title_concurrency_spin.value(), "image_concurrency": self.image_concurrency_spin.value(), "retry": self.retry_spin.value(), @@ -502,7 +651,7 @@ class SettingsTab(QWidget): settings = { key: value for key, value in self.config.items() - if key not in {"config_path", "ai_models_path"} + if key not in {"config_path", "ai_models_path", "cmhub_config_path"} } settings.update( { @@ -528,12 +677,41 @@ class SettingsTab(QWidget): ) return settings + def _cmhub_settings_values(self, backend): + current = appconfig.cmhub_config(self.config) + values = { + "base_url": self.cmhub_base_url_edit.text().strip(), + "title_alias": self.cmhub_title_alias_combo.currentData() or "", + "image_alias": self.cmhub_image_alias_combo.currentData() or "", + "connect_timeout": self.cmhub_connect_timeout_spin.value(), + "check_balance_before_batch": self.cmhub_check_balance_checkbox.isChecked(), + } + if backend != "cmhub": + merged = dict(current) + merged.update(values) + return merged + missing = [] + if not values["base_url"]: + missing.append("Base URL") + if not self.cmhub_api_key_edit.text(): + missing.append("API Key") + if not values["title_alias"]: + missing.append("生文别名") + if not values["image_alias"]: + missing.append("生图别名") + if missing: + self._show_error("cmhub 配置不完整:缺少 " + "、".join(missing)) + return None + return values + def _replace_config(self, saved): internal = {} if self.config_path != appconfig.CONFIG_PATH: internal["config_path"] = self.config_path if self.ai_models_path != appconfig.AI_MODELS_PATH: internal["ai_models_path"] = self.ai_models_path + if self.cmhub_config_path != appconfig.CMHUB_CONFIG_PATH: + internal["cmhub_config_path"] = self.cmhub_config_path self.config.clear() self.config.update(saved) self.config.update(internal) @@ -541,6 +719,23 @@ class SettingsTab(QWidget): def _populate_app_settings(self): self._populate_role_model_combos() ai_cfg = appconfig.ai_config(self.config) + backend = ai_cfg.get("backend", "direct") + self._set_combo_by_data(self.backend_combo, backend) + cmhub_cfg = appconfig.cmhub_config(self.config) + self.cmhub_base_url_edit.setText(cmhub_cfg.get("base_url", "")) + self._loaded_cmhub_api_key = appconfig.get_cmhub_api_key(path=self.cmhub_config_path) + self.cmhub_api_key_edit.setText(self._loaded_cmhub_api_key) + self.cmhub_connect_timeout_spin.setValue( + max(1, int(cmhub_cfg.get("connect_timeout", 10) or 10)) + ) + self.cmhub_check_balance_checkbox.setChecked( + bool(cmhub_cfg.get("check_balance_before_batch", False)) + ) + self._populate_cmhub_alias_combos( + self.cmhub_models, + title_selected=cmhub_cfg.get("title_alias", ""), + image_selected=cmhub_cfg.get("image_alias", ""), + ) self._set_combo_by_data( self.default_text_model_combo, ai_cfg.get("default_text_model", ""), @@ -595,6 +790,7 @@ class SettingsTab(QWidget): max(1, int(update_cfg.get("max_parallel_accounts", 2) or 2)) ) self._update_response_timeout_label() + self._on_backend_changed() def _shopee_update_config(self): defaults = appconfig.default_config().get("shopee_update", {}) @@ -753,6 +949,168 @@ class SettingsTab(QWidget): self.test_result_label.setText(message) self._set_status(message) + def refresh_cmhub_models(self, checked=False): + self._start_cmhub_worker(include_balance=False) + + def test_cmhub_connection(self, checked=False): + self._start_cmhub_worker(include_balance=True) + + def _start_cmhub_worker(self, include_balance): + if self.cmhub_thread is not None: + self._set_status("cmhub 检测正在进行...") + return + base_url = self.cmhub_base_url_edit.text().strip() + api_key = self.cmhub_api_key_edit.text() + missing = [] + if not base_url: + missing.append("Base URL") + if not api_key: + missing.append("API Key") + if missing: + self._show_error("cmhub 配置不完整:缺少 " + "、".join(missing)) + return + worker = CMHubSettingsWorker( + base_url, + api_key, + connect_timeout=self.cmhub_connect_timeout_spin.value(), + include_balance=include_balance, + db_path=_database_path(config=self.config), + diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, + ) + worker.finished.connect(self._on_cmhub_finished) + worker.failed.connect(self._on_cmhub_failed) + thread = run_worker(worker, thread_name="CMHubSettingsWorker", start=False) + thread.finished.connect(lambda: self._forget_cmhub_thread(thread)) + self.cmhub_worker = worker + self.cmhub_thread = thread + self._set_cmhub_running(True) + message = "正在测试 cmhub 连接并查询余额..." if include_balance else "正在刷新 cmhub 别名..." + self.cmhub_result_label.setText(message) + self._set_status(message) + thread.start() + + def _set_cmhub_running(self, running): + enabled = not running + for widget in ( + self.cmhub_base_url_edit, + self.cmhub_api_key_edit, + self.cmhub_title_alias_combo, + self.cmhub_image_alias_combo, + self.cmhub_connect_timeout_spin, + self.cmhub_check_balance_checkbox, + self.cmhub_refresh_button, + self.cmhub_test_button, + ): + widget.setEnabled(enabled) + + def _forget_cmhub_thread(self, thread): + if self.cmhub_thread is thread: + self.cmhub_thread = None + self.cmhub_worker = None + self._set_cmhub_running(False) + + def _on_cmhub_finished(self, payload): + if not payload.get("ok", True): + self._on_cmhub_failed(-1, payload.get("error") or "连接失败") + return + models = [model for model in payload.get("models", []) if isinstance(model, dict)] + self.cmhub_models = models + current_cfg = appconfig.cmhub_config(self.config) + title_selected = self.cmhub_title_alias_combo.currentData() or current_cfg.get("title_alias", "") + image_selected = self.cmhub_image_alias_combo.currentData() or current_cfg.get("image_alias", "") + self._populate_cmhub_alias_combos( + models, + title_selected=title_selected, + image_selected=image_selected, + ) + title_count = self._cmhub_alias_count("title") + image_count = self._cmhub_alias_count("image") + balance = payload.get("points_balance") + balance_text = f";余额 {balance}" if balance is not None else "" + message = f"cmhub 连接成功:生文别名 {title_count} 个,生图别名 {image_count} 个{balance_text}" + self.cmhub_result_label.setText(message) + self._set_status(message) + + def _on_cmhub_failed(self, _task_id, error): + message = f"cmhub 连接失败:{error}" + self.cmhub_result_label.setText(message) + self._set_status(message) + + def _populate_cmhub_alias_combos(self, models, title_selected="", image_selected=""): + self._populate_cmhub_alias_combo( + self.cmhub_title_alias_combo, + models, + "title", + title_selected, + ) + self._populate_cmhub_alias_combo( + self.cmhub_image_alias_combo, + models, + "image", + image_selected, + ) + + def _populate_cmhub_alias_combo(self, combo, models, operation, selected): + combo.blockSignals(True) + combo.clear() + selected = str(selected or "").strip() + added = set() + for model in self._cmhub_priced_models(models, operation): + alias = str(model.get("alias") or "").strip() + if not alias or alias in added: + continue + combo.addItem(self._cmhub_alias_label(model), alias) + added.add(alias) + if selected and selected not in added: + combo.addItem(f"{selected}(已保存)", selected) + if combo.count() == 0: + combo.addItem("无可用别名", None) + index = combo.findData(selected) + combo.setCurrentIndex(index if index >= 0 else 0) + combo.blockSignals(False) + + def _cmhub_priced_models(self, models, operation): + items = [] + for model in models or []: + if not isinstance(model, dict): + continue + alias = str(model.get("alias") or "").strip() + op = str(model.get("operation_type") or "").lower() + pricing_status = str(model.get("pricing_status") or "").lower() + if alias and op == operation and pricing_status != "unpriced": + items.append(model) + return items + + def _cmhub_alias_label(self, model): + alias = str(model.get("alias") or "").strip() + price_text = self._cmhub_price_text(model.get("prices")) + parts = [alias] + if price_text: + parts.append(price_text) + if model.get("requires_image"): + parts.append("需参考图") + return " · ".join(parts) + + def _cmhub_price_text(self, prices): + if not isinstance(prices, list): + return "" + parts = [] + for item in prices[:3]: + if not isinstance(item, dict): + continue + cost = item.get("points_cost") + if cost is None: + cost = item.get("cost") + if cost is None: + continue + resolution = item.get("resolution") or item.get("name") or "" + parts.append(f"{resolution}:{cost}点" if resolution else f"{cost}点") + return "/".join(parts) + + def _cmhub_alias_count(self, operation): + combo = self.cmhub_title_alias_combo if operation == "title" else self.cmhub_image_alias_combo + return sum(1 for index in range(combo.count()) if combo.itemData(index)) + def _show_error(self, error): message = str(error) QMessageBox.warning(self, "设置", message) @@ -763,6 +1121,10 @@ class SettingsTab(QWidget): current_key = str((current or {}).get("api_key") or "") return bool(new_key) and new_key != current_key + def _should_warn_plaintext_cmhub_api_key(self, key): + new_key = str(key or "") + return bool(new_key) and new_key != str(self._loaded_cmhub_api_key or "") + def _show_plaintext_api_key_warning(self): QMessageBox.warning( self, @@ -770,6 +1132,13 @@ class SettingsTab(QWidget): PLAINTEXT_API_KEY_WARNING, ) + def _show_plaintext_cmhub_api_key_warning(self): + QMessageBox.warning( + self, + PLAINTEXT_SECRET_TITLE, + PLAINTEXT_CMHUB_API_KEY_WARNING, + ) + def _current_model(self): return self._model_by_name(self.current_model_name) diff --git a/app/gui/workers.py b/app/gui/workers.py index ce200db..c5c6431 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -1661,6 +1661,152 @@ class AccountLoginCheckWorker(BaseWorker): def _elapsed_ms(self, started): return _elapsed_ms(started) +class CMHubSettingsWorker(BaseWorker): + """Fetch cmhub aliases and optional balance without blocking the GUI.""" + + def __init__( + self, + base_url, + api_key, + connect_timeout=10, + include_balance=True, + db_path=None, + diagnostic_log_dir=None, + ): + super().__init__() + self.base_url = str(base_url or "").strip() + self.api_key = str(api_key or "") + self.connect_timeout = max(1, int(connect_timeout or 10)) + self.include_balance = bool(include_balance) + self.db_path = db_path + self.diagnostic_log_dir = diagnostic_log_dir + self._run_id = None + + def execute(self): + self._run_id = self._create_run_log() + started = time.monotonic() + action = "测试连接/查余额" if self.include_balance else "刷新别名" + self._log_run_event(f"step=cmhub_settings result=start detail={action}") + try: + models = ai.fetch_cmhub_models( + self.base_url, + self.api_key, + connect_timeout=self.connect_timeout, + ) + balance = None + if self.include_balance: + balance = ai.fetch_cmhub_balance( + self.base_url, + self.api_key, + connect_timeout=self.connect_timeout, + ) + except Exception as exc: + error = self._safe_error(exc) + elapsed_ms = self._elapsed_ms(started) + self._log_run_event( + f"step=cmhub_settings result=failed detail={error} elapsed_ms={elapsed_ms}", + level="error", + ) + self._write_diagnostic_log( + "cmhub 设置检测失败", + level="ERROR", + step="cmhub_settings", + elapsed_ms=elapsed_ms, + payload={"base_url": self.base_url, "error": error}, + exc=exc, + ) + _safe_finish_run_log( + self._run_id, + db_path=self.db_path, + status="failed", + done=0, + failed_count=1, + summary_json={"ok": False, "error": error}, + ) + raise RuntimeError(error) from exc + + elapsed_ms = self._elapsed_ms(started) + payload = { + "ok": True, + "models": appconfig.sanitize_for_log(models), + "balance": appconfig.sanitize_for_log(balance or {}), + "points_balance": (balance or {}).get("points_balance"), + } + title_count = self._priced_count(models, "title") + image_count = self._priced_count(models, "image") + self._log_run_event( + "step=cmhub_settings result=success detail=title_aliases={title_count} image_aliases={image_count} points_balance={points_balance} elapsed_ms={elapsed_ms}".format( + title_count=title_count, + image_count=image_count, + points_balance=payload.get("points_balance") if payload.get("points_balance") is not None else "", + elapsed_ms=elapsed_ms, + ) + ) + _safe_finish_run_log( + self._run_id, + db_path=self.db_path, + status="done", + done=1, + success_count=1, + summary_json=payload, + ) + return payload + + def _priced_count(self, models, operation): + return sum( + 1 + for model in models or [] + if str(model.get("operation_type") or "").lower() == operation + and str(model.get("pricing_status") or "").lower() != "unpriced" + and str(model.get("alias") or "").strip() + ) + + def _create_run_log(self): + if not self.db_path: + return None + return _safe_create_run_log( + "cmhub_settings_test", + db_path=self.db_path, + total=1, + options={"base_url": self.base_url, "include_balance": self.include_balance}, + ) + + def _log_run_event(self, message, level="info"): + safe_message = _safe_add_run_log_event( + self._run_id, + message, + db_path=self.db_path, + level=level, + ) + self.log.emit(str(safe_message)) + + def _write_diagnostic_log( + self, + message, + level="INFO", + step=None, + elapsed_ms=None, + payload=None, + exc=None, + ): + _safe_write_diagnostic_log( + message, + level=level, + step=step, + elapsed_ms=elapsed_ms, + payload=payload, + exc=exc, + log_dir=self.diagnostic_log_dir, + ) + + def _safe_error(self, exc): + raw = str(exc) or exc.__class__.__name__ + redacted = appconfig.redact_secrets(raw, [self.api_key]) + return diagnostics.redact_log_text(redacted) + + def _elapsed_ms(self, started): + return _elapsed_ms(started) + class AIModelTestWorker(BaseWorker): """Test one AI model connection without blocking the GUI thread.""" diff --git a/docs/06-tasks.md b/docs/06-tasks.md index b480a26..6fa758b 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -125,7 +125,7 @@ | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | | T-526 | `app/ai.py` + `appconfig` 接入 cmhub backend | T-301, T-303, T-520 | 依据 `docs/cmhub-integration-design.md` v3.2。`config.json` 的 `ai` 段加 `backend`(`cmhub`/`direct`)+ `cmhub` 子段(`base_url`/`title_alias`/`image_alias`/`connect_timeout` 等);为保护既有用户,全新配置默认 `backend=direct`、加载既有配置缺 `backend` 时也补 `direct`,`cmhub` 一律由用户在⑤显式 opt-in;`backend=cmhub` 但 `base_url`/Key 缺失时须抛清晰「请去⑤配置 cmhub」错误(`CMHubError`/`AIError`),不崩溃、不静默直连。cmhub API Key 固定存 `config/cmhub.json`(schema `{ "api_key": "..." }`),新增读写/打码 helper 并把该文件加入 `.gitignore`;日志脱敏。`gen_title`/`gen_cover` **返回值不变**,内层按 backend 分流并保留 `direct`;计费元数据不塞进返回值,允许给二者**新增一个可选事件回调参数**(如 `on_meta`/`on_event`)承载,属向后兼容加参,`generate_batch` 显式传回调不受影响。cmhub 分支:生文 `POST /api/v1/generate/title` 体 `{prompt,model:别名,resolution?}`、取 `titles[0]`、空则 `AIError`;生图 `POST /api/v1/generate/image` 体 `{prompt,model:别名,image_base64:<旧封面>,resolution,aspect_ratio:"1:1"}`、拿 `image_url` 后**立即下载**再走 `_save_jpeg`;`resolution` 归一大写 `512/1K/2K/4K`。新增 `CMHubError(AIError)`,带 `code/status/retryable/retry_after`,错误按 `code` 优先分支(`insufficient_points`/`unauthorized`/`account_disabled`/`bad_request`/`model_not_allowed`/`no_pricing_rule`/`content_blocked`/`upstream_error`/`rate_limited`,未知 code 当不可重试);cmhub HTTP helper 需区分 connect/read timeout(优先用 `requests timeout=(connect, read)`),只对 502/429/连接超时重试,生图读超时绝不自动重发,读超时按分辨率封顶 600s。`points_cost`/`points_balance`/`call_id` 不改返回值,通过 `on_step`/事件回调上报;T-526 只保证 metadata 事件完整传出,T-528 再由 GUI worker 脱敏写 run_logs 和余额展示。`image_url` 下载必须限制 http/https、拒绝内网/回环地址、校验域名解析后的 IP 仍不是内网/回环/本机地址,并设置超时和大小上限。新增 `fetch_cmhub_models(base_url, api_key)` helper 调 `GET /api/v1/models` 返回别名清单(`alias/operation_type/requires_image/pricing_status/prices`)供 T-527 渲染下拉,错误脱敏。不碰 editor/cdp/chrome/accounts/excel/db,也不改 ①③④流程。`tests/test_ai.py` 加 cmhub mock(titles 列表、image_url 下载、安全下载、错误码与重试、读超时不重发、metadata 事件)、`tests/test_appconfig.py` 加 schema 和 key 文件 helper,direct 用例保持绿 | DONE | -| T-527 | ⑤设置 cmhub 网关面板 | T-526, T-517 | 依据 `docs/cmhub-integration-design.md` v3.2。⑤ AI 设置按 `backend` 切换:cmhub 模式显示「网关 Base URL + API Key(打码,提示从网页端复制、仅显示一次)+ 生文别名 + 生图别名 + 测试连接/查余额」;**别名从 `GET /api/v1/models`(T-526 的 `fetch_cmhub_models`)动态拉取渲染下拉**,按 `operation_type` 分生文/生图,过滤 `pricing_status="unpriced"` 的别名,可展示单价与 `requires_image` 提示,选中值持久化到 `ai.cmhub.title_alias/image_alias`(网关临时不可达时回退已存值);不写死别名。direct 模式保留现有 AI 模型 master-detail。保存写 `config.json` 的 `ai` 段与 `config/cmhub.json`;切换 backend 时不删除 legacy `config/ai_models.json`。测试连接/查余额经后台 worker 调 cmhub(复用 `AIModelTestWorker` 思路或新增 worker),错误必须脱敏并给用户可读提示。同步 GUI 设置测试;不改 Shopee/CDP 流程 | TODO | +| T-527 | ⑤设置 cmhub 网关面板 | T-526, T-517 | 依据 `docs/cmhub-integration-design.md` v3.2。⑤ AI 设置按 `backend` 切换:cmhub 模式显示「网关 Base URL + API Key(打码,提示从网页端复制、仅显示一次)+ 生文别名 + 生图别名 + 测试连接/查余额」;**别名从 `GET /api/v1/models`(T-526 的 `fetch_cmhub_models`)动态拉取渲染下拉**,按 `operation_type` 分生文/生图,过滤 `pricing_status="unpriced"` 的别名,可展示单价与 `requires_image` 提示,选中值持久化到 `ai.cmhub.title_alias/image_alias`(网关临时不可达时回退已存值);不写死别名。direct 模式保留现有 AI 模型 master-detail。保存写 `config.json` 的 `ai` 段与 `config/cmhub.json`;切换 backend 时不删除 legacy `config/ai_models.json`。测试连接/查余额经后台 worker 调 cmhub(复用 `AIModelTestWorker` 思路或新增 worker),错误必须脱敏并给用户可读提示。同步 GUI 设置测试;不改 Shopee/CDP 流程 | DONE | | T-528 | ② 计费错误提示 + 余额展示 | T-526, T-527, T-303 | 依据 `docs/cmhub-integration-design.md` v3.2。② AI生成页把 cmhub 计费失败态显式化:通过 `CMHubError.code` 识别 `insufficient_points`,弹明确提示「点数不足,请先充值」并引导去网页端充值,本轮未开始任务可提前中止,不靠中文字符串匹配、不淹没在失败计数里;用 T-526 成功响应事件里的 `points_balance` 刷新②页剩余点数显示,`/balance` 仅作手动刷新/可选批量前预检;`points_cost`/`call_id` 记入脱敏 run_logs。只改② UI、`GenerateWorker` 事件处理/文案及 GUI 单测;不改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | TODO | ## Phase 8 · 工程基础设施后续(`docs/engineering-review.md`) diff --git a/docs/current-state.md b/docs/current-state.md index 79745b1..0dbe5a5 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,10 +6,10 @@ ## 当前快照 - 日期:2026-07-04 -- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-103b 登录检测补充识别 Shopee accounts 登录页、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、T-106 账号快捷方式、T-201 Excel 导入入库、T-202 Tab① 任务列表与导入按钮、T-202b Tab① 导入汇总栏、T-203 采集旧标题旧封面、T-204 回写旧字段到原 Excel、T-204b 采集完成自动回写、T-205 首次未配账号/Chrome 未启动/未登录引导保护、T-205b 采集后关闭自动新建商品页 tab、T-206 Tab① 删除指定批次软删除、T-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-303b ②/③ 商品ID筛选、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI、T-501b Tab⑤ 角色与生成参数、T-501c Tab⑤ Shopee 更新安全开关、T-502 换封面删第一张再上传、T-503 敏感信息本地明文保存提示与日志脱敏、T-504 多账号并行/dry-run/运行日志、T-207 ①采集诊断日志、② AI生成图片失败诊断日志补丁、T-404a ②/③ 选中记录重置、T-404 真实 Shopee 单条更新冒烟验收、T-506 正式使用批量更新体验、T-507 正式批量更新移除普通流程测试商品 ID 限制、T-508 ③ 更新shopee生产化操作区、T-509 ② 新标题人工微调、T-510 ③ 检查本轮更新文案统一、T-505 全流程诊断日志扩展、T-511 语义色板与任务状态列上色、T-512 ③高风险按钮上色与①导入校验数字标红、T-513 登录点/③Tab危险标识/破坏性按钮上色、T-514 ①②③ 首次空状态引导卡片、T-515 批次阶段进度总览、T-516 ①筛选对齐②③、T-517 ⑤设置分区与兼容字段清理、T-518 ②左栏提示词区组件密度优化、T-519 ②AI生成长任务进度条与用户可读滚动日志、T-520 ②AI生成封面可选生成开关、T-521 依赖清单(锁版本 requirements)、T-522 CI 自动跑语法 + 单元 / GUI 测试、T-523 拆分 `app/gui.py` 为 `app/gui/` 包、T-523a ②/③ 新一轮运行前清空界面日志显示、T-404b 商品详情页加载失败 toast 自动捕获、T-524 PyInstaller 打包为免安装 exe、T-526 `app/ai.py` + `appconfig` 接入 cmhub backend。 +- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-103b 登录检测补充识别 Shopee accounts 登录页、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、T-106 账号快捷方式、T-201 Excel 导入入库、T-202 Tab① 任务列表与导入按钮、T-202b Tab① 导入汇总栏、T-203 采集旧标题旧封面、T-204 回写旧字段到原 Excel、T-204b 采集完成自动回写、T-205 首次未配账号/Chrome 未启动/未登录引导保护、T-205b 采集后关闭自动新建商品页 tab、T-206 Tab① 删除指定批次软删除、T-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-303b ②/③ 商品ID筛选、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI、T-501b Tab⑤ 角色与生成参数、T-501c Tab⑤ Shopee 更新安全开关、T-502 换封面删第一张再上传、T-503 敏感信息本地明文保存提示与日志脱敏、T-504 多账号并行/dry-run/运行日志、T-207 ①采集诊断日志、② AI生成图片失败诊断日志补丁、T-404a ②/③ 选中记录重置、T-404 真实 Shopee 单条更新冒烟验收、T-506 正式使用批量更新体验、T-507 正式批量更新移除普通流程测试商品 ID 限制、T-508 ③ 更新shopee生产化操作区、T-509 ② 新标题人工微调、T-510 ③ 检查本轮更新文案统一、T-505 全流程诊断日志扩展、T-511 语义色板与任务状态列上色、T-512 ③高风险按钮上色与①导入校验数字标红、T-513 登录点/③Tab危险标识/破坏性按钮上色、T-514 ①②③ 首次空状态引导卡片、T-515 批次阶段进度总览、T-516 ①筛选对齐②③、T-517 ⑤设置分区与兼容字段清理、T-518 ②左栏提示词区组件密度优化、T-519 ②AI生成长任务进度条与用户可读滚动日志、T-520 ②AI生成封面可选生成开关、T-521 依赖清单(锁版本 requirements)、T-522 CI 自动跑语法 + 单元 / GUI 测试、T-523 拆分 `app/gui.py` 为 `app/gui/` 包、T-523a ②/③ 新一轮运行前清空界面日志显示、T-404b 商品详情页加载失败 toast 自动捕获、T-524 PyInstaller 打包为免安装 exe、T-526 `app/ai.py` + `appconfig` 接入 cmhub backend、T-527 ⑤设置 cmhub 网关面板。 - 技术栈:Python 3.10+,根目录 `requirements.txt` 锁定运行依赖,GitHub Actions 使用 Windows + Python 3.11 自动跑语法和单元/GUI 测试,PyInstaller onedir 打包为 Windows 免安装 exe;自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(direct: `config/ai_models.json` 通用 HTTP;cmhub: `config.json` backend + `config/cmhub.json` Key),GUI PySide6 5 Tab(已定)。 -- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,打开商品详情页时会安装 toast 捕获并在详情页加载失败时上浮商品失效/无权限等错误原因,`open_product()` 内部失败会关闭本轮自动新建的失败 tab,复用用户已有 tab 不关闭,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页,确认后跳回商品列表页时关闭前等待 2 秒;`click_update()` 已补 Shopee 站点侧确认框处理,页面主「更新」后若出现 `確定您要更新商品嗎?` modal(`.eds-modal__content` / `.eds-modal__box`),只点击弹窗主按钮「更新」并避开「立即優化」,且记录确认后是否跳回商品列表页;`replace_cover()` 已实现更新封面统一先校验本地旧封面备份,再点第一张删除、可见确认框、等待图片管理器稳定和上传 input 恢复,随后先点击上传块模拟人工入口、短暂等待、重新获取 input、注入文件上传新图并等待 Shopee CDN 后拖到第一位的代码路径;已修正 2026-06-30 稳定等待回归:删除第一张前不要求上传 input 可用,只等图片列表稳定;删除后再等上传入口恢复;`重複/重复/duplicate` 上传 toast 会立即判为新封面重复错误;`app/image_paths.py` 已统一新采集/新生成图片路径为 `images///__old/new.jpg`,历史 DB 路径继续按原路径读取;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取、Shopee 更新安全与执行模式默认值,`config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接与 OpenAI-compatible base URL 自动补 endpoint,`config/cmhub.json` cmhub Key 读写/打码 helper,以及 `mask_secret()`、`sanitize_for_log()`、`redact_secrets()` 敏感信息脱敏工具;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,支持 direct 通用 HTTP 和 cmhub 网关 backend,direct 按默认文本/图片模型调用,cmhub 按 Base URL + 生文/生图别名调用 title/image/models 接口,生图 `image_url` 安全下载后转本地 JPEG,计费 metadata 通过事件回调传出,且保留重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、按 `ai.generate_cover` 选择只生成标题或先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed`、步骤级事件/错误回调与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数、T-206 批次软删除标记与默认业务查询过滤、T-404a 本地生成结果/更新状态重置函数、T-509 `update_generated_title()` 本地新标题微调函数,以及 `run_logs/run_log_events` 运行日志函数;`app/diagnostics.py` 已实现 gitignore 本地诊断日志、滚动写入、结构化 payload 和自由文本脱敏;T-505 已把 Excel 导入/回写、③更新shopee、④Chrome 启动/登录检测、⑤AI模型测试连接接入 `run_logs/run_log_events` 与本地脱敏诊断日志;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui/` 已由 T-523 拆分为 PySide6 GUI 包,包入口 `__init__.py` 兼容旧导入,`main_window.py` 放 `MainWindow`,`models.py` 放 3 个 TableModel,`widgets.py` 放色板/空状态/批次总览/helper,`workers.py` 放具体 GUI worker,`tabs/` 放 ①~⑤ Tab;整体仍实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、统一语义色板、①②③任务状态列前景色、③「开始更新」warning 描边/文字色和①导入校验数字标红、④登录状态点上色、③更新shopee Tab warning 小圆点和删除类按钮 danger 样式、①②③首次空状态引导卡片、①②③批次阶段进度总览、① 导入采集的 Excel 导入按钮/导入汇总栏/批次筛选与删除批次软删除入口/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker/采集运行日志视图、② AI生成左右布局/标题与封面提示词管理/批次/店铺/商品ID/状态筛选/任务列表/新标题列本地微调/变量预览/生成封面图片成本开关/开始生成/停止/标题与图片双进度条/双击新旧封面预览/用户可读自动滚动AI生成运行日志/重置生成结果与 `GenerateWorker`、③ 更新shopee批次/店铺/商品ID/状态筛选栏/任务列表/开始更新主按钮/重置更新状态右键菜单/更新安全开关拦截与「前往设置」跳转/「检查本轮更新」按钮/开始更新确认弹窗/确认后 `ApplyWorker` 按每批最大更新条数分批执行当前筛选全部可更新记录/账号就绪和端口冲突预检/按账号并行可选/逐条 `set_applied`/运行日志/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、密码明文保存提示、⑤ 设置 AI 模型同列上下布局、居中内容区、三列详情/参数表单、API Key 明文保存提示、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置、Shopee 更新安全与多账号并行设置并持久化 `config.json`,保存成功后弹轻量提示框;⑤ 设置页已将「Shopee 更新安全 / 执行模式」前置、将「基础设施(路径与端口)」后置,`test_item_id` 与 `dry_run` 不再有用户可操作控件,保存时保留 `test_item_id` 兼容值并固定 `dry_run=false`;③ 普通正式更新不再用 `test_item_id` 阻断非测试商品,确认弹窗不再显示测试商品 ID;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 -- 测试:`tests/` 已建立;T-522 已新增 GitHub Actions 在 push / pull_request 自动运行语法检查与全量 unittest;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测(含 Shopee accounts 登录页)与商品 tab 生命周期、商品详情页失效/隐藏 toast 捕获、更新成功后关闭本轮新开 tab、更新封面备份缺失阻断/8张与9张先删第一张/删除确认/上传入口初始不可用/重复图片 toast/物流错误不阻断上传/删图后稳定等待/上传状态诊断/拖首位 mock 路径/Shopee 更新确认框、成功跳回商品列表和残留错误 toast 不覆盖成功 mock 路径/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集、删除批次软删除与采集诊断日志/gui ② AI生成布局与批次/店铺/商品ID/状态筛选、提示词管理、生成 worker、图片失败诊断日志、双击预览、重置生成结果与新标题本地编辑/gui ③ 更新shopee批次/店铺/商品ID/状态筛选列表、确认弹窗、开始更新主按钮、重置更新状态右键菜单、Shopee 更新安全拦截与前往设置、`ApplyWorker` 串行/检查/分批/按账号并行/端口冲突预检、运行日志、结果回写与汇总/gui ④ 账号管理、密码打码与明文保存提示/gui ⑤ AI 模型管理、API Key 打码与明文保存提示、角色/生成参数设置、Shopee 更新安全设置、检查按钮/每批最大更新条数/多账号并行设置、测试商品 ID 限制移除/worker signal 与线程包装、T-505 全流程诊断日志(import/write_back/apply/chrome_launch/login_check/ai_model_test)与本地日志脱敏、gui T-511 语义色板和任务状态列前景色、T-512 高风险按钮与导入校验数字样式、T-513 登录状态/Tab 标识/删除按钮样式、T-514 首次空状态引导卡片、T-515 批次阶段进度总览、T-516 ①筛选对齐②③、T-517 ⑤设置分区与兼容字段清理、T-518 ②左栏提示词区组件密度优化、T-519 ②AI生成长任务进度条与用户可读滚动日志、T-520 ②AI生成封面可选生成开关、T-524 PyInstaller 打包入口/构建脚本/发布目录校验、T-526 cmhub backend mock;2026-07-01 已完成 5 个真实商品的 T-404 更新验收,后续 CDP/Shopee 改动仍需测试商品手动验证。 +- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,打开商品详情页时会安装 toast 捕获并在详情页加载失败时上浮商品失效/无权限等错误原因,`open_product()` 内部失败会关闭本轮自动新建的失败 tab,复用用户已有 tab 不关闭,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页,确认后跳回商品列表页时关闭前等待 2 秒;`click_update()` 已补 Shopee 站点侧确认框处理,页面主「更新」后若出现 `確定您要更新商品嗎?` modal(`.eds-modal__content` / `.eds-modal__box`),只点击弹窗主按钮「更新」并避开「立即優化」,且记录确认后是否跳回商品列表页;`replace_cover()` 已实现更新封面统一先校验本地旧封面备份,再点第一张删除、可见确认框、等待图片管理器稳定和上传 input 恢复,随后先点击上传块模拟人工入口、短暂等待、重新获取 input、注入文件上传新图并等待 Shopee CDN 后拖到第一位的代码路径;已修正 2026-06-30 稳定等待回归:删除第一张前不要求上传 input 可用,只等图片列表稳定;删除后再等上传入口恢复;`重複/重复/duplicate` 上传 toast 会立即判为新封面重复错误;`app/image_paths.py` 已统一新采集/新生成图片路径为 `images///__old/new.jpg`,历史 DB 路径继续按原路径读取;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取、Shopee 更新安全与执行模式默认值,`config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接与 OpenAI-compatible base URL 自动补 endpoint,`config/cmhub.json` cmhub Key 读写/打码 helper,以及 `mask_secret()`、`sanitize_for_log()`、`redact_secrets()` 敏感信息脱敏工具;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,支持 direct 通用 HTTP 和 cmhub 网关 backend,direct 按默认文本/图片模型调用,cmhub 按 Base URL + 生文/生图别名调用 title/image/models/balance 接口,生图 `image_url` 安全下载后转本地 JPEG,计费 metadata 通过事件回调传出,且保留重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、按 `ai.generate_cover` 选择只生成标题或先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed`、步骤级事件/错误回调与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数、T-206 批次软删除标记与默认业务查询过滤、T-404a 本地生成结果/更新状态重置函数、T-509 `update_generated_title()` 本地新标题微调函数,以及 `run_logs/run_log_events` 运行日志函数;`app/diagnostics.py` 已实现 gitignore 本地诊断日志、滚动写入、结构化 payload 和自由文本脱敏;T-505 已把 Excel 导入/回写、③更新shopee、④Chrome 启动/登录检测、⑤AI模型测试连接接入 `run_logs/run_log_events` 与本地脱敏诊断日志;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui/` 已由 T-523 拆分为 PySide6 GUI 包,包入口 `__init__.py` 兼容旧导入,`main_window.py` 放 `MainWindow`,`models.py` 放 3 个 TableModel,`widgets.py` 放色板/空状态/批次总览/helper,`workers.py` 放具体 GUI worker,`tabs/` 放 ①~⑤ Tab;整体仍实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、统一语义色板、①②③任务状态列前景色、③「开始更新」warning 描边/文字色和①导入校验数字标红、④登录状态点上色、③更新shopee Tab warning 小圆点和删除类按钮 danger 样式、①②③首次空状态引导卡片、①②③批次阶段进度总览、① 导入采集的 Excel 导入按钮/导入汇总栏/批次筛选与删除批次软删除入口/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker/采集运行日志视图、② AI生成左右布局/标题与封面提示词管理/批次/店铺/商品ID/状态筛选/任务列表/新标题列本地微调/变量预览/生成封面图片成本开关/开始生成/停止/标题与图片双进度条/双击新旧封面预览/用户可读自动滚动AI生成运行日志/重置生成结果与 `GenerateWorker`、③ 更新shopee批次/店铺/商品ID/状态筛选栏/任务列表/开始更新主按钮/重置更新状态右键菜单/更新安全开关拦截与「前往设置」跳转/「检查本轮更新」按钮/开始更新确认弹窗/确认后 `ApplyWorker` 按每批最大更新条数分批执行当前筛选全部可更新记录/账号就绪和端口冲突预检/按账号并行可选/逐条 `set_applied`/运行日志/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、密码明文保存提示、⑤ 设置按 AI backend 切换:direct 模式保留 AI 模型同列上下布局、三列详情/参数表单、API Key 明文保存提示和 `AIModelTestWorker` 后台测试连接,cmhub 模式显示网关 Base URL、API Key、动态生文/生图别名下拉、刷新别名与测试连接/查余额;通用角色/生成参数、路径/端口、Shopee 更新安全与多账号并行设置继续持久化 `config.json`,cmhub Key 单独写入 `config/cmhub.json`,保存成功后弹轻量提示框;⑤ 设置页已将「Shopee 更新安全 / 执行模式」前置、将「基础设施(路径与端口)」后置,`test_item_id` 与 `dry_run` 不再有用户可操作控件,保存时保留 `test_item_id` 兼容值并固定 `dry_run=false`;③ 普通正式更新不再用 `test_item_id` 阻断非测试商品,确认弹窗不再显示测试商品 ID;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 +- 测试:`tests/` 已建立;T-522 已新增 GitHub Actions 在 push / pull_request 自动运行语法检查与全量 unittest;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测(含 Shopee accounts 登录页)与商品 tab 生命周期、商品详情页失效/隐藏 toast 捕获、更新成功后关闭本轮新开 tab、更新封面备份缺失阻断/8张与9张先删第一张/删除确认/上传入口初始不可用/重复图片 toast/物流错误不阻断上传/删图后稳定等待/上传状态诊断/拖首位 mock 路径/Shopee 更新确认框、成功跳回商品列表和残留错误 toast 不覆盖成功 mock 路径/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集、删除批次软删除与采集诊断日志/gui ② AI生成布局与批次/店铺/商品ID/状态筛选、提示词管理、生成 worker、图片失败诊断日志、双击预览、重置生成结果与新标题本地编辑/gui ③ 更新shopee批次/店铺/商品ID/状态筛选列表、确认弹窗、开始更新主按钮、重置更新状态右键菜单、Shopee 更新安全拦截与前往设置、`ApplyWorker` 串行/检查/分批/按账号并行/端口冲突预检、运行日志、结果回写与汇总/gui ④ 账号管理、密码打码与明文保存提示/gui ⑤ AI 模型管理、API Key 打码与明文保存提示、角色/生成参数设置、Shopee 更新安全设置、检查按钮/每批最大更新条数/多账号并行设置、测试商品 ID 限制移除/worker signal 与线程包装、T-505 全流程诊断日志(import/write_back/apply/chrome_launch/login_check/ai_model_test)与本地日志脱敏、gui T-511 语义色板和任务状态列前景色、T-512 高风险按钮与导入校验数字样式、T-513 登录状态/Tab 标识/删除按钮样式、T-514 首次空状态引导卡片、T-515 批次阶段进度总览、T-516 ①筛选对齐②③、T-517 ⑤设置分区与兼容字段清理、T-518 ②左栏提示词区组件密度优化、T-519 ②AI生成长任务进度条与用户可读滚动日志、T-520 ②AI生成封面可选生成开关、T-524 PyInstaller 打包入口/构建脚本/发布目录校验、T-526 cmhub backend mock、T-527 设置页 cmhub backend 切换/别名下拉/worker mock;2026-07-01 已完成 5 个真实商品的 T-404 更新验收,后续 CDP/Shopee 改动仍需测试商品手动验证。 - 数据:`config.json`、`config/ai_models.json`、`config/cmhub.json`、`cmshopee.db`、`cmshopee.db-*`、`chrome_user_data_dir/`、`images/`、`logs/`、`prompts/`、`title_prompt.txt`、`build/`、`dist/` 已由 `.gitignore` 排除;密码与 API Key 本地明文保存但保存/变更时提示,UI 打码,日志/导出必须脱敏;运营填写后的 Excel 业务文件默认忽略,标准空模板 `shopee待处理任务模板.xlsx` 可提交;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。 ## 既定设计要点(文档已定) @@ -38,13 +38,13 @@ | `requirements-build.txt` | 已有 | T-524 产出:打包环境依赖,先安装运行依赖再安装 PyInstaller 5.13.2;不作为运行时依赖文件 | | `.github/workflows/tests.yml` | 已有 | T-522 产出:push / pull_request 自动在 `windows-latest` + Python 3.11 安装 `requirements.txt`,设置 `QT_QPA_PLATFORM=offscreen`,运行 `python -m compileall app main.py` 与 `python -m unittest discover -s tests` | | `cmshopee.spec` / `scripts/build_exe.ps1` | 已有 | T-524 产出:PyInstaller onedir 构建配置与发布目录校验脚本;`cmshopee.spec` 不声明本地数据 `datas`,脚本检查 `dist\\cmshopee` 不含配置、DB、图片、日志、Chrome 登录态或提示词 | -| `app/gui/` | 已有 | T-523 产出:由旧 `app/gui.py` 拆分的 PySide6 GUI 包,包入口 `__init__.py` 继续兼容 `from app import gui` / `from app.gui import MainWindow`;`main_window.py` 放 MainWindow,`models.py` 放 3 个 TableModel,`widgets.py` 放色板/空状态/批次总览/helper,`workers.py` 放具体 GUI workers,`tabs/` 放 ①~⑤ Tab; T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-206/T-207/T-302/T-302p/T-303/T-303b/T-401/T-402/T-403/T-404a/T-501/T-501b/T-501c/T-503/T-504/T-506/T-507/T-508/T-509/T-510/T-505/T-511/T-512/T-513/T-514/T-515/T-516/T-517/T-518/T-519/T-520 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;统一语义色板、①②③状态列前景色、③开始更新 warning 样式、①导入校验数字标红、④登录状态点、③Tab warning 小圆点、删除按钮 danger 样式、①②③首次空状态引导卡片和批次阶段进度总览;① 导入采集导入按钮、导入汇总栏、批次/店铺/商品ID/状态筛选、删除批次软删除按钮、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker(旧封面路径按批次/店铺/任务细分)、采集前账号就绪预检与④引导、采集完成自动回写与手动重试、采集运行日志视图;② AI生成左右布局、提示词管理、批次/店铺/商品ID/状态筛选栏、任务列表、新标题列本地编辑、开始生成/停止/标题与图片双进度条、双击新旧封面预览、用户可读自动滚动AI生成运行日志、重置生成结果与 `GenerateWorker`;③ 更新shopee批次/店铺/商品ID/状态筛选栏、任务列表、开始更新主按钮、重置更新状态右键菜单、Shopee 更新安全拦截与「前往设置」跳转、「检查本轮更新」按钮、开始更新确认弹窗、`ApplyWorker` 检查/分批串行/按账号并行、账号与端口预检、运行日志、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、密码本地明文保存提示、启动登录、检测登录、快捷方式;⑤ 设置 AI 模型下拉、新增/删除、详情编辑、密钥打码与本地明文保存提示、测试连接 worker、默认角色下拉、生成参数、路径端口配置、三列 Shopee 更新安全与执行模式设置,高频安全/执行模式分区前置、基础设施分区后置,`test_item_id`/`dry_run` 兼容字段无用户入口且普通更新不再阻断正式更新;②封面模板「另存为/重命名/删除」低频操作已收敛进「模板操作」菜单;②AI生成底部已增加标题/图片双进度条,运行日志已改为用户可读、自动滚动、脱敏的长任务日志,且②本轮「生成封面图片(成本较高)」开关默认关闭并持久化到 `ai.generate_cover`,关闭时只生成标题并进入可更新状态 | +| `app/gui/` | 已有 | T-523 产出:由旧 `app/gui.py` 拆分的 PySide6 GUI 包,包入口 `__init__.py` 继续兼容 `from app import gui` / `from app.gui import MainWindow`;`main_window.py` 放 MainWindow,`models.py` 放 3 个 TableModel,`widgets.py` 放色板/空状态/批次总览/helper,`workers.py` 放具体 GUI workers,`tabs/` 放 ①~⑤ Tab; T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-206/T-207/T-302/T-302p/T-303/T-303b/T-401/T-402/T-403/T-404a/T-501/T-501b/T-501c/T-503/T-504/T-506/T-507/T-508/T-509/T-510/T-505/T-511/T-512/T-513/T-514/T-515/T-516/T-517/T-518/T-519/T-520/T-527 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;统一语义色板、①②③状态列前景色、③开始更新 warning 样式、①导入校验数字标红、④登录状态点、③Tab warning 小圆点、删除按钮 danger 样式、①②③首次空状态引导卡片和批次阶段进度总览;① 导入采集导入按钮、导入汇总栏、批次/店铺/商品ID/状态筛选、删除批次软删除按钮、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker(旧封面路径按批次/店铺/任务细分)、采集前账号就绪预检与④引导、采集完成自动回写与手动重试、采集运行日志视图;② AI生成左右布局、提示词管理、批次/店铺/商品ID/状态筛选栏、任务列表、新标题列本地编辑、开始生成/停止/标题与图片双进度条、双击新旧封面预览、用户可读自动滚动AI生成运行日志、重置生成结果与 `GenerateWorker`;③ 更新shopee批次/店铺/商品ID/状态筛选栏、任务列表、开始更新主按钮、重置更新状态右键菜单、Shopee 更新安全拦截与「前往设置」跳转、「检查本轮更新」按钮、开始更新确认弹窗、`ApplyWorker` 检查/分批串行/按账号并行、账号与端口预检、运行日志、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、密码本地明文保存提示、启动登录、检测登录、快捷方式;⑤ 设置 AI backend 选择,direct 模式保留模型下拉、新增/删除、详情编辑、密钥打码与本地明文保存提示、测试连接 worker、默认角色下拉,cmhub 模式提供 Base URL、API Key、生文/生图动态别名、刷新别名、测试连接/查余额和 `config/cmhub.json` 保存;通用生成参数、路径端口配置、三列 Shopee 更新安全与执行模式设置仍保留,高频安全/执行模式分区前置、基础设施分区后置,`test_item_id`/`dry_run` 兼容字段无用户入口且普通更新不再阻断正式更新;②封面模板「另存为/重命名/删除」低频操作已收敛进「模板操作」菜单;②AI生成底部已增加标题/图片双进度条,运行日志已改为用户可读、自动滚动、脱敏的长任务日志,且②本轮「生成封面图片(成本较高)」开关默认关闭并持久化到 `ai.generate_cover`,关闭时只生成标题并进入可更新状态 | | `app/workers.py` | 已有 | T-104b 产出:`BaseWorker` + 通用 signals + 取消标记 + `run_worker()` QThread 包装 | | `app/accounts.py` | 已有 | T-105/T-106 产出:账号 CRUD 服务、目录创建、端口分配、启动登录、检测登录、快捷方式 | | `app/editor.py` | 已有 | T-001/T-103/T-205b/T-207/T-501c/T-502/T-404b + T-404 补丁产出:登录状态检测、打开商品页、详情页加载失败 toast 捕获与商品失效归因、读/写标题、读/下载封面、采集步骤回调、更新步骤回调、采集后关闭自动新建商品页 tab、上传前等待图片管理器稳定、点击上传块后注入文件并检测 CDN 后拖封面、更新封面统一先校验旧封面备份再删线上第一张、重复图片 toast 明确失败、页面主更新按钮、Shopee 站点侧确认框主按钮、apply_task;更新成功后可按设置关闭本轮自动新建商品页 | -| `app/appconfig.py` | 已有 | T-002/T-501c/T-503/T-504/T-520/T-526 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取、AI `backend=direct/cmhub` 和 `ai.cmhub` 默认值、`config/cmhub.json` Key helper、Shopee 更新安全与 dry-run/多账号并行默认值,②生成封面默认关闭的 `ai.generate_cover`;拒绝敏感字段写入;提供敏感值打码、结构化日志脱敏与自由文本替换工具 | +| `app/appconfig.py` | 已有 | T-002/T-501c/T-503/T-504/T-520/T-526/T-527 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取、AI `backend=direct/cmhub` 和 `ai.cmhub` 默认值、`config/cmhub.json` Key helper、Shopee 更新安全与 dry-run/多账号并行默认值,②生成封面默认关闭的 `ai.generate_cover`;拒绝敏感字段写入;提供敏感值打码、结构化日志脱敏与自由文本替换工具 | | `app/diagnostics.py` | 已有 | T-207 + AI生成诊断补丁 + T-505 产出:本地 `logs/cmshopee.log` 诊断日志、大小滚动、异常类型/traceback/step/耗时记录,结构化 payload 和自由文本脱敏后写入 | -| `app/ai.py` | 已有 | T-301/T-303 + AI生成诊断补丁 + T-519/T-520/T-526 产出:`gen_title()`/`gen_cover()`/`generate_batch()`;支持 direct 默认模型直连和 cmhub 网关 backend;cmhub 使用 `CMHubError` 结构化错误、tuple timeout、非幂等生图读超时不重发、`image_url` 安全下载、`fetch_cmhub_models()` 别名发现和 metadata 事件;direct 保留通用 HTTP 调用、失败重试、错误脱敏;封面按 resolution/jpg_quality 保存并按批次/店铺/任务路径落盘;批量生成可按 `ai.generate_cover` 跳过封面阶段,标题成功即写库进入 generated;开启封面时先标题后封面、进度回调、逐条落库、失败标记、事件/错误回调、停止取消未开始项 | +| `app/ai.py` | 已有 | T-301/T-303 + AI生成诊断补丁 + T-519/T-520/T-526 产出:`gen_title()`/`gen_cover()`/`generate_batch()`;支持 direct 默认模型直连和 cmhub 网关 backend;cmhub 使用 `CMHubError` 结构化错误、tuple timeout、非幂等生图读超时不重发、`image_url` 安全下载、`fetch_cmhub_models()` 别名发现、`fetch_cmhub_balance()` 余额查询和 metadata 事件;direct 保留通用 HTTP 调用、失败重试、错误脱敏;封面按 resolution/jpg_quality 保存并按批次/店铺/任务路径落盘;批量生成可按 `ai.generate_cover` 跳过封面阶段,标题成功即写库进入 generated;开启封面时先标题后封面、进度回调、逐条落库、失败标记、事件/错误回调、停止取消未开始项 | | `app/prompts.py` | 已有 | T-302p 产出:标题提示词读写、封面模板列表/读取/保存/重命名/删除、变量替换 | | `app/db.py` | 已有 | T-003/T-206/T-404a/T-504/T-509 产出:batches/accounts/tasks schema;batches 软删除字段与默认业务查询过滤;run_logs/run_log_events;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库;生成结果/更新状态本地重置;`update_generated_title()` 本地新标题微调;运行日志写入与查询 | | `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir | @@ -68,8 +68,8 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。 -- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-103b(登录检测补充识别 Shopee accounts 登录页)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-206(Tab① 删除指定批次软删除)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-303b(②/③ 商品ID筛选)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)、T-501c(Tab⑤ Shopee 更新安全开关)、T-502(换封面删第一张再上传)、T-503(敏感信息本地明文保存提示与日志脱敏)、T-504(多账号并行 / dry-run / 运行日志)、T-207(①采集诊断日志)、② AI生成图片失败诊断日志补丁、T-404a(②/③ 选中记录重置)、T-404(真实 Shopee 单条更新冒烟验收)、T-506(正式使用批量更新体验)、T-507(正式批量更新:移除普通流程测试商品 ID 限制)、T-508(③ 更新shopee生产化操作区)、T-509(② 新标题人工微调)、T-510(③ 检查本轮更新文案统一)、T-505(全流程诊断日志扩展)、T-511(语义色板 + ①②③任务状态列上色)、T-512(③高风险按钮上色 + ①导入校验数字标红)、T-513(登录点 / ③Tab危险标识 / 破坏性按钮上色)、T-514(①②③ 首次空状态引导卡片)、T-515(批次阶段进度总览)、T-516(①筛选对齐②③)、T-517(⑤设置分区 + 清理兼容字段)、T-518(②左栏提示词区组件密度优化)、T-519(②AI生成长任务进度条 + 用户可读滚动日志)、T-520(②AI生成封面可选生成开关)、T-521(依赖清单:锁版本 requirements)、T-522(CI:自动跑语法 + 单元 / GUI 测试)、T-523(拆分 `app/gui.py` 为 `app/gui/` 包)、T-523a(②/③ 新一轮运行前清空界面日志显示)、T-404b(商品详情页加载失败 toast 自动捕获)、T-524(PyInstaller 打包为免安装 exe)、T-526(`app/ai.py` + `appconfig` 接入 cmhub backend)。 -- 下一个可领取任务:T-527(⑤设置 cmhub 网关面板);T-525 ruff 顺延到 cmhub 对接后。 +- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-103b(登录检测补充识别 Shopee accounts 登录页)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-206(Tab① 删除指定批次软删除)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-303b(②/③ 商品ID筛选)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)、T-501c(Tab⑤ Shopee 更新安全开关)、T-502(换封面删第一张再上传)、T-503(敏感信息本地明文保存提示与日志脱敏)、T-504(多账号并行 / dry-run / 运行日志)、T-207(①采集诊断日志)、② AI生成图片失败诊断日志补丁、T-404a(②/③ 选中记录重置)、T-404(真实 Shopee 单条更新冒烟验收)、T-506(正式使用批量更新体验)、T-507(正式批量更新:移除普通流程测试商品 ID 限制)、T-508(③ 更新shopee生产化操作区)、T-509(② 新标题人工微调)、T-510(③ 检查本轮更新文案统一)、T-505(全流程诊断日志扩展)、T-511(语义色板 + ①②③任务状态列上色)、T-512(③高风险按钮上色 + ①导入校验数字标红)、T-513(登录点 / ③Tab危险标识 / 破坏性按钮上色)、T-514(①②③ 首次空状态引导卡片)、T-515(批次阶段进度总览)、T-516(①筛选对齐②③)、T-517(⑤设置分区 + 清理兼容字段)、T-518(②左栏提示词区组件密度优化)、T-519(②AI生成长任务进度条 + 用户可读滚动日志)、T-520(②AI生成封面可选生成开关)、T-521(依赖清单:锁版本 requirements)、T-522(CI:自动跑语法 + 单元 / GUI 测试)、T-523(拆分 `app/gui.py` 为 `app/gui/` 包)、T-523a(②/③ 新一轮运行前清空界面日志显示)、T-404b(商品详情页加载失败 toast 自动捕获)、T-524(PyInstaller 打包为免安装 exe)、T-526(`app/ai.py` + `appconfig` 接入 cmhub backend)、T-527(⑤设置 cmhub 网关面板)。 +- 下一个可领取任务:T-528(②计费错误提示 + 余额展示);T-525 ruff 顺延到 cmhub 对接后。 ## 当前已知限制 @@ -96,7 +96,7 @@ - T-523a 已完成:②点击「开始生成」、③点击「检查本轮更新」或「开始更新」时,先清空对应界面的旧日志显示并写入本轮开始摘要;运行中只追加本轮日志。历史 `run_logs/run_log_events` 和本地 `logs/` 不删除、不自动混入当前运行界面。 - T-404b 已完成:`open_product()` 在导航商品详情页前后安装 toast 捕获,等待详情页关键元素超时时会读取最近 `.eds-toasts`/toast/message 节点及页面缓存,把明确商品失效/商品不存在/无权限类 toast 上浮为 `商品失效:<原始toast>`;如果失败发生在 `open_product()` 内部,本轮自动新建的失败 tab 会关闭,复用用户已有 tab 不关闭;①列表“阶段”列只在这类明确失效错误时显示“商品失效”,底层仍保持 `stage=imported/status=failed`,其他打开失败仍显示“失败”。 - T-524 已完成:新增 PyInstaller onedir 打包配置、构建脚本和发布目录校验;打包版启动时工作目录切到 `cmshopee.exe` 所在目录,首次运行在 exe 同级生成/使用本地配置、DB、图片、日志、登录态和提示词;发布包不内置这些本地数据,用户后续更新采用关闭程序后覆盖程序文件、保留本地数据的方式。 -- Phase 7 cmhub 网关对接:T-526 已接 `app/ai.py`/`appconfig` backend,保留 direct 默认并新增 cmhub mock 单测;下一步 T-527 做⑤设置面板,T-528 做②计费错误提示与余额展示;T-525 ruff 因业务优先级顺延。 +- Phase 7 cmhub 网关对接:T-526 已接 `app/ai.py`/`appconfig` backend,T-527 已完成⑤设置页 cmhub backend 切换、动态别名下拉、测试连接/查余额 worker 和 mock 单测;下一步 T-528 做②计费错误提示与余额展示;T-525 ruff 因业务优先级顺延。 - T-301/T-303 已完成通用 HTTP AI 接口、批量生成编排和 GUI 接入 mock 单测;真实 AI 生成还需要在 `config/ai_models.json` 填入可用 url/model/api_key 后做一次成本可控的小样本实测;URL 可填完整 endpoint 或 OpenAI-compatible base URL。 - 本地 `config/ai_models.json` 若由旧版本或手工维护,可能缺少 `category`;启动报 “AI 模型 category 必须是 text 或 image” 时,按 [`troubleshooting.md`](troubleshooting.md) 只补 `category` / `enabled` 等非密钥字段,保留 API Key,且不要提交该文件。 diff --git a/progress.md b/progress.md index d1883fc..6e48d5f 100644 --- a/progress.md +++ b/progress.md @@ -1113,4 +1113,15 @@ - 文档:同步 `docs/03-tech-stack.md`、`docs/04-architecture.md`、`docs/api.md`、`docs/06-tasks.md`、`docs/current-state.md`;T-526 标记 DONE,下一步为 T-527。 - 测试:`python -m py_compile app\appconfig.py app\ai.py tests\test_appconfig.py tests\test_ai.py` 通过;`python -m unittest discover -s tests -p "test_appconfig.py"` 通过(8 tests);`python -m unittest discover -s tests -p "test_ai.py"` 通过(16 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(179 tests)。全量测试仍有本机 PySide6 字体目录提示,不影响结果。 - 决策:全新配置和旧配置缺 `ai.backend` 时都保持 `direct`,避免升级后未配置 cmhub 就破坏现有生成;cmhub Key 固定只进 `config/cmhub.json`,不进 `config.json`;T-526 只传出 metadata,GUI 余额/计费提示留给 T-528。 -- 下一步:T-527 ⑤设置 cmhub 网关面板。 \ No newline at end of file +- 下一步:T-527 ⑤设置 cmhub 网关面板。 +## 【2026-07-04】T-527 完成 · ⑤设置 cmhub 网关面板 + +- 状态:DONE +- 代码:⑤设置页新增 AI 后端选择,`direct` 模式保留现有 `config/ai_models.json` 模型 master-detail 和直连模型测试;`cmhub` 模式显示网关 Base URL、API Key、生文别名、生图别名、刷新别名、测试连接/查余额和余额结果。cmhub Key 单独保存到 `config/cmhub.json`,保存新 Key 时沿用本地明文保存提示,`config.json` 只保存 backend、Base URL、别名、连接超时和余额预检开关。 +- 别名:`CMHubSettingsWorker` 后台调用 `fetch_cmhub_models()`,⑤设置页首次显示且已配置 Base URL/Key 时自动刷新一次,也可手动点「刷新别名」;按 `operation_type` 拆分生文/生图,过滤 `pricing_status="unpriced"`,下拉展示点数价格和 `requires_image` 提示;网关暂不可达或已存别名不在可用列表时保留已存值,避免保存设置时清空用户配置。 +- 余额:`app/ai.py` 新增 `fetch_cmhub_balance()` 调 `GET /api/v1/balance`,设置页「测试连接/查余额」复用同一个 worker 拉模型和余额;worker 失败会用本地 Key 脱敏后再上浮错误。 +- 边界:未改 Shopee/CDP、Excel、DB schema、账号流程;`config/ai_models.json` 在切换 cmhub 时不删除,仍供 direct 模式回退使用。 +- 测试:`tests/test_ai.py` 覆盖 cmhub 余额 helper;`tests/test_gui.py` 覆盖 `CMHubSettingsWorker` 成功/失败脱敏、设置页 cmhub backend 切换、保存 `config.json` + `config/cmhub.json`、别名过滤与已存值保留。 +- 文档:`docs/06-tasks.md` 将 T-527 标为 DONE;同步 `docs/current-state.md`,下一步为 T-528。 +- 验证:`python -m py_compile tests\test_ai.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p "test_ai.py"` 通过(17 tests);`python -m unittest discover -s tests -p "test_gui.py"` 通过(77 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(184 tests);`git diff --check` 无空白错误(仅 LF/CRLF 提示)。全量测试仍有本机 PySide6 字体目录提示,不影响结果。 +- 下一步:T-528 ②计费错误提示 + 余额展示。 \ No newline at end of file diff --git a/tests/test_ai.py b/tests/test_ai.py index 06776a7..b9d49ed 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -515,6 +515,20 @@ class AITests(TempDirMixin, unittest.TestCase): self.assertEqual("https://cmhub.example.com/api/v1/models", calls[0][1]) self.assertEqual("title-standard", models[0]["alias"]) + def test_fetch_cmhub_balance_returns_points(self): + calls = [] + + def fake_request(method, url, **kwargs): + calls.append((method, url, kwargs)) + return _RequestsResponse({"user": {"id": "u1"}, "points_balance": 42}) + + with mock.patch("app.ai.requests.request", side_effect=fake_request): + balance = ai.fetch_cmhub_balance("https://cmhub.example.com", "sk-cmhub-secret") + + self.assertEqual("GET", calls[0][0]) + self.assertEqual("https://cmhub.example.com/api/v1/balance", calls[0][1]) + self.assertEqual(42, balance["points_balance"]) + def test_generate_batch_forwards_cmhub_metadata_event(self): with self.make_temp_dir() as temp_dir: cfg, key_path = self._cmhub_config(temp_dir) diff --git a/tests/test_gui.py b/tests/test_gui.py index c0b4c00..f479e64 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -25,6 +25,7 @@ from app.gui import ( AccountLoginCheckWorker, AccountsTab, AIModelTestWorker, + CMHubSettingsWorker, ApplyTab, ApplyWorker, CollectWorker, @@ -705,6 +706,232 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_cmhub_settings_worker_fetches_models_and_balance(self): + worker = CMHubSettingsWorker( + "https://cmhub.example.com", + "sk-cmhub-secret", + connect_timeout=7, + include_balance=True, + ) + models = [ + { + "alias": "title-standard", + "operation_type": "title", + "pricing_status": "priced", + } + ] + balance = {"user": {"id": "u1"}, "points_balance": 88} + + with mock.patch("app.gui.ai.fetch_cmhub_models", return_value=models) as fetch_models, \ + mock.patch("app.gui.ai.fetch_cmhub_balance", return_value=balance) as fetch_balance: + result = worker.execute() + + fetch_models.assert_called_once_with( + "https://cmhub.example.com", + "sk-cmhub-secret", + connect_timeout=7, + ) + fetch_balance.assert_called_once_with( + "https://cmhub.example.com", + "sk-cmhub-secret", + connect_timeout=7, + ) + self.assertTrue(result["ok"]) + self.assertEqual(models, result["models"]) + self.assertEqual(88, result["points_balance"]) + + def test_cmhub_settings_worker_redacts_key_on_failure(self): + worker = CMHubSettingsWorker( + "https://cmhub.example.com", + "sk-cmhub-secret", + connect_timeout=7, + include_balance=False, + ) + + with mock.patch( + "app.gui.ai.fetch_cmhub_models", + side_effect=RuntimeError("bad key sk-cmhub-secret"), + ): + with self.assertRaises(RuntimeError) as raised: + worker.execute() + + self.assertIn("bad key", str(raised.exception)) + self.assertNotIn("sk-cmhub-secret", str(raised.exception)) + + def test_settings_tab_cmhub_backend_panel_saves_config_and_key(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + cfg["ai"] = appconfig.default_config()["ai"] + cfg["ai"]["backend"] = "cmhub" + cfg["ai"]["cmhub"] = { + "base_url": "https://cmhub.old", + "title_alias": "title-old", + "image_alias": "image-old", + "connect_timeout": 9, + "check_balance_before_batch": False, + } + cfg["cmhub_config_path"] = os.path.join(temp_dir, "config", "cmhub.json") + appconfig.save_cmhub_config( + {"api_key": "sk-old-secret"}, + path=cfg["cmhub_config_path"], + ) + appconfig.save_ai_models_config( + { + "models": [ + { + "name": "Text A", + "category": "text", + "enabled": True, + "url": "", + "model": "", + "api_key": "", + "api_type": "chat", + "connect_timeout_seconds": 30, + "timeout_seconds": 0, + "extra_body": {}, + }, + { + "name": "Image A", + "category": "image", + "enabled": True, + "url": "", + "model": "", + "api_key": "", + "api_type": "auto", + "connect_timeout_seconds": 30, + "timeout_seconds": 0, + "extra_body": {}, + }, + ] + }, + path=cfg["ai_models_path"], + ) + tab = SettingsTab( + config=cfg, + config_path=cfg["config_path"], + ai_models_path=cfg["ai_models_path"], + ) + self.addCleanup(tab.close) + + self.assertEqual("cmhub", tab.backend_combo.currentData()) + self.assertTrue(tab.model_picker_panel.isHidden()) + self.assertFalse(tab.cmhub_panel.isHidden()) + self.assertEqual("https://cmhub.old", tab.cmhub_base_url_edit.text()) + self.assertEqual("sk-old-secret", tab.cmhub_api_key_edit.text()) + self.assertEqual(QLineEdit.Password, tab.cmhub_api_key_edit.echoMode()) + self.assertEqual("title-old", tab.cmhub_title_alias_combo.currentData()) + self.assertEqual("image-old", tab.cmhub_image_alias_combo.currentData()) + + tab.cmhub_base_url_edit.setText("https://cmhub.example.com") + tab.cmhub_api_key_edit.setText("sk-new-secret") + tab.cmhub_connect_timeout_spin.setValue(12) + tab.cmhub_check_balance_checkbox.setChecked(True) + tab._populate_cmhub_alias_combos( + [ + { + "alias": "title-standard", + "operation_type": "title", + "requires_image": False, + "pricing_status": "priced", + "prices": [{"resolution": "1K", "points_cost": 1}], + }, + { + "alias": "image-standard", + "operation_type": "image", + "requires_image": True, + "pricing_status": "priced", + "prices": [{"resolution": "1K", "points_cost": 5}], + }, + ], + title_selected="title-standard", + image_selected="image-standard", + ) + + with mock.patch("app.gui.QMessageBox.warning") as warning, \ + mock.patch("app.gui.QMessageBox.information") as info: + tab.save_app_settings() + + warning.assert_called_once() + self.assertIn("config/cmhub.json", warning.call_args[0][2]) + info.assert_called_once_with(tab, "保存设置", "设置已保存") + saved = appconfig.load_config(cfg["config_path"]) + self.assertEqual("cmhub", saved["ai"]["backend"]) + self.assertEqual("https://cmhub.example.com", saved["ai"]["cmhub"]["base_url"]) + self.assertEqual("title-standard", saved["ai"]["cmhub"]["title_alias"]) + self.assertEqual("image-standard", saved["ai"]["cmhub"]["image_alias"]) + self.assertEqual(12, saved["ai"]["cmhub"]["connect_timeout"]) + self.assertTrue(saved["ai"]["cmhub"]["check_balance_before_batch"]) + self.assertEqual( + "sk-new-secret", + appconfig.get_cmhub_api_key(path=cfg["cmhub_config_path"]), + ) + self.assertTrue(os.path.exists(cfg["ai_models_path"])) + + self.assert_removed(temp_dir) + + def test_settings_tab_cmhub_alias_refresh_filters_unpriced_and_keeps_saved(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + cfg["ai"] = appconfig.default_config()["ai"] + cfg["ai"]["backend"] = "cmhub" + cfg["ai"]["cmhub"] = { + "base_url": "https://cmhub.example.com", + "title_alias": "title-saved", + "image_alias": "image-saved", + "connect_timeout": 10, + "check_balance_before_batch": False, + } + cfg["cmhub_config_path"] = os.path.join(temp_dir, "config", "cmhub.json") + appconfig.save_cmhub_config({"api_key": "sk-cmhub-secret"}, path=cfg["cmhub_config_path"]) + tab = SettingsTab(config=cfg, config_path=cfg["config_path"], ai_models_path=cfg["ai_models_path"]) + self.addCleanup(tab.close) + + tab._on_cmhub_finished( + { + "ok": True, + "points_balance": 55, + "models": [ + { + "alias": "title-priced", + "operation_type": "title", + "requires_image": False, + "pricing_status": "priced", + "prices": [{"resolution": "512", "points_cost": 1}], + }, + { + "alias": "title-free", + "operation_type": "title", + "pricing_status": "unpriced", + "prices": [], + }, + { + "alias": "image-priced", + "operation_type": "image", + "requires_image": True, + "pricing_status": "priced", + "prices": [{"resolution": "1K", "points_cost": 5}], + }, + ], + } + ) + + title_aliases = [ + tab.cmhub_title_alias_combo.itemData(index) + for index in range(tab.cmhub_title_alias_combo.count()) + ] + image_labels = [ + tab.cmhub_image_alias_combo.itemText(index) + for index in range(tab.cmhub_image_alias_combo.count()) + ] + self.assertIn("title-priced", title_aliases) + self.assertIn("title-saved", title_aliases) + self.assertNotIn("title-free", title_aliases) + self.assertIn("512:1点", tab.cmhub_title_alias_combo.itemText(0)) + self.assertTrue(any("需参考图" in label for label in image_labels)) + self.assertIn("余额 55", tab.cmhub_result_label.text()) + + self.assert_removed(temp_dir) + def test_generate_tab_has_prompt_editors_and_task_table(self): with self.make_temp_dir() as temp_dir: title_prompt_path = os.path.join(temp_dir, "title_prompt.txt")