feat: track settings changes and enforce Chinese UI copy
This commit is contained in:
@@ -53,4 +53,5 @@ T-006 前若 `tests/` 不存在,不运行 `unittest discover`,只在回复
|
||||
## 风格
|
||||
|
||||
- 文档与 UI 文案用中文,标识符用英文。
|
||||
- 所有用户可见文本必须使用中文:包括弹窗标题/正文/按钮、窗口标题、按钮、菜单、label、placeholder、tooltip、状态栏、空状态、确认框、运行日志、错误提示和成功提示等。URL、配置键、API 字段、模型别名、第三方/Shopee 原始错误可保留原文,但外围必须给中文解释;不得直接把英文技术报错裸露给用户。
|
||||
- 内容面向 agent 执行:能落到“读什么、改什么、验证什么”。
|
||||
|
||||
@@ -27,6 +27,9 @@ class MainWindow(QMainWindow):
|
||||
)
|
||||
self.setWindowTitle("cmshopee")
|
||||
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.setObjectName("mainTabs")
|
||||
self.tabs.setStyleSheet(TAB_STYLE)
|
||||
@@ -82,7 +85,51 @@ class MainWindow(QMainWindow):
|
||||
widget.refresh_tasks()
|
||||
|
||||
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)}")
|
||||
|
||||
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):
|
||||
self.tabs.setCurrentIndex(TAB_TITLES.index("④ 账号管理"))
|
||||
|
||||
+116
-4
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
from ..widgets import *
|
||||
from ..workers import AIModelTestWorker as _RealAIModelTestWorker
|
||||
from ..workers import CMHubSettingsWorker as _RealCMHubSettingsWorker
|
||||
@@ -63,6 +65,8 @@ class SettingsTab(QWidget):
|
||||
self._loaded_cmhub_api_key = ""
|
||||
self._cmhub_auto_refresh_done = False
|
||||
self._compat_test_item_id = ""
|
||||
self._dirty = False
|
||||
self._suspend_dirty = 0
|
||||
|
||||
self.backend_combo = QComboBox()
|
||||
self.backend_combo.setObjectName("aiBackendCombo")
|
||||
@@ -173,6 +177,10 @@ class SettingsTab(QWidget):
|
||||
self.cdp_ready_timeout_spin.setObjectName("cdpReadyTimeoutSpin")
|
||||
self.cdp_ready_timeout_spin.setRange(1, 3600)
|
||||
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.setObjectName("allowRealSubmitCheckbox")
|
||||
self.allow_cover_update_checkbox = QCheckBox("允许更新封面")
|
||||
@@ -351,7 +359,12 @@ class SettingsTab(QWidget):
|
||||
panel_layout.addSpacing(18)
|
||||
panel_layout.addWidget(self.infrastructure_section_title)
|
||||
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)
|
||||
|
||||
scroll = QScrollArea()
|
||||
@@ -380,9 +393,12 @@ class SettingsTab(QWidget):
|
||||
self._update_response_timeout_label
|
||||
)
|
||||
self.save_config_button.clicked.connect(self.save_app_settings)
|
||||
self._connect_dirty_signals()
|
||||
|
||||
with self._dirty_tracking_suspended():
|
||||
self.refresh_models()
|
||||
self._populate_app_settings()
|
||||
self._set_dirty(False)
|
||||
|
||||
def _three_column_form(self, fields):
|
||||
layout = QGridLayout()
|
||||
@@ -436,6 +452,92 @@ class SettingsTab(QWidget):
|
||||
if self.status_callback is not None:
|
||||
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):
|
||||
if self._cmhub_auto_refresh_done:
|
||||
return
|
||||
@@ -594,7 +696,7 @@ class SettingsTab(QWidget):
|
||||
def save_app_settings(self, checked=False):
|
||||
settings = self._app_settings_values()
|
||||
if settings is None:
|
||||
return
|
||||
return False
|
||||
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()
|
||||
@@ -606,11 +708,13 @@ class SettingsTab(QWidget):
|
||||
saved = appconfig.save_config(settings, path=self.config_path)
|
||||
except Exception as exc:
|
||||
self._show_error(exc)
|
||||
return
|
||||
return False
|
||||
self._replace_config(saved)
|
||||
self._populate_app_settings()
|
||||
self._set_dirty(False)
|
||||
self._set_status("设置已保存")
|
||||
QMessageBox.information(self, "保存设置", "设置已保存")
|
||||
return True
|
||||
|
||||
def _app_settings_values(self):
|
||||
start_port = self.debug_port_start_spin.value()
|
||||
@@ -700,6 +804,10 @@ class SettingsTab(QWidget):
|
||||
self.config.update(internal)
|
||||
|
||||
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()
|
||||
ai_cfg = appconfig.ai_config(self.config)
|
||||
self._set_combo_by_data(self.backend_combo, "cmhub")
|
||||
@@ -1011,7 +1119,7 @@ class SettingsTab(QWidget):
|
||||
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}"
|
||||
message = f"cmhub 连接成功:生文别名 {title_count} 个,生图别名 {image_count} 个{balance_text};别名已拉取,记得点『保存设置』持久化"
|
||||
self.cmhub_result_label.setText(message)
|
||||
self._set_status(message)
|
||||
|
||||
@@ -1021,6 +1129,10 @@ class SettingsTab(QWidget):
|
||||
self._set_status(message)
|
||||
|
||||
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.cmhub_title_alias_combo,
|
||||
models,
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
## 5. 代码规范
|
||||
|
||||
- 标识符使用英文;UI 文案、注释、文档保持中文,与现有脚本一致。
|
||||
- 所有用户可见文本必须使用中文:包括弹窗标题/正文/按钮、窗口标题、按钮、菜单、label、placeholder、tooltip、状态栏、空状态、确认框、运行日志、错误提示和成功提示等。不要直接使用 `Save` / `Discard` / `Cancel` 等英文标准按钮露出给用户;如需三选一弹窗,使用自定义中文按钮文案(如“保存”“放弃”“取消”)。URL、配置键、API 字段、模型别名、第三方/Shopee 原始错误可保留原文,但外围必须有中文解释。
|
||||
- 错误必须处理:CDP 超时、tab 找不到、上传失败、按钮禁用都要给明确提示,不吞错。
|
||||
- 注释解释“为什么”(尤其 CDP 的坑:代理、Origin、就绪判断、拖拽落点),不复述“做了什么”。
|
||||
- 复用现有脚本里已验证的 JS 字符串,不重写出不一致的版本。
|
||||
|
||||
+1
-1
@@ -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-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-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`)
|
||||
|
||||
|
||||
+2
-2
@@ -381,9 +381,9 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
|
||||
|
||||
⑤ 设置当前要点(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` 并回填控件。
|
||||
- `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 列。
|
||||
- 模型数据读写复用 `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`,不得进入日志/导出。
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
-2
@@ -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,成功文案需提醒“记得点保存设置持久化”。
|
||||
- API Key 存在 `config/cmhub.json`,本地明文保存;保存/变更时提示;UI 使用密码框打码显示,不进入日志/导出。
|
||||
- 生文/生图别名来自 `GET /api/v1/models` 动态下拉,过滤未定价别名并展示单价/需原图提示;网关临时不可达时保留已存别名。
|
||||
- 保存设置固定写 `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 暴露。
|
||||
- AI 生成参数:标题并发、图片并发、失败重试、分辨率、返回超时、jpg 质量等短字段按三列排列。
|
||||
- 分辨率为 `512 / 1k / 2k / 4k`;返回超时只读展示 `resolution_timeouts[resolution]`,不单独编辑。
|
||||
|
||||
+19
@@ -1157,3 +1157,22 @@
|
||||
- 修正:认可“显式保存 + 脏状态追踪 + 离开确认”方案,不做自动保存;补充 `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 顺延。
|
||||
- 验证:文档-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 提示。
|
||||
@@ -84,6 +84,44 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertIsNotNone(value)
|
||||
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):
|
||||
account = SimpleNamespace(alias="papa", account_name="papa 店铺")
|
||||
invalid_task = SimpleNamespace(
|
||||
@@ -936,6 +974,198 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
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):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
title_prompt_path = os.path.join(temp_dir, "title_prompt.txt")
|
||||
|
||||
Reference in New Issue
Block a user