feat: track settings changes and enforce Chinese UI copy

This commit is contained in:
chengma
2026-07-06 11:32:02 +08:00
parent 26e59c8cea
commit 19eaa794cf
10 changed files with 430 additions and 19 deletions
+1
View File
@@ -53,4 +53,5 @@ T-006 前若 `tests/` 不存在,不运行 `unittest discover`,只在回复
## 风格 ## 风格
- 文档与 UI 文案用中文,标识符用英文。 - 文档与 UI 文案用中文,标识符用英文。
- 所有用户可见文本必须使用中文:包括弹窗标题/正文/按钮、窗口标题、按钮、菜单、label、placeholder、tooltip、状态栏、空状态、确认框、运行日志、错误提示和成功提示等。URL、配置键、API 字段、模型别名、第三方/Shopee 原始错误可保留原文,但外围必须给中文解释;不得直接把英文技术报错裸露给用户。
- 内容面向 agent 执行:能落到“读什么、改什么、验证什么”。 - 内容面向 agent 执行:能落到“读什么、改什么、验证什么”。
+47
View File
@@ -27,6 +27,9 @@ class MainWindow(QMainWindow):
) )
self.setWindowTitle("cmshopee") self.setWindowTitle("cmshopee")
self.resize(1180, 760) self.resize(1180, 760)
self._settings_tab_index = TAB_TITLES.index("⑤ 设置")
self._last_tab_index = 0
self._reverting_tab_change = False
self.tabs = QTabWidget() self.tabs = QTabWidget()
self.tabs.setObjectName("mainTabs") self.tabs.setObjectName("mainTabs")
self.tabs.setStyleSheet(TAB_STYLE) self.tabs.setStyleSheet(TAB_STYLE)
@@ -82,8 +85,52 @@ class MainWindow(QMainWindow):
widget.refresh_tasks() widget.refresh_tasks()
def _on_tab_changed(self, index): def _on_tab_changed(self, index):
if self._reverting_tab_change:
self._last_tab_index = index
self.statusBar().showMessage(f"当前:{self.tabs.tabText(index)}")
return
previous = self._last_tab_index
if previous == self._settings_tab_index and index != previous:
if not self._confirm_leave_settings_tab():
self._reverting_tab_change = True
try:
self.tabs.setCurrentIndex(previous)
finally:
self._reverting_tab_change = False
self._last_tab_index = previous
self.statusBar().showMessage(f"当前:{self.tabs.tabText(previous)}")
return
self._last_tab_index = index
self.statusBar().showMessage(f"当前:{self.tabs.tabText(index)}") self.statusBar().showMessage(f"当前:{self.tabs.tabText(index)}")
def _settings_tab(self):
return self.tabs.widget(self._settings_tab_index)
def _confirm_leave_settings_tab(self):
settings_tab = self._settings_tab()
if not hasattr(settings_tab, "is_dirty") or not settings_tab.is_dirty():
return True
box = QMessageBox(self)
box.setWindowTitle("未保存更改")
box.setText("⑤ 设置有未保存更改。要先保存再离开吗?")
save_button = box.addButton("保存", QMessageBox.AcceptRole)
discard_button = box.addButton("放弃", QMessageBox.DestructiveRole)
cancel_button = box.addButton("取消", QMessageBox.RejectRole)
box.setDefaultButton(save_button)
box.exec()
clicked = box.clickedButton()
if clicked is save_button:
return bool(settings_tab.save_app_settings())
if clicked is discard_button:
return bool(settings_tab.discard_unsaved_changes())
return False
def closeEvent(self, event):
if self._confirm_leave_settings_tab():
event.accept()
return
event.ignore()
def open_accounts_tab(self): def open_accounts_tab(self):
self.tabs.setCurrentIndex(TAB_TITLES.index("④ 账号管理")) self.tabs.setCurrentIndex(TAB_TITLES.index("④ 账号管理"))
+118 -6
View File
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
from contextlib import contextmanager
from ..widgets import * from ..widgets import *
from ..workers import AIModelTestWorker as _RealAIModelTestWorker from ..workers import AIModelTestWorker as _RealAIModelTestWorker
from ..workers import CMHubSettingsWorker as _RealCMHubSettingsWorker from ..workers import CMHubSettingsWorker as _RealCMHubSettingsWorker
@@ -63,6 +65,8 @@ class SettingsTab(QWidget):
self._loaded_cmhub_api_key = "" self._loaded_cmhub_api_key = ""
self._cmhub_auto_refresh_done = False self._cmhub_auto_refresh_done = False
self._compat_test_item_id = "" self._compat_test_item_id = ""
self._dirty = False
self._suspend_dirty = 0
self.backend_combo = QComboBox() self.backend_combo = QComboBox()
self.backend_combo.setObjectName("aiBackendCombo") self.backend_combo.setObjectName("aiBackendCombo")
@@ -173,6 +177,10 @@ class SettingsTab(QWidget):
self.cdp_ready_timeout_spin.setObjectName("cdpReadyTimeoutSpin") self.cdp_ready_timeout_spin.setObjectName("cdpReadyTimeoutSpin")
self.cdp_ready_timeout_spin.setRange(1, 3600) self.cdp_ready_timeout_spin.setRange(1, 3600)
self.save_config_button = QPushButton("保存设置") self.save_config_button = QPushButton("保存设置")
self.unsaved_changes_label = QLabel("● 未保存更改")
self.unsaved_changes_label.setObjectName("settingsUnsavedChangesLabel")
self.unsaved_changes_label.setStyleSheet("color: #bc4c00; font-weight: 600;")
self.unsaved_changes_label.setVisible(False)
self.allow_real_submit_checkbox = QCheckBox("允许真实提交线上商品") self.allow_real_submit_checkbox = QCheckBox("允许真实提交线上商品")
self.allow_real_submit_checkbox.setObjectName("allowRealSubmitCheckbox") self.allow_real_submit_checkbox.setObjectName("allowRealSubmitCheckbox")
self.allow_cover_update_checkbox = QCheckBox("允许更新封面") self.allow_cover_update_checkbox = QCheckBox("允许更新封面")
@@ -351,7 +359,12 @@ class SettingsTab(QWidget):
panel_layout.addSpacing(18) panel_layout.addSpacing(18)
panel_layout.addWidget(self.infrastructure_section_title) panel_layout.addWidget(self.infrastructure_section_title)
panel_layout.addLayout(path_form) panel_layout.addLayout(path_form)
panel_layout.addWidget(self.save_config_button) save_settings_layout = QHBoxLayout()
save_settings_layout.setContentsMargins(0, 0, 0, 0)
save_settings_layout.addWidget(self.save_config_button)
save_settings_layout.addWidget(self.unsaved_changes_label)
save_settings_layout.addStretch(1)
panel_layout.addLayout(save_settings_layout)
panel_layout.addStretch(1) panel_layout.addStretch(1)
scroll = QScrollArea() scroll = QScrollArea()
@@ -380,9 +393,12 @@ class SettingsTab(QWidget):
self._update_response_timeout_label self._update_response_timeout_label
) )
self.save_config_button.clicked.connect(self.save_app_settings) self.save_config_button.clicked.connect(self.save_app_settings)
self._connect_dirty_signals()
self.refresh_models() with self._dirty_tracking_suspended():
self._populate_app_settings() self.refresh_models()
self._populate_app_settings()
self._set_dirty(False)
def _three_column_form(self, fields): def _three_column_form(self, fields):
layout = QGridLayout() layout = QGridLayout()
@@ -436,6 +452,92 @@ class SettingsTab(QWidget):
if self.status_callback is not None: if self.status_callback is not None:
self.status_callback(message) self.status_callback(message)
@contextmanager
def _dirty_tracking_suspended(self):
self._suspend_dirty += 1
try:
yield
finally:
self._suspend_dirty -= 1
def _connect_dirty_signals(self):
line_edits = (
self.cmhub_base_url_edit,
self.cmhub_api_key_edit,
self.name_edit,
self.model_id_edit,
self.url_edit,
self.api_key_edit,
self.chrome_path_edit,
self.user_data_root_edit,
self.image_dir_edit,
self.db_path_edit,
)
combos = (
self.backend_combo,
self.cmhub_title_alias_combo,
self.cmhub_image_alias_combo,
self.category_combo,
self.api_type_combo,
self.default_text_model_combo,
self.default_image_model_combo,
self.resolution_combo,
)
spin_boxes = (
self.cmhub_connect_timeout_spin,
self.connect_timeout_spin,
self.title_concurrency_spin,
self.image_concurrency_spin,
self.retry_spin,
self.jpg_quality_spin,
self.default_debug_port_spin,
self.debug_port_start_spin,
self.debug_port_end_spin,
self.cdp_ready_timeout_spin,
self.max_items_per_run_spin,
self.max_parallel_accounts_spin,
)
checkboxes = (
self.cmhub_check_balance_checkbox,
self.enabled_checkbox,
self.allow_real_submit_checkbox,
self.allow_cover_update_checkbox,
self.close_success_tab_checkbox,
self.parallel_accounts_checkbox,
)
for widget in line_edits:
widget.textChanged.connect(self._mark_dirty)
for widget in combos:
widget.currentIndexChanged.connect(self._mark_dirty)
for widget in spin_boxes:
widget.valueChanged.connect(self._mark_dirty)
for widget in checkboxes:
widget.toggled.connect(self._mark_dirty)
def _mark_dirty(self, *args):
if self._suspend_dirty > 0:
return
self._set_dirty(True)
def _set_dirty(self, dirty):
self._dirty = bool(dirty)
self.unsaved_changes_label.setVisible(self._dirty)
def is_dirty(self):
return self._dirty
def discard_unsaved_changes(self):
try:
saved = appconfig.load_config(self.config_path)
except Exception as exc:
self._show_error(exc)
return False
self._replace_config(saved)
self._populate_app_settings()
self._set_dirty(False)
self._set_status("已放弃未保存更改")
return True
def _maybe_auto_refresh_cmhub_models(self): def _maybe_auto_refresh_cmhub_models(self):
if self._cmhub_auto_refresh_done: if self._cmhub_auto_refresh_done:
return return
@@ -594,7 +696,7 @@ class SettingsTab(QWidget):
def save_app_settings(self, checked=False): def save_app_settings(self, checked=False):
settings = self._app_settings_values() settings = self._app_settings_values()
if settings is None: if settings is None:
return return False
cmhub_key = self.cmhub_api_key_edit.text() cmhub_key = self.cmhub_api_key_edit.text()
if self._should_warn_plaintext_cmhub_api_key(cmhub_key): if self._should_warn_plaintext_cmhub_api_key(cmhub_key):
self._show_plaintext_cmhub_api_key_warning() self._show_plaintext_cmhub_api_key_warning()
@@ -606,11 +708,13 @@ class SettingsTab(QWidget):
saved = appconfig.save_config(settings, path=self.config_path) saved = appconfig.save_config(settings, path=self.config_path)
except Exception as exc: except Exception as exc:
self._show_error(exc) self._show_error(exc)
return return False
self._replace_config(saved) self._replace_config(saved)
self._populate_app_settings() self._populate_app_settings()
self._set_dirty(False)
self._set_status("设置已保存") self._set_status("设置已保存")
QMessageBox.information(self, "保存设置", "设置已保存") QMessageBox.information(self, "保存设置", "设置已保存")
return True
def _app_settings_values(self): def _app_settings_values(self):
start_port = self.debug_port_start_spin.value() start_port = self.debug_port_start_spin.value()
@@ -700,6 +804,10 @@ class SettingsTab(QWidget):
self.config.update(internal) self.config.update(internal)
def _populate_app_settings(self): def _populate_app_settings(self):
if self._suspend_dirty <= 0:
with self._dirty_tracking_suspended():
self._populate_app_settings()
return
self._populate_role_model_combos() self._populate_role_model_combos()
ai_cfg = appconfig.ai_config(self.config) ai_cfg = appconfig.ai_config(self.config)
self._set_combo_by_data(self.backend_combo, "cmhub") self._set_combo_by_data(self.backend_combo, "cmhub")
@@ -1011,7 +1119,7 @@ class SettingsTab(QWidget):
image_count = self._cmhub_alias_count("image") image_count = self._cmhub_alias_count("image")
balance = payload.get("points_balance") balance = payload.get("points_balance")
balance_text = f";余额 {balance}" if balance is not None else "" balance_text = f";余额 {balance}" if balance is not None else ""
message = f"cmhub 连接成功:生文别名 {title_count} 个,生图别名 {image_count} 个{balance_text}" message = f"cmhub 连接成功:生文别名 {title_count} 个,生图别名 {image_count} 个{balance_text};别名已拉取,记得点『保存设置』持久化"
self.cmhub_result_label.setText(message) self.cmhub_result_label.setText(message)
self._set_status(message) self._set_status(message)
@@ -1021,6 +1129,10 @@ class SettingsTab(QWidget):
self._set_status(message) self._set_status(message)
def _populate_cmhub_alias_combos(self, models, title_selected="", image_selected=""): def _populate_cmhub_alias_combos(self, models, title_selected="", image_selected=""):
if self._suspend_dirty <= 0:
with self._dirty_tracking_suspended():
self._populate_cmhub_alias_combos(models, title_selected, image_selected)
return
self._populate_cmhub_alias_combo( self._populate_cmhub_alias_combo(
self.cmhub_title_alias_combo, self.cmhub_title_alias_combo,
models, models,
+1
View File
@@ -43,6 +43,7 @@
## 5. 代码规范 ## 5. 代码规范
- 标识符使用英文;UI 文案、注释、文档保持中文,与现有脚本一致。 - 标识符使用英文;UI 文案、注释、文档保持中文,与现有脚本一致。
- 所有用户可见文本必须使用中文:包括弹窗标题/正文/按钮、窗口标题、按钮、菜单、label、placeholder、tooltip、状态栏、空状态、确认框、运行日志、错误提示和成功提示等。不要直接使用 `Save` / `Discard` / `Cancel` 等英文标准按钮露出给用户;如需三选一弹窗,使用自定义中文按钮文案(如“保存”“放弃”“取消”)。URL、配置键、API 字段、模型别名、第三方/Shopee 原始错误可保留原文,但外围必须有中文解释。
- 错误必须处理:CDP 超时、tab 找不到、上传失败、按钮禁用都要给明确提示,不吞错。 - 错误必须处理:CDP 超时、tab 找不到、上传失败、按钮禁用都要给明确提示,不吞错。
- 注释解释“为什么”(尤其 CDP 的坑:代理、Origin、就绪判断、拖拽落点),不复述“做了什么”。 - 注释解释“为什么”(尤其 CDP 的坑:代理、Origin、就绪判断、拖拽落点),不复述“做了什么”。
- 复用现有脚本里已验证的 JS 字符串,不重写出不一致的版本。 - 复用现有脚本里已验证的 JS 字符串,不重写出不一致的版本。
+1 -1
View File
@@ -129,7 +129,7 @@
| 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 流程 | 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 流程 | DONE |
| T-529 | 默认 cmhub 网关并隐藏 AI 后端选择 | T-528, T-527 | 产品收口:后续普通用户只使用 cmhub 网关,不再在⑤设置页暴露「AI 后端」label 与 direct/cmhub 下拉框。方案:`DEFAULT_CONFIG.ai.backend` 改为 `cmhub`;⑤设置页默认直接展示 cmhub 网关配置(Base URL、API Key、生文/生图别名、刷新别名、测试连接/查余额),隐藏 direct 模型选择、模型详情、标题/图片模型角色下拉和「AI 后端」下拉;保存设置固定写 `ai.backend=cmhub`,允许先保存不完整 cmhub 配置,②生成时仍由 `app/ai.py` 对缺 Base URL/Key/别名给出「请去⑤配置 cmhub」错误。direct 代码、`config/ai_models.json` helper 与 direct 单测保留为内部兼容/手工回滚路径,但普通 UI 不提供切换入口。同步 `appconfig`、⑤设置页、②余额默认显示和 GUI/appconfig 单测;不改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | DONE | | T-529 | 默认 cmhub 网关并隐藏 AI 后端选择 | T-528, T-527 | 产品收口:后续普通用户只使用 cmhub 网关,不再在⑤设置页暴露「AI 后端」label 与 direct/cmhub 下拉框。方案:`DEFAULT_CONFIG.ai.backend` 改为 `cmhub`;⑤设置页默认直接展示 cmhub 网关配置(Base URL、API Key、生文/生图别名、刷新别名、测试连接/查余额),隐藏 direct 模型选择、模型详情、标题/图片模型角色下拉和「AI 后端」下拉;保存设置固定写 `ai.backend=cmhub`,允许先保存不完整 cmhub 配置,②生成时仍由 `app/ai.py` 对缺 Base URL/Key/别名给出「请去⑤配置 cmhub」错误。direct 代码、`config/ai_models.json` helper 与 direct 单测保留为内部兼容/手工回滚路径,但普通 UI 不提供切换入口。同步 `appconfig`、⑤设置页、②余额默认显示和 GUI/appconfig 单测;不改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | DONE |
| T-530 | cmhub Base URL 规整 + 404 明确提示 | T-526, T-527 | 现象:手动填入 Base URL 后点「刷新别名」提示 notfound(HTTP 404)。核对确认 cmshopee 请求已达标(`GET /api/v1/models` + Bearer,对齐对接文档 §4.4 与 cmhub `ModelsView` 路由,见 `docs/troubleshooting.md`「cmhub 刷新别名 notfound」与 `docs/cmhub-integration-design.md` v3.4);404 根因是 Base URL 带多余 `/api(/v1)` 路径导致双拼、或所连实例未部署 `/api/v1/models`。方案:保存/请求前规整 Base URL——去掉结尾的 `/`、`/api`、`/api/v1` 等多余路径段,只保留 scheme+host(+port)(`appconfig.cmhub_request_url()` 或保存时统一处理),并在⑤输入框旁给出「只填网关根,如 https://host」提示;`_cmhub_code_for_status` 给 404 一个明确码/中文提示(如「cmhub 接口不存在,请检查 Base URL 或该实例是否已部署 /api/v1/models」),不再透传生涩英文原文。补 `test_appconfig`(Base URL 规整:带 `/api/v1`、带尾斜杠、带路径均归一到根)与 `test_ai`(404 → 明确提示)单测;不改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | DONE | | T-530 | cmhub Base URL 规整 + 404 明确提示 | T-526, T-527 | 现象:手动填入 Base URL 后点「刷新别名」提示 notfound(HTTP 404)。核对确认 cmshopee 请求已达标(`GET /api/v1/models` + Bearer,对齐对接文档 §4.4 与 cmhub `ModelsView` 路由,见 `docs/troubleshooting.md`「cmhub 刷新别名 notfound」与 `docs/cmhub-integration-design.md` v3.4);404 根因是 Base URL 带多余 `/api(/v1)` 路径导致双拼、或所连实例未部署 `/api/v1/models`。方案:保存/请求前规整 Base URL——去掉结尾的 `/`、`/api`、`/api/v1` 等多余路径段,只保留 scheme+host(+port)(`appconfig.cmhub_request_url()` 或保存时统一处理),并在⑤输入框旁给出「只填网关根,如 https://host」提示;`_cmhub_code_for_status` 给 404 一个明确码/中文提示(如「cmhub 接口不存在,请检查 Base URL 或该实例是否已部署 /api/v1/models」),不再透传生涩英文原文。补 `test_appconfig`(Base URL 规整:带 `/api/v1`、带尾斜杠、带路径均归一到根)与 `test_ai`(404 → 明确提示)单测;不改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | DONE |
| T-531 | ⑤ 设置未保存状态追踪 + 离开确认 | T-530, T-529, T-527 | 现象:在⑤填写 cmhub Base URL/API Key(或改任何设置)后,没有任何"未保存"提示;切换到其它 Tab、关闭窗口都不拦截,输入静默丢失。且「刷新别名/测试连接」用输入框实时文本、不落盘,成功后易误以为已保存。采用**方式 B(显式保存 + 脏状态追踪 + 离开确认)**,不改自动保存(API Key/半份 cmhub 配置不宜自动写盘)。方案:① 脏追踪——⑤ 所有可编辑控件(cmhub Base URL/API Key/别名下拉、模型详情、生成参数、路径端口、Shopee 更新安全等)接 `textChanged`/`currentIndexChanged`/`toggled`/`valueChanged` 置 `self._dirty=True`;「保存设置」成功后清 `False`;`save_app_settings()` 或等价保存入口必须返回成功/失败,失败时保留 dirty 并阻止离开。② 程序化回填保护——加载配置、保存后重载、刷新别名写回下拉、`_populate_app_settings()` / `_populate_cmhub_alias_combos()` 等必须用 `_suspend_dirty` 或 `QSignalBlocker` 避免误置脏;用户手动输入后由刷新/测试触发的 Base URL 规整仍应保持 dirty,提醒其保存。③ 离开拦截——`main_window` 切 Tab 与 `closeEvent` 若发现⑤ `_dirty` 为真,弹三选一「保存 / 放弃 / 取消」:保存则调保存入口且成功后再切/关;放弃必须重新从 `config.json` + `config/cmhub.json` 载入并回填控件,再清 dirty,不能只清标志;取消则留在⑤不切不关。`QTabWidget.currentChanged` 是切换后信号,实现时需维护上一 Tab index,并用 `_reverting_tab_change`/类似 guard 防止 `setCurrentIndex()` 递归触发。④ 视觉提示——「保存设置」旁显示「● 未保存更改」,保存或放弃回填后消失。⑤ 「刷新别名/测试连接」成功后提示语补「别名已拉取,记得点『保存设置』持久化」,**不**顺带偷偷保存 url/key(保持显式保存一致性)。只改 ⑤ 设置页、`main_window` Tab 切换/关闭守卫和 GUI 单测(脏标志置位/清除、切 Tab 保存/放弃/取消/保存失败分支、程序化回填不置脏、放弃会还原输入、currentChanged 回退不递归);不改配置 schema、AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | TODO | | T-531 | ⑤ 设置未保存状态追踪 + 离开确认 | T-530, T-529, T-527 | 现象:在⑤填写 cmhub Base URL/API Key(或改任何设置)后,没有任何"未保存"提示;切换到其它 Tab、关闭窗口都不拦截,输入静默丢失。且「刷新别名/测试连接」用输入框实时文本、不落盘,成功后易误以为已保存。采用**方式 B(显式保存 + 脏状态追踪 + 离开确认)**,不改自动保存(API Key/半份 cmhub 配置不宜自动写盘)。方案:① 脏追踪——⑤ 所有可编辑控件(cmhub Base URL/API Key/别名下拉、模型详情、生成参数、路径端口、Shopee 更新安全等)接 `textChanged`/`currentIndexChanged`/`toggled`/`valueChanged` 置 `self._dirty=True`;「保存设置」成功后清 `False`;`save_app_settings()` 或等价保存入口必须返回成功/失败,失败时保留 dirty 并阻止离开。② 程序化回填保护——加载配置、保存后重载、刷新别名写回下拉、`_populate_app_settings()` / `_populate_cmhub_alias_combos()` 等必须用 `_suspend_dirty` 或 `QSignalBlocker` 避免误置脏;用户手动输入后由刷新/测试触发的 Base URL 规整仍应保持 dirty,提醒其保存。③ 离开拦截——`main_window` 切 Tab 与 `closeEvent` 若发现⑤ `_dirty` 为真,弹三选一「保存 / 放弃 / 取消」:保存则调保存入口且成功后再切/关;放弃必须重新从 `config.json` + `config/cmhub.json` 载入并回填控件,再清 dirty,不能只清标志;取消则留在⑤不切不关。`QTabWidget.currentChanged` 是切换后信号,实现时需维护上一 Tab index,并用 `_reverting_tab_change`/类似 guard 防止 `setCurrentIndex()` 递归触发。④ 视觉提示——「保存设置」旁显示「● 未保存更改」,保存或放弃回填后消失。⑤ 「刷新别名/测试连接」成功后提示语补「别名已拉取,记得点『保存设置』持久化」,**不**顺带偷偷保存 url/key(保持显式保存一致性)。只改 ⑤ 设置页、`main_window` Tab 切换/关闭守卫和 GUI 单测(脏标志置位/清除、切 Tab 保存/放弃/取消/保存失败分支、程序化回填不置脏、放弃会还原输入、currentChanged 回退不递归);不改配置 schema、AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | DONE |
## Phase 8 · 工程基础设施后续(`docs/engineering-review.md`) ## Phase 8 · 工程基础设施后续(`docs/engineering-review.md`)
+2 -2
View File
@@ -381,9 +381,9 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
⑤ 设置当前要点(T-501): ⑤ 设置当前要点(T-501):
- `SettingsTab` 使用居中内容区 + 适度左右留白布局,当前留白已从 T-506 初始实现缩短到约 40%;实现上使用最大内容宽度和自适应 margin,避免固定像素导致小屏挤压。各设置组默认响应式 3 列表单:短字段占 1 格,长字段(URL/API Key/路径)跨 2 格或 3 格,窄窗口降为 2 列/1 列。点击「保存设置」成功后,调用 `QMessageBox.information` 弹出“设置已保存”轻量提示框,同时保留状态栏提示。T-531 后 `save_app_settings()` 或包装保存入口应返回 bool:成功写 `config.json` + `config/cmhub.json` 后清 dirty,失败保留 dirty 并让调用方阻止离开。 - `SettingsTab` 使用居中内容区 + 适度左右留白布局,当前留白已从 T-506 初始实现缩短到约 40%;实现上使用最大内容宽度和自适应 margin,避免固定像素导致小屏挤压。各设置组默认响应式 3 列表单:短字段占 1 格,长字段(URL/API Key/路径)跨 2 格或 3 格,窄窗口降为 2 列/1 列。点击「保存设置」成功后,调用 `QMessageBox.information` 弹出“设置已保存”轻量提示框,同时保留状态栏提示。T-531 已完成:`save_app_settings()` 返回 bool,成功写 `config.json` + `config/cmhub.json` 后清 dirty,失败保留 dirty 并让调用方阻止离开。
- `SettingsTab` 的 cmhub 网关配置:Base URL 保存/刷新前规整为网关根;API Key 单独读写 `config/cmhub.json`;别名下拉来自 `fetch_cmhub_models()`,按 `operation_type` 分生文/生图并过滤未计价别名;「测试连接/查余额」调用 cmhub models + balance helper;保存设置固定写 `ai.backend=cmhub`。`refresh_cmhub_models()` / `test_cmhub_connection()` 使用输入框实时值但不得自动保存,成功文案提醒用户保存。`is_dirty()` / `discard_unsaved_changes()` / `_suspend_dirty`(或等价机制)用于 T-531:用户编辑置脏,程序化回填不置脏,放弃时重新加载 `config.json` + `config/cmhub.json` 并回填控件。 - `SettingsTab` 的 cmhub 网关配置:Base URL 保存/刷新前规整为网关根;API Key 单独读写 `config/cmhub.json`;别名下拉来自 `fetch_cmhub_models()`,按 `operation_type` 分生文/生图并过滤未计价别名;「测试连接/查余额」调用 cmhub models + balance helper;保存设置固定写 `ai.backend=cmhub`。`refresh_cmhub_models()` / `test_cmhub_connection()` 使用输入框实时值但不得自动保存,成功文案提醒用户保存。`is_dirty()` / `discard_unsaved_changes()` / `_suspend_dirty`(或等价机制)用于 T-531:用户编辑置脏,程序化回填不置脏,放弃时重新加载 `config.json` + `config/cmhub.json` 并回填控件。
- `MainWindow` 在 T-531 后负责⑤设置页离开守卫:切 Tab 与 `closeEvent` 发现 `SettingsTab.is_dirty()` 时弹保存/放弃/取消;保存成功后继续,保存失败或取消时回到⑤。由于 `QTabWidget.currentChanged` 是切换后信号,需维护上一个 index,并用 `_reverting_tab_change` 或等价 guard 防止 `setCurrentIndex()` 递归。 - `MainWindow` 已负责⑤设置页离开守卫:切 Tab 与 `closeEvent` 发现 `SettingsTab.is_dirty()` 时弹保存/放弃/取消;保存成功后继续,保存失败或取消时回到⑤。由于 `QTabWidget.currentChanged` 是切换后信号,需维护上一个 index,并用 `_reverting_tab_change` 或等价 guard 防止 `setCurrentIndex()` 递归。
- 模型详情字段按 3 个组件一组排列:启用、类别、api_type、连接超时等短字段一格;服务商名、模型 ID 视宽度占一格或两格;网址、密钥跨整行或跨 2/3 列。 - 模型详情字段按 3 个组件一组排列:启用、类别、api_type、连接超时等短字段一格;服务商名、模型 ID 视宽度占一格或两格;网址、密钥跨整行或跨 2/3 列。
- 模型数据读写复用 `appconfig.list_ai_models(..., reveal_api_key=True)`、`add_ai_model()`、`update_ai_model()`、`delete_ai_model()`;保存时保留现有 `extra_body` 与 `timeout_seconds`。 - 模型数据读写复用 `appconfig.list_ai_models(..., reveal_api_key=True)`、`add_ai_model()`、`update_ai_model()`、`delete_ai_model()`;保存时保留现有 `extra_body` 与 `timeout_seconds`。
- 密钥字段使用 `QLineEdit.Password` 打码显示;首次保存/变更非空 Key 前弹窗提示“本地明文保存”;明文只写入已 gitignore 的 `config/ai_models.json`,不得进入日志/导出。 - 密钥字段使用 `QLineEdit.Password` 打码显示;首次保存/变更非空 Key 前弹窗提示“本地明文保存”;明文只写入已 gitignore 的 `config/ai_models.json`,不得进入日志/导出。
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -142,12 +142,12 @@
## ⑤ 设置 ## ⑤ 设置
- 设置页整体布局:内容区居中,左右留白已从 T-506 初始实现缩短到约 40%;实现上用最大内容宽度 + 自适应 margin,而不是写死窗口像素。所有设置组默认响应式 3 列表单:短字段占 1 格,URL/API Key/路径等长字段跨 2 格或 3 格;窄窗口自动降为 2 列/1 列。点击「保存设置」成功后,状态栏显示“设置已保存”,并弹出轻量提示框。T-531 后保存成功还需清除未保存标记;保存失败时保留未保存标记并阻止离开。 - 设置页整体布局:内容区居中,左右留白已从 T-506 初始实现缩短到约 40%;实现上用最大内容宽度 + 自适应 margin,而不是写死窗口像素。所有设置组默认响应式 3 列表单:短字段占 1 格,URL/API Key/路径等长字段跨 2 格或 3 格;窄窗口自动降为 2 列/1 列。点击「保存设置」成功后,状态栏显示“设置已保存”,并弹出轻量提示框。T-531 已完成:保存成功会清除未保存标记;保存失败时保留未保存标记并阻止离开。
- cmhub 网关配置(T-529 后普通用户唯一 AI 入口):⑤设置页不再显示「AI 后端」label 或 direct/cmhub 下拉,直接展示 cmhub 网关 Base URL、API Key、生文别名、生图别名、连接超时、刷新别名、测试连接/查余额;Base URL 输入框旁提示“只填网关根,如 https://host”,保存/刷新前会规整掉 `/api`、`/api/v1` 或其它路径。刷新别名/测试连接使用当前输入框内容发起请求,但不自动保存 URL/API Key,成功文案需提醒“记得点保存设置持久化”。 - cmhub 网关配置(T-529 后普通用户唯一 AI 入口):⑤设置页不再显示「AI 后端」label 或 direct/cmhub 下拉,直接展示 cmhub 网关 Base URL、API Key、生文别名、生图别名、连接超时、刷新别名、测试连接/查余额;Base URL 输入框旁提示“只填网关根,如 https://host”,保存/刷新前会规整掉 `/api`、`/api/v1` 或其它路径。刷新别名/测试连接使用当前输入框内容发起请求,但不自动保存 URL/API Key,成功文案需提醒“记得点保存设置持久化”。
- API Key 存在 `config/cmhub.json`,本地明文保存;保存/变更时提示;UI 使用密码框打码显示,不进入日志/导出。 - API Key 存在 `config/cmhub.json`,本地明文保存;保存/变更时提示;UI 使用密码框打码显示,不进入日志/导出。
- 生文/生图别名来自 `GET /api/v1/models` 动态下拉,过滤未定价别名并展示单价/需原图提示;网关临时不可达时保留已存别名。 - 生文/生图别名来自 `GET /api/v1/models` 动态下拉,过滤未定价别名并展示单价/需原图提示;网关临时不可达时保留已存别名。
- 保存设置固定写 `ai.backend=cmhub`。允许先保存不完整 cmhub 配置,②真正生成时如果缺 Base URL/API Key/别名,会提示去⑤补配置,不静默回退 direct。 - 保存设置固定写 `ai.backend=cmhub`。允许先保存不完整 cmhub 配置,②真正生成时如果缺 Base URL/API Key/别名,会提示去⑤补配置,不静默回退 direct。
- T-531 后,⑤设置页任意可编辑控件变更都进入未保存状态,保存按钮旁显示“● 未保存更改”;切换到其它 Tab 或关闭窗口时弹出保存/放弃/取消。放弃必须重新从本地配置文件回填控件,避免未保存的 URL/API Key 留在界面上;程序化回填、保存后重载和刷新别名填充下拉不得误触发未保存状态。 - T-531 已完成:⑤设置页任意可编辑控件变更都进入未保存状态,保存按钮旁显示“● 未保存更改”;切换到其它 Tab 或关闭窗口时弹出保存/放弃/取消。放弃会重新从本地配置文件回填控件,避免未保存的 URL/API Key 留在界面上;程序化回填、保存后重载和刷新别名填充下拉不会误触发未保存状态。
- direct 模型清单和 `config/ai_models.json` 代码路径保留为内部兼容/手工回滚,不在普通 UI 暴露。 - direct 模型清单和 `config/ai_models.json` 代码路径保留为内部兼容/手工回滚,不在普通 UI 暴露。
- AI 生成参数:标题并发、图片并发、失败重试、分辨率、返回超时、jpg 质量等短字段按三列排列。 - AI 生成参数:标题并发、图片并发、失败重试、分辨率、返回超时、jpg 质量等短字段按三列排列。
- 分辨率为 `512 / 1k / 2k / 4k`;返回超时只读展示 `resolution_timeouts[resolution]`,不单独编辑。 - 分辨率为 `512 / 1k / 2k / 4k`;返回超时只读展示 `resolution_timeouts[resolution]`,不单独编辑。
+20 -1
View File
@@ -1156,4 +1156,23 @@
- 背景:Claude Code 已在任务看板新增 T-531,用于解决⑤设置页填写 cmhub Base URL/API Key 后没有未保存提示、切 Tab/关闭窗口会静默丢输入的问题。 - 背景:Claude Code 已在任务看板新增 T-531,用于解决⑤设置页填写 cmhub Base URL/API Key 后没有未保存提示、切 Tab/关闭窗口会静默丢输入的问题。
- 修正:认可“显式保存 + 脏状态追踪 + 离开确认”方案,不做自动保存;补充 `save_app_settings()` 成功/失败返回语义、放弃必须重新从 `config.json` 与 `config/cmhub.json` 回填、程序化回填不置脏、`currentChanged` 回退需 guard 防递归、刷新别名/测试连接成功只提醒保存不落盘。 - 修正:认可“显式保存 + 脏状态追踪 + 离开确认”方案,不做自动保存;补充 `save_app_settings()` 成功/失败返回语义、放弃必须重新从 `config.json` 与 `config/cmhub.json` 回填、程序化回填不置脏、`currentChanged` 回退需 guard 防递归、刷新别名/测试连接成功只提醒保存不落盘。
- 文档:同步 `docs/06-tasks.md`、`docs/current-state.md`、`docs/routes.md`、`docs/api.md`;下一个可领取任务改为 T-531,T-525 ruff 顺延。 - 文档:同步 `docs/06-tasks.md`、`docs/current-state.md`、`docs/routes.md`、`docs/api.md`;下一个可领取任务改为 T-531,T-525 ruff 顺延。
- 验证:文档-only 更新,未运行单元测试。 - 验证:文档-only 更新,未运行单元测试。
## 【2026-07-06】T-531 完成 · ⑤设置未保存状态追踪 + 离开确认
- 状态:DONE
- 代码:⑤设置页新增 dirty 状态、保存按钮旁“● 未保存更改”提示、`is_dirty()`、`discard_unsaved_changes()`,`save_app_settings()` 改为返回成功/失败;保存成功清除 dirty,保存失败保留 dirty;放弃会重新读取 `config.json` 与 `config/cmhub.json` 并回填控件。
- 主窗口:`MainWindow` 在切 Tab 和关闭窗口时检查⑤设置页未保存状态,弹保存/放弃/取消;保存失败或取消会回到⑤设置页;`currentChanged` 回退使用 guard 防止递归。
- cmhub:刷新别名/测试连接继续使用输入框实时值,但不自动保存 URL/API Key;成功文案提醒“记得点『保存设置』持久化”。程序化回填和别名下拉刷新不触发 dirty。
- 边界:未改配置 schema、AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程。
- 测试:新增 GUI 单测覆盖 dirty 置位/清除、程序化刷新不置脏、放弃还原 URL/Key、切 Tab 取消、保存失败、放弃分支、关闭窗口取消。
- 验证:`python -m py_compile app\gui\tabs\settings.py app\gui\main_window.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p test_gui.py` 通过(86 tests)。
- 下一步:T-525「引入 ruff(lint + format)+ 可选 pre-commit」。
## 【2026-07-06】文档规则 · 用户可见文本统一中文
- 规则:所有用户可见文本必须使用中文,覆盖弹窗标题/正文/按钮、窗口标题、按钮、菜单、label、placeholder、tooltip、状态栏、空状态、确认框、运行日志、错误提示和成功提示等。
- 边界:URL、配置键、API 字段、模型别名、第三方/Shopee 原始错误可保留原文,但外围必须给中文解释;不得直接把英文技术报错裸露给用户。
- 文档:同步 `AGENTS.md`、`docs/05-coding-rules.md`、`docs/current-state.md`。
- 代码:同步修正 T-531 设置未保存离开确认框,改用自定义中文按钮“保存 / 放弃 / 取消”,避免系统默认按钮显示英文。
- 验证:`python -m py_compile app\gui\main_window.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p test_gui.py` 通过(86 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(195 tests);`git diff --check` 通过,仅有本机 LF/CRLF 提示。
+230
View File
@@ -84,6 +84,44 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertIsNotNone(value) self.assertIsNotNone(value)
self.assertEqual(color, value.name()) self.assertEqual(color, value.name())
def make_fake_message_box(self, selected_label):
boxes = []
class FakeMessageBox:
AcceptRole = object()
DestructiveRole = object()
RejectRole = object()
def __init__(self, parent=None):
self.parent = parent
self.title = ""
self.text = ""
self.buttons = {}
self.default_button = None
boxes.append(self)
def setWindowTitle(self, title):
self.title = title
def setText(self, text):
self.text = text
def addButton(self, label, role):
button = object()
self.buttons[label] = button
return button
def setDefaultButton(self, button):
self.default_button = button
def exec(self):
return 0
def clickedButton(self):
return self.buttons[selected_label]
return FakeMessageBox, boxes
def test_collect_table_shows_product_unavailable_only_for_explicit_error(self): def test_collect_table_shows_product_unavailable_only_for_explicit_error(self):
account = SimpleNamespace(alias="papa", account_name="papa 店铺") account = SimpleNamespace(alias="papa", account_name="papa 店铺")
invalid_task = SimpleNamespace( invalid_task = SimpleNamespace(
@@ -936,6 +974,198 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
def test_settings_tab_tracks_dirty_state_and_programmatic_cmhub_refresh_is_clean(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"])
tab = SettingsTab(config=cfg, config_path=cfg["config_path"], ai_models_path=cfg["ai_models_path"])
self.addCleanup(tab.close)
self.assertFalse(tab.is_dirty())
self.assertTrue(tab.unsaved_changes_label.isHidden())
tab._on_cmhub_finished(
{
"ok": True,
"models": [
{
"alias": "title-fresh",
"operation_type": "title",
"pricing_status": "priced",
"prices": [{"resolution": "1K", "points_cost": 1}],
},
{
"alias": "image-fresh",
"operation_type": "image",
"pricing_status": "priced",
"requires_image": True,
"prices": [{"resolution": "1K", "points_cost": 5}],
},
],
}
)
self.assertFalse(tab.is_dirty())
self.assertIn("记得点『保存设置』", tab.cmhub_result_label.text())
tab.cmhub_base_url_edit.setText("https://cmhub.example.com/api/v1/")
self.assertTrue(tab.is_dirty())
self.assertFalse(tab.unsaved_changes_label.isHidden())
with mock.patch("app.gui.QMessageBox.information") as info:
self.assertTrue(tab.save_app_settings())
info.assert_called_once_with(tab, "保存设置", "设置已保存")
self.assertFalse(tab.is_dirty())
self.assertTrue(tab.unsaved_changes_label.isHidden())
self.assert_removed(temp_dir)
def test_settings_tab_discard_unsaved_changes_reloads_saved_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.saved",
"title_alias": "title-saved",
"image_alias": "image-saved",
"connect_timeout": 11,
"check_balance_before_batch": False,
}
cfg["cmhub_config_path"] = os.path.join(temp_dir, "config", "cmhub.json")
appconfig.save_config(
{key: value for key, value in cfg.items() if key not in {"config_path", "ai_models_path", "cmhub_config_path"}},
path=cfg["config_path"],
)
appconfig.save_cmhub_config({"api_key": "sk-saved-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.cmhub_base_url_edit.setText("https://cmhub.changed")
tab.cmhub_api_key_edit.setText("sk-changed-secret")
self.assertTrue(tab.is_dirty())
self.assertTrue(tab.discard_unsaved_changes())
self.assertFalse(tab.is_dirty())
self.assertEqual("https://cmhub.saved", tab.cmhub_base_url_edit.text())
self.assertEqual("sk-saved-secret", tab.cmhub_api_key_edit.text())
self.assertTrue(tab.unsaved_changes_label.isHidden())
self.assert_removed(temp_dir)
def test_main_window_cancelled_tab_change_keeps_user_on_dirty_settings(self):
with self.make_temp_dir() as temp_dir:
window = MainWindow(config=self.make_config(temp_dir))
self.addCleanup(window.close)
settings_index = TAB_TITLES.index("⑤ 设置")
window.tabs.setCurrentIndex(settings_index)
settings_tab = window.tabs.widget(settings_index)
settings_tab.cmhub_base_url_edit.setText("https://cmhub.changed")
message_box, boxes = self.make_fake_message_box("取消")
with mock.patch("app.gui.main_window.QMessageBox", message_box):
window.tabs.setCurrentIndex(0)
self.assertEqual(1, len(boxes))
self.assertEqual("未保存更改", boxes[0].title)
self.assertEqual(["保存", "放弃", "取消"], list(boxes[0].buttons))
self.assertEqual(settings_index, window.tabs.currentIndex())
self.assertTrue(settings_tab.is_dirty())
settings_tab._set_dirty(False)
self.assert_removed(temp_dir)
def test_main_window_save_failure_keeps_user_on_dirty_settings(self):
with self.make_temp_dir() as temp_dir:
window = MainWindow(config=self.make_config(temp_dir))
self.addCleanup(window.close)
settings_index = TAB_TITLES.index("⑤ 设置")
window.tabs.setCurrentIndex(settings_index)
settings_tab = window.tabs.widget(settings_index)
settings_tab.cmhub_base_url_edit.setText("https://cmhub.changed")
message_box, _ = self.make_fake_message_box("保存")
with mock.patch("app.gui.main_window.QMessageBox", message_box), \
mock.patch.object(settings_tab, "save_app_settings", return_value=False) as save_settings:
window.tabs.setCurrentIndex(0)
save_settings.assert_called_once_with()
self.assertEqual(settings_index, window.tabs.currentIndex())
self.assertTrue(settings_tab.is_dirty())
settings_tab._set_dirty(False)
self.assert_removed(temp_dir)
def test_main_window_discard_tab_change_restores_saved_settings(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.saved",
"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_config(
{key: value for key, value in cfg.items() if key not in {"config_path", "ai_models_path", "cmhub_config_path"}},
path=cfg["config_path"],
)
appconfig.save_cmhub_config({"api_key": "sk-saved-secret"}, path=cfg["cmhub_config_path"])
window = MainWindow(config=cfg, config_path=cfg["config_path"], ai_models_path=cfg["ai_models_path"])
self.addCleanup(window.close)
settings_index = TAB_TITLES.index("⑤ 设置")
window.tabs.setCurrentIndex(settings_index)
settings_tab = window.tabs.widget(settings_index)
settings_tab.cmhub_base_url_edit.setText("https://cmhub.changed")
settings_tab.cmhub_api_key_edit.setText("sk-changed-secret")
message_box, _ = self.make_fake_message_box("放弃")
with mock.patch("app.gui.main_window.QMessageBox", message_box):
window.tabs.setCurrentIndex(0)
self.assertEqual(0, window.tabs.currentIndex())
self.assertFalse(settings_tab.is_dirty())
self.assertEqual("https://cmhub.saved", settings_tab.cmhub_base_url_edit.text())
self.assertEqual("sk-saved-secret", settings_tab.cmhub_api_key_edit.text())
self.assert_removed(temp_dir)
def test_main_window_close_cancel_ignores_dirty_settings_close(self):
with self.make_temp_dir() as temp_dir:
window = MainWindow(config=self.make_config(temp_dir))
self.addCleanup(window.close)
settings_index = TAB_TITLES.index("⑤ 设置")
window.tabs.setCurrentIndex(settings_index)
settings_tab = window.tabs.widget(settings_index)
settings_tab.cmhub_base_url_edit.setText("https://cmhub.changed")
event = SimpleNamespace(accepted=False, ignored=False)
event.accept = lambda: setattr(event, "accepted", True)
event.ignore = lambda: setattr(event, "ignored", True)
message_box, _ = self.make_fake_message_box("取消")
with mock.patch("app.gui.main_window.QMessageBox", message_box):
window.closeEvent(event)
self.assertFalse(event.accepted)
self.assertTrue(event.ignored)
settings_tab._set_dirty(False)
self.assert_removed(temp_dir)
def test_generate_tab_has_prompt_editors_and_task_table(self): def test_generate_tab_has_prompt_editors_and_task_table(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
title_prompt_path = os.path.join(temp_dir, "title_prompt.txt") title_prompt_path = os.path.join(temp_dir, "title_prompt.txt")