From 91683abe7d74ca98ffd9ff6a5591ae95dc71a448 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 29 Jun 2026 08:58:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90T-501b=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=E8=A7=92=E8=89=B2=E4=B8=8E=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在SettingsTab增加标题/图片默认模型角色下拉并按text/image过滤 - 增加并发、重试、分辨率、返回超时展示和jpg质量设置 - 增加Chrome路径、账号数据根目录、图片目录、DB路径和端口配置 - 保存到config.json并校验端口范围,避免写入测试注入路径 - 补充GUI测试并同步任务、api、routes、current-state和progress --- app/gui.py | 247 +++++++++++++++++++++++++++++++++++++++++- docs/06-tasks.md | 2 +- docs/api.md | 7 +- docs/current-state.md | 18 +-- docs/routes.md | 9 +- progress.md | 10 ++ tests/test_gui.py | 148 +++++++++++++++++++++++++ 7 files changed, 424 insertions(+), 17 deletions(-) diff --git a/app/gui.py b/app/gui.py index 5fb89bd..87ee889 100644 --- a/app/gui.py +++ b/app/gui.py @@ -26,6 +26,7 @@ try: QMessageBox, QPlainTextEdit, QPushButton, + QScrollArea, QSplitter, QTableView, QSpinBox, @@ -2437,16 +2438,23 @@ if QT_IMPORT_ERROR is None: CATEGORY_ITEMS = [("文本", "text"), ("图像", "image")] API_TYPE_ITEMS = [("chat", "chat"), ("images_edits", "images_edits"), ("auto", "auto")] + RESOLUTION_ITEMS = ["512", "1k", "2k", "4k"] def __init__( self, parent=None, config=None, + config_path=None, ai_models_path=None, status_callback=None, ): super().__init__(parent) self.config = appconfig.load_config() if config is None else config + self.config_path = ( + config_path + or self.config.get("config_path") + or appconfig.CONFIG_PATH + ) self.ai_models_path = ( ai_models_path or self.config.get("ai_models_path") @@ -2501,6 +2509,48 @@ if QT_IMPORT_ERROR is None: self.test_connection_button = QPushButton("测试连接") self.test_result_label = QLabel("") self.test_result_label.setWordWrap(True) + self.default_text_model_combo = QComboBox() + self.default_text_model_combo.setObjectName("defaultTextModelCombo") + self.default_image_model_combo = QComboBox() + self.default_image_model_combo.setObjectName("defaultImageModelCombo") + self.title_concurrency_spin = QSpinBox() + self.title_concurrency_spin.setObjectName("titleConcurrencySpin") + self.title_concurrency_spin.setRange(1, 64) + self.image_concurrency_spin = QSpinBox() + self.image_concurrency_spin.setObjectName("imageConcurrencySpin") + self.image_concurrency_spin.setRange(1, 64) + self.retry_spin = QSpinBox() + self.retry_spin.setObjectName("retrySpin") + self.retry_spin.setRange(0, 20) + self.resolution_combo = QComboBox() + self.resolution_combo.setObjectName("resolutionCombo") + 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.user_data_root_edit = QLineEdit() + self.user_data_root_edit.setObjectName("userDataRootEdit") + self.image_dir_edit = QLineEdit() + self.image_dir_edit.setObjectName("imageDirEdit") + self.db_path_edit = QLineEdit() + self.db_path_edit.setObjectName("dbPathEdit") + self.default_debug_port_spin = QSpinBox() + self.default_debug_port_spin.setObjectName("defaultDebugPortSpin") + self.default_debug_port_spin.setRange(1, 65535) + self.debug_port_start_spin = QSpinBox() + self.debug_port_start_spin.setObjectName("debugPortStartSpin") + self.debug_port_start_spin.setRange(1, 65535) + self.debug_port_end_spin = QSpinBox() + self.debug_port_end_spin.setObjectName("debugPortEndSpin") + self.debug_port_end_spin.setRange(1, 65535) + self.cdp_ready_timeout_spin = QSpinBox() + self.cdp_ready_timeout_spin.setObjectName("cdpReadyTimeoutSpin") + self.cdp_ready_timeout_spin.setRange(1, 3600) + self.save_config_button = QPushButton("保存设置") form = QFormLayout() form.addRow("", self.enabled_checkbox) @@ -2517,6 +2567,30 @@ if QT_IMPORT_ERROR is None: action_layout.addWidget(self.test_connection_button) action_layout.addStretch(1) + ai_form = QFormLayout() + ai_form.addRow("标题大模型", self.default_text_model_combo) + ai_form.addRow("图片大模型", self.default_image_model_combo) + ai_form.addRow("标题并发数", self.title_concurrency_spin) + ai_form.addRow("图片并发数", self.image_concurrency_spin) + ai_form.addRow("失败重试次数", self.retry_spin) + ai_form.addRow("分辨率", self.resolution_combo) + ai_form.addRow("返回超时", self.response_timeout_label) + ai_form.addRow("jpg质量", self.jpg_quality_spin) + + port_range_layout = QHBoxLayout() + port_range_layout.addWidget(self.debug_port_start_spin) + port_range_layout.addWidget(QLabel("到")) + port_range_layout.addWidget(self.debug_port_end_spin) + + path_form = QFormLayout() + path_form.addRow("Chrome路径", self.chrome_path_edit) + path_form.addRow("账号数据根目录", self.user_data_root_edit) + path_form.addRow("图片目录", self.image_dir_edit) + path_form.addRow("DB路径", self.db_path_edit) + path_form.addRow("默认调试端口", self.default_debug_port_spin) + path_form.addRow("调试端口范围", port_range_layout) + path_form.addRow("CDP就绪超时(秒)", self.cdp_ready_timeout_spin) + right_panel = QWidget() right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(12, 0, 0, 0) @@ -2524,11 +2598,22 @@ if QT_IMPORT_ERROR is None: right_layout.addLayout(form) right_layout.addLayout(action_layout) right_layout.addWidget(self.test_result_label) + right_layout.addSpacing(18) + right_layout.addWidget(QLabel("角色与生成参数")) + right_layout.addLayout(ai_form) + right_layout.addSpacing(18) + right_layout.addWidget(QLabel("路径与端口")) + right_layout.addLayout(path_form) + right_layout.addWidget(self.save_config_button) right_layout.addStretch(1) + right_scroll = QScrollArea() + right_scroll.setWidgetResizable(True) + right_scroll.setWidget(right_panel) + self.splitter = QSplitter(Qt.Horizontal) self.splitter.addWidget(left_panel) - self.splitter.addWidget(right_panel) + self.splitter.addWidget(right_scroll) self.splitter.setStretchFactor(0, 1) self.splitter.setStretchFactor(1, 3) self.splitter.setSizes([280, 860]) @@ -2542,8 +2627,13 @@ if QT_IMPORT_ERROR is None: 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.resolution_combo.currentIndexChanged.connect( + self._update_response_timeout_label + ) + self.save_config_button.clicked.connect(self.save_app_settings) self.refresh_models() + self._populate_app_settings() def _set_status(self, message): if self.status_callback is not None: @@ -2572,6 +2662,8 @@ if QT_IMPORT_ERROR is None: self.model_combo.setCurrentIndex(index if index >= 0 else (0 if self.models else -1)) self.model_combo.blockSignals(False) self.load_selected_model() + if hasattr(self, "default_text_model_combo"): + self._populate_role_model_combos() def load_selected_model(self, index=None): name = self.model_combo.currentData() @@ -2667,6 +2759,151 @@ if QT_IMPORT_ERROR is None: self._set_status(f"正在测试 AI 模型连接:{model['name']}") thread.start() + def save_app_settings(self, checked=False): + settings = self._app_settings_values() + if settings is None: + return + try: + saved = appconfig.save_config(settings, path=self.config_path) + except Exception as exc: + self._show_error(exc) + return + self._replace_config(saved) + self._populate_app_settings() + self._set_status("设置已保存") + + def _app_settings_values(self): + start_port = self.debug_port_start_spin.value() + end_port = self.debug_port_end_spin.value() + default_port = self.default_debug_port_spin.value() + if start_port > end_port: + self._show_error("调试端口范围起始值不能大于结束值") + return None + 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: + self._show_error("标题大模型和图片大模型不能为空") + return None + + ai_cfg = appconfig.ai_config(self.config) + ai_cfg.update( + { + "default_text_model": text_model, + "default_image_model": image_model, + "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", {})), + } + ) + + settings = { + key: value + for key, value in self.config.items() + if key not in {"config_path", "ai_models_path"} + } + settings.update( + { + "chrome_path": self.chrome_path_edit.text().strip(), + "user_data_root": self.user_data_root_edit.text().strip(), + "image_dir": self.image_dir_edit.text().strip(), + "db_path": self.db_path_edit.text().strip(), + "default_debug_port": default_port, + "debug_port_range": [start_port, end_port], + "cdp_ready_timeout": self.cdp_ready_timeout_spin.value(), + "ai": ai_cfg, + } + ) + return settings + + 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 + self.config.clear() + self.config.update(saved) + self.config.update(internal) + + def _populate_app_settings(self): + self._populate_role_model_combos() + ai_cfg = appconfig.ai_config(self.config) + self._set_combo_by_data( + self.default_text_model_combo, + ai_cfg.get("default_text_model", ""), + ) + self._set_combo_by_data( + self.default_image_model_combo, + ai_cfg.get("default_image_model", ""), + ) + self.title_concurrency_spin.setValue( + int(ai_cfg.get("title_concurrency", 4) or 4) + ) + self.image_concurrency_spin.setValue( + int(ai_cfg.get("image_concurrency", 4) or 4) + ) + self.retry_spin.setValue(int(ai_cfg.get("retry", 2) or 0)) + self._set_combo_by_data( + 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(appconfig.user_data_root(self.config)) + self.image_dir_edit.setText(appconfig.image_dir(self.config)) + self.db_path_edit.setText(appconfig.db_path(self.config)) + self.default_debug_port_spin.setValue( + int(appconfig.default_debug_port(self.config)) + ) + start_port, end_port = appconfig.debug_port_range(self.config) + self.debug_port_start_spin.setValue(int(start_port)) + self.debug_port_end_spin.setValue(int(end_port)) + self.cdp_ready_timeout_spin.setValue( + int(appconfig.cdp_ready_timeout(self.config)) + ) + self._update_response_timeout_label() + + def _populate_role_model_combos(self): + ai_cfg = appconfig.ai_config(self.config) + self._populate_role_combo( + self.default_text_model_combo, + "text", + ai_cfg.get("default_text_model"), + ) + self._populate_role_combo( + self.default_image_model_combo, + "image", + ai_cfg.get("default_image_model"), + ) + + def _populate_role_combo(self, combo, category, selected): + combo.blockSignals(True) + combo.clear() + for model in self.models: + if model.get("category") == category and model.get("enabled", True): + combo.addItem(model.get("name", ""), model.get("name", "")) + if combo.count() == 0: + combo.addItem("无可用模型", None) + index = combo.findData(selected) + combo.setCurrentIndex(index if index >= 0 else 0) + combo.blockSignals(False) + + def _update_response_timeout_label(self, index=None): + ai_cfg = appconfig.ai_config(self.config) + resolution = self.resolution_combo.currentData() or ai_cfg.get("resolution", "1k") + timeouts = ai_cfg.get("resolution_timeouts", {}) + timeout = timeouts.get(str(resolution)) + if timeout is None: + self.response_timeout_label.setText("未配置") + return + self.response_timeout_label.setText(f"{int(timeout)} 秒") + def _form_values(self): current = self._current_model() or {} name = self.name_edit.text().strip() @@ -3069,10 +3306,15 @@ if QT_IMPORT_ERROR is None: class MainWindow(QMainWindow): """Main application window with the fixed five-tab workflow.""" - def __init__(self, db_path=None, config=None, ai_models_path=None): + def __init__(self, db_path=None, config=None, config_path=None, ai_models_path=None): super().__init__() self.config = appconfig.load_config() if config is None else config self.db_path = _database_path(db_path, self.config) + self.config_path = ( + config_path + or self.config.get("config_path") + or appconfig.CONFIG_PATH + ) self.ai_models_path = ( ai_models_path or self.config.get("ai_models_path") @@ -3118,6 +3360,7 @@ if QT_IMPORT_ERROR is None: ) return SettingsTab( config=self.config, + config_path=self.config_path, ai_models_path=self.ai_models_path, status_callback=self.statusBar().showMessage, ) diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 9647440..fb99ff6 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -79,7 +79,7 @@ | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | | T-501 | Tab⑤ 设置 · AI 模型管理 UI(master-detail):模型下拉 + 新增/删除 + 详情(服务商名/类别/api_type/模型ID/网址/密钥打码/连接超时) + 测试连接 | T-005, T-104 | 复用 T-005 后端;name 唯一;类别 text/image;至少各一个;密钥本地明文保存、UI 打码;测试连接可用 | DONE | -| T-501b | Tab⑤ 设置 · 角色与生成参数:标题大模型/图片大模型下拉、分辨率(512/1k/2k/4k)、返回超时随分辨率、并发/重试/jpg质量、路径/端口 | T-501 | 角色下拉按类别过滤;分辨率选后返回超时按 resolution_timeouts 自动;持久化 config.json | TODO | +| T-501b | Tab⑤ 设置 · 角色与生成参数:标题大模型/图片大模型下拉、分辨率(512/1k/2k/4k)、返回超时随分辨率、并发/重试/jpg质量、路径/端口 | T-501 | 角色下拉按类别过滤;分辨率选后返回超时按 resolution_timeouts 自动;持久化 config.json | DONE | | T-501c | Tab⑤ 设置 · Shopee 更新安全开关 | T-501b | 配置测试商品 ID、是否允许真实提交、是否允许更新封面、单次最大更新条数、成功后是否自动关闭本次新开编辑 tab;默认关闭真实提交和封面更新;Tab③ 执行前读取并拦截不符合安全配置的更新 | TODO | | T-502 | 满 9 张封面:删第一张再上传 | T-001 | 实测删除确认框并处理;满 9 张流程正确 | TODO | | T-503 | 敏感信息本地明文存储提示与日志脱敏 | T-105, T-501 | 首次保存密码/API Key 时提示“本地明文保存”;UI 打码;日志/导出不含密码/API Key;文档说明 `config.json`/`config/ai_models.json`/DB/user-data-dir/images 必须 gitignore | TODO | diff --git a/docs/api.md b/docs/api.md index 891143d..e21a5f3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -293,7 +293,7 @@ render_prompt(template_text, task) -> str ```python # GUI 入口 main() -> int # 创建 QApplication + MainWindow -class MainWindow(QMainWindow) # QTabWidget: ①②③④⑤;支持注入 db_path/config/ai_models_path 便于测试 +class MainWindow(QMainWindow) # QTabWidget: ①②③④⑤;支持注入 db_path/config/config_path/ai_models_path 便于测试 class CollectTab(QWidget) # ① 导入采集:导入 Excel + 汇总栏 + QTableView 任务列表 + 未匹配略过标记 class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 开始/停止生成 + 新旧封面预览 class ApplyTab(QWidget) # ③ 更新shopee:筛选已生成任务 + 确认后串行执行更新 @@ -326,10 +326,13 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式: - `SettingsTab` 左侧为 AI 模型下拉与新增/删除按钮,右侧为模型详情表单。 - 详情字段:启用、服务商名、类别(text/image)、api_type、模型 ID、网址、密钥、连接超时。 -- 模型数据读写复用 `appconfig.list_ai_models(..., reveal_api_key=True)`、`add_ai_model()`、`update_ai_model()`、`delete_ai_model()`;保存时保留现有 `extra_body` 与 `timeout_seconds`,不接管 T-501b 的生成参数。 +- 模型数据读写复用 `appconfig.list_ai_models(..., reveal_api_key=True)`、`add_ai_model()`、`update_ai_model()`、`delete_ai_model()`;保存时保留现有 `extra_body` 与 `timeout_seconds`。 - 密钥字段使用 `QLineEdit.Password` 打码显示;明文只写入已 gitignore 的 `config/ai_models.json`,不得进入日志/导出。 - 删除按钮在当前类别只剩 1 个模型时禁用;后端仍以“至少启用一个 text 和 image 模型”为硬约束。 - 「测试连接」创建 `AIModelTestWorker` 后台调用 `appconfig.test_ai_model()`,GUI 主线程不直接发网络请求。 +- 角色与生成参数读写 `config.json`:标题大模型(仅 text)、图片大模型(仅 image)、标题/图片并发、失败重试、分辨率、jpg 质量。 +- 分辨率下拉固定 `512/1k/2k/4k`;返回超时标签只读展示 `resolution_timeouts[resolution]`。 +- 路径与端口读写 `config.json`:Chrome 路径、账号数据根目录、图片目录、DB 路径、默认调试端口、调试端口范围、CDP 就绪超时。保存时校验端口范围和默认端口。 ① 导入采集当前要点(T-202/T-202b): diff --git a/docs/current-state.md b/docs/current-state.md index 435217a..45a3cf1 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,11 +5,11 @@ ## 当前快照 -- 日期:2026-06-27 -- 阶段: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-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-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI。 +- 日期:2026-06-29 +- 阶段: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-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-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI、T-501b Tab⑤ 角色与生成参数。 - 技术栈:Python 3.10+,自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(`config/ai_models.json` 通用 HTTP,chat JSON / images_edits),GUI PySide6 5 Tab(已定)。 -- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数与端口读取,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,按默认文本/图片模型发起通用 HTTP 调用,支持重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed` 与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`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.py` 已实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、① 导入采集的 Excel 导入按钮/导入汇总栏/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker、② AI生成左右布局/标题与封面提示词管理/批次店铺状态筛选/任务列表/变量预览/开始生成/停止/进度/双击新旧封面预览与 `GenerateWorker`、③ 更新shopee筛选栏/任务列表/开始更新确认弹窗/确认后 `ApplyWorker` 串行更新/账号就绪预检与④引导/逐条 `set_applied`/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、⑤ 设置 AI 模型 master-detail 管理与 `AIModelTestWorker` 后台测试连接;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 -- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集/gui ② AI生成布局筛选、提示词管理、生成 worker 与双击预览/gui ③ 更新shopee筛选列表、确认弹窗、`ApplyWorker` 串行更新与账号预检、结果回写与汇总/gui ④ 账号管理/gui ⑤ AI 模型管理/worker signal 与线程包装;CDP/Shopee 改动仍需测试商品手动验证。 +- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数与端口读取,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,按默认文本/图片模型发起通用 HTTP 调用,支持重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed` 与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`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.py` 已实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、① 导入采集的 Excel 导入按钮/导入汇总栏/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker、② AI生成左右布局/标题与封面提示词管理/批次店铺状态筛选/任务列表/变量预览/开始生成/停止/进度/双击新旧封面预览与 `GenerateWorker`、③ 更新shopee筛选栏/任务列表/开始更新确认弹窗/确认后 `ApplyWorker` 串行更新/账号就绪预检与④引导/逐条 `set_applied`/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、⑤ 设置 AI 模型 master-detail 管理、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置并持久化 `config.json`;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 +- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集/gui ② AI生成布局筛选、提示词管理、生成 worker 与双击预览/gui ③ 更新shopee筛选列表、确认弹窗、`ApplyWorker` 串行更新与账号预检、结果回写与汇总/gui ④ 账号管理/gui ⑤ AI 模型管理和角色/生成参数设置/worker signal 与线程包装;CDP/Shopee 改动仍需测试商品手动验证。 - 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 已由 `.gitignore` 排除;运营填写后的 Excel 业务文件默认忽略,标准空模板 `shopee待处理任务模板.xlsx` 可提交;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。 ## 既定设计要点(文档已定) @@ -32,7 +32,7 @@ | `prototypes/` | 已有 | 已验证原型/探查脚本(demo/set_title/set_cover/get_title/cookies/inspect_images/grab/1.py),保留作人工回归与探查参考;见 `prototypes/README.md` | | `chrome-remote-debug-lan.md` | 已有 | WSL→Windows CDP 转发排查记录 | | `app/__init__.py` / `app/__main__.py` / `main.py` | 已有 | 正式包与启动入口;`python main.py` / `python -m app` 可运行占位入口 | -| `app/gui.py` | 已有 | T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-302/T-302p/T-303/T-401/T-402/T-403/T-501 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集前账号就绪预检与④引导、采集完成自动回写与手动重试;② AI生成左右布局、提示词管理、筛选栏、任务列表、开始生成/停止/进度、双击新旧封面预览与 `GenerateWorker`;③ 更新shopee筛选栏、任务列表、开始更新确认弹窗、确认后 `ApplyWorker` 串行更新、账号预检与④引导、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、启动登录、检测登录、快捷方式;⑤ 设置 AI 模型下拉、新增/删除、详情编辑、密钥打码、测试连接 worker | +| `app/gui.py` | 已有 | T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-302/T-302p/T-303/T-401/T-402/T-403/T-501/T-501b 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;① 导入采集导入按钮、导入汇总栏、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker、采集前账号就绪预检与④引导、采集完成自动回写与手动重试;② AI生成左右布局、提示词管理、筛选栏、任务列表、开始生成/停止/进度、双击新旧封面预览与 `GenerateWorker`;③ 更新shopee筛选栏、任务列表、开始更新确认弹窗、确认后 `ApplyWorker` 串行更新、账号预检与④引导、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、启动登录、检测登录、快捷方式;⑤ 设置 AI 模型下拉、新增/删除、详情编辑、密钥打码、测试连接 worker、默认角色下拉、生成参数、路径端口配置 | | `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 产出:登录状态检测、打开商品页、读/写标题、读/下载封面、采集后关闭自动新建商品页 tab、上传拖封面、更新按钮、apply_task | @@ -42,7 +42,7 @@ | `app/db.py` | 已有 | T-003 产出:batches/accounts/tasks schema;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库 | | `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir | | `app/chrome.py` | 已有 | T-102/T-106 产出:Chrome 启动参数、`subprocess.Popen` 启动、`/json/version` 端口探测、PowerShell `.lnk` 快捷方式 | -| `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/T-301/T-302/T-302p/T-303/T-401/T-402/T-403/T-501 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/prompts/gui/workers | +| `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/T-301/T-302/T-302p/T-303/T-401/T-402/T-403/T-501/T-501b 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/prompts/gui/workers | | `app/excel.py` | 已有 | T-201/T-204/T-403 产出:多文件 Excel 输入列解析、必需列整文件拒绝、脏行逐行跳过、批次/任务入库、匹配统计;按源文件/工作表/行号回写旧标题与旧封面路径;按源文件/工作表/行号回写新标题、新封面路径、更新状态;支持原文件被占用时另存副本 | | `shopee待处理任务模板.xlsx` | 已有,已提交 | 标准空 Excel 模板;单工作表 `待处理任务`,表头 `账号名 | 别名 | 商品id | 旧标题 | 旧封面图片路径 | 新标题 | 新封面图片路径 | 更新状态`;运营复制后填写,填写副本不提交 | | `config.json` / `config/ai_models.json` / `cmshopee.db` / `chrome_user_data_dir/` / `images/` | 本地存在或按需生成,已忽略 | 含配置、密钥、业务、登录态、图片,不提交版本库 | @@ -60,14 +60,14 @@ 任务状态以 [`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-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-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)。 +- 已完成: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-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-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)。 - 正在进行:无。 -- 下一个可领取任务:**T-501b(Tab⑤ 设置 · 角色与生成参数)**。T-404 真实 Shopee 单条更新冒烟已登记,但依赖 T-501c 设置安全开关完成后再执行。 +- 下一个可领取任务:**T-501c(Tab⑤ 设置 · Shopee 更新安全开关)**。T-404 真实 Shopee 单条更新冒烟已登记,但依赖 T-501c 设置安全开关完成后再执行。 ## 当前已知限制 - ① 采集依赖对应账号 Chrome 已用专属 user-data-dir 和 CDP 端口启动并登录;T-205 已在采集前拦截未配置账号、Chrome 未启动、未登录,并引导去④账号管理,但不会无提示批量启动所有账号 Chrome。 -- T-501 已完成 `config/ai_models.json` 模型清单 UI;标题/图片默认模型角色、并发、重试、分辨率、jpg 质量、路径/端口等仍留给 T-501b。 +- T-501/T-501b 已完成 `config/ai_models.json` 模型清单 UI,以及 `config.json` 里的标题/图片默认模型角色、并发、重试、分辨率、jpg 质量、路径/端口设置。 - T-301/T-303 已完成通用 HTTP AI 接口、批量生成编排和 GUI 接入 mock 单测;真实 AI 生成还需要在 `config/ai_models.json` 填入可用 url/model/api_key 后做一次成本可控的小样本实测。 - T-403 已完成③更新结果回写与结束汇总;真实 Shopee 更新冒烟暂不执行,延后到 T-501c 设置安全开关完成后做 T-404 单条验收。T-404 默认先只测标题更新,封面更新作为可选子项。 diff --git a/docs/routes.md b/docs/routes.md index b9a0ef5..9c65006 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -128,8 +128,11 @@ - 密钥存在 `config/ai_models.json`,本地明文保存;UI 使用密码框打码显示,不进入日志。 - 保存复用 `appconfig` 模型清单后端,保持 name 唯一、至少 text/image 各一个模型。 - 「测试连接」通过后台 worker 调 `appconfig.test_ai_model()`,不阻塞主线程。 -- AI 生成参数(T-501b):标题/图片模型角色下拉、标题并发数、图片并发数、失败重试次数、分辨率、jpg 质量。 -- 路径与端口(T-501b):本地图片目录、Chrome 路径、默认调试端口 / 端口范围、超时、DB 路径。 +- AI 生成参数(T-501b 已接入):标题/图片模型角色下拉、标题并发数、图片并发数、失败重试次数、分辨率、jpg 质量。 + - 标题模型下拉只列 text 类模型;图片模型下拉只列 image 类模型。 + - 分辨率为 `512 / 1k / 2k / 4k`;返回超时只读展示 `resolution_timeouts[resolution]`,不单独编辑。 + - 保存写入 `config.json` 的 `ai` 段,供 ② AI生成复用。 +- 路径与端口(T-501b 已接入):本地图片目录、Chrome 路径、账号数据根目录、DB 路径、默认调试端口、端口范围、CDP 就绪超时。 ## 流程导航 @@ -156,7 +159,7 @@ | `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;开始生成/停止/进度已接入 `GenerateWorker` | | `ApplyTab(QWidget)` | ③ | 已生成任务筛选 + 开始更新确认 + 确认后串行更新 + 结果回写与结束汇总 | | `AccountsTab(QWidget)` | ④ | 账号增删改、启动登录、检测登录、生成快捷方式 | -| `SettingsTab(QWidget)` | ⑤ | AI 模型 master-detail 管理;后续接入生成参数、目录、Chrome 配置 | +| `SettingsTab(QWidget)` | ⑤ | AI 模型 master-detail 管理 + 角色/生成参数/路径/端口配置 | | `TaskTableModel(QAbstractTableModel)` | ①②③ | 任务表格数据模型,供 `QTableView` 使用 | | `BaseWorker(QObject)` | 后台 | 定义 `progress/log/row_updated/failed/finished/cancelled` signals | | `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、串行调用 `editor.apply_task()`、逐条 `set_applied()`,失败继续 | diff --git a/progress.md b/progress.md index dff31e4..27c18c6 100644 --- a/progress.md +++ b/progress.md @@ -585,3 +585,13 @@ - 测试:`tests/test_gui.py` 覆盖 MainWindow 挂载⑤、模型加载与密钥打码、新增/保存/删除、测试连接 worker、`AIModelTestWorker.execute()`。 - 文档:`docs/06-tasks.md` 将 T-501 标为 DONE;同步 `docs/routes.md`、`docs/api.md`、`docs/current-state.md`,下一个可领取任务更新为 T-501b。 - 验证:`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests -p test_gui.py` 通过(36 tests);`python -m unittest discover -s tests` 通过(92 tests)。 + +## 【2026-06-29】T-501b Tab⑤ 角色与生成参数 + +- 状态:DONE +- 变更:`app/gui.py` 扩展 `SettingsTab`,⑤设置页新增角色与生成参数、路径与端口配置区。标题大模型下拉只列 text 类模型,图片大模型下拉只列 image 类模型;支持标题/图片并发、失败重试、分辨率、jpg 质量、Chrome 路径、账号数据根目录、图片目录、DB 路径、默认调试端口、端口范围、CDP 就绪超时。 +- 配置:点击「保存设置」写入 `config.json`;保留 `resolution_timeouts`,分辨率切换时只读展示对应返回超时;保存前校验端口范围和默认端口。 +- 细节:`SettingsTab` 和 `MainWindow` 支持注入 `config_path` 便于测试;保存时不会把测试用的 `config_path`/`ai_models_path` 写入 `config.json`。 +- 测试:`tests/test_gui.py` 覆盖角色下拉类别过滤、分辨率返回超时显示、生成参数/路径/端口保存到临时 `config.json`、非法端口范围拦截。 +- 文档:`docs/06-tasks.md` 将 T-501b 标为 DONE;同步 `docs/routes.md`、`docs/api.md`、`docs/current-state.md`,下一个可领取任务更新为 T-501c。 +- 验证:`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests -p test_gui.py` 通过(38 tests);`python -m unittest discover -s tests` 通过(94 tests)。 diff --git a/tests/test_gui.py b/tests/test_gui.py index db4f335..39a74ca 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -54,6 +54,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): "db_path": os.path.join(temp_dir, "cmshopee.db"), "debug_port_range": [9222, 9260], "ai_models_path": os.path.join(temp_dir, "ai_models.json"), + "config_path": os.path.join(temp_dir, "config.json"), } def test_main_window_has_five_tabs_in_workflow_order(self): @@ -215,6 +216,153 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_settings_tab_saves_role_generation_path_and_port_config(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + cfg["ai"] = appconfig.default_config()["ai"] + cfg["ai"]["default_text_model"] = "Text A" + cfg["ai"]["default_image_model"] = "Image A" + models_path = cfg["ai_models_path"] + config_path = cfg["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": "Text B", + "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": {}, + }, + { + "name": "Image B", + "category": "image", + "enabled": True, + "url": "", + "model": "", + "api_key": "", + "api_type": "auto", + "connect_timeout_seconds": 30, + "timeout_seconds": 0, + "extra_body": {}, + }, + ] + }, + path=models_path, + ) + statuses = [] + tab = SettingsTab( + config=cfg, + config_path=config_path, + ai_models_path=models_path, + status_callback=statuses.append, + ) + self.addCleanup(tab.close) + + text_roles = [ + tab.default_text_model_combo.itemData(index) + for index in range(tab.default_text_model_combo.count()) + ] + image_roles = [ + tab.default_image_model_combo.itemData(index) + for index in range(tab.default_image_model_combo.count()) + ] + self.assertEqual(["Text A", "Text B"], text_roles) + self.assertEqual(["Image A", "Image B"], image_roles) + + tab.default_text_model_combo.setCurrentIndex( + tab.default_text_model_combo.findData("Text B") + ) + tab.default_image_model_combo.setCurrentIndex( + tab.default_image_model_combo.findData("Image B") + ) + tab.title_concurrency_spin.setValue(3) + tab.image_concurrency_spin.setValue(2) + tab.retry_spin.setValue(1) + tab.resolution_combo.setCurrentIndex(tab.resolution_combo.findData("2k")) + self.assertEqual("360 秒", tab.response_timeout_label.text()) + tab.jpg_quality_spin.setValue(86) + tab.chrome_path_edit.setText("D:\\Chrome\\chrome.exe") + tab.user_data_root_edit.setText("profiles") + tab.image_dir_edit.setText("pictures") + tab.db_path_edit.setText("data\\cmshopee.db") + 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.save_app_settings() + + saved = appconfig.load_config(config_path) + self.assertEqual("Text B", saved["ai"]["default_text_model"]) + self.assertEqual("Image B", saved["ai"]["default_image_model"]) + self.assertEqual(3, saved["ai"]["title_concurrency"]) + 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("D:\\Chrome\\chrome.exe", saved["chrome_path"]) + self.assertEqual("profiles", saved["user_data_root"]) + self.assertEqual("pictures", saved["image_dir"]) + self.assertEqual("data\\cmshopee.db", saved["db_path"]) + self.assertEqual(9300, saved["default_debug_port"]) + self.assertEqual([9300, 9350], saved["debug_port_range"]) + self.assertEqual(45, saved["cdp_ready_timeout"]) + self.assertNotIn("ai_models_path", saved) + self.assertNotIn("config_path", saved) + self.assertIn("设置已保存", statuses[-1]) + + self.assert_removed(temp_dir) + + def test_settings_tab_rejects_invalid_port_range(self): + with self.make_temp_dir() as temp_dir: + cfg = self.make_config(temp_dir) + tab = SettingsTab( + config=cfg, + config_path=cfg["config_path"], + ai_models_path=cfg["ai_models_path"], + ) + self.addCleanup(tab.close) + + tab.debug_port_start_spin.setValue(9400) + tab.debug_port_end_spin.setValue(9300) + with mock.patch("app.gui.QMessageBox.warning") as warning: + tab.save_app_settings() + + warning.assert_called_once() + self.assertFalse(os.path.exists(cfg["config_path"])) + + self.assert_removed(temp_dir) + def test_ai_model_test_worker_calls_appconfig(self): with self.make_temp_dir() as temp_dir: models_path = os.path.join(temp_dir, "ai_models.json")