feat(settings): use radio buttons for gateway

This commit is contained in:
chengma
2026-07-21 09:07:04 +08:00
parent 4217f855ac
commit 415efe91a8
6 changed files with 396 additions and 12 deletions
+7 -9
View File
@@ -82,12 +82,10 @@ class SettingsTab(QWidget):
for label, value in self.BACKEND_ITEMS:
self.backend_combo.addItem(label, value)
self.backend_combo.setVisible(False)
self.gateway_default_button = QPushButton("默认网关")
self.gateway_default_button = QRadioButton("默认网关")
self.gateway_default_button.setObjectName("gatewayDefaultButton")
self.gateway_default_button.setCheckable(True)
self.gateway_custom_button = QPushButton("自定义网关")
self.gateway_custom_button = QRadioButton("自定义网关")
self.gateway_custom_button.setObjectName("gatewayCustomButton")
self.gateway_custom_button.setCheckable(True)
self.gateway_button_group = QButtonGroup(self)
self.gateway_button_group.setExclusive(True)
self.gateway_button_group.addButton(self.gateway_default_button)
@@ -100,11 +98,11 @@ class SettingsTab(QWidget):
gateway_selector_layout.addWidget(self.gateway_default_button)
gateway_selector_layout.addWidget(self.gateway_custom_button)
self.gateway_selector.setStyleSheet(
"QPushButton { min-width: 108px; padding: 6px 12px; border: 1px solid #b8c0ca; "
"background: #f6f8fa; color: #57606a; }"
"QPushButton:first-child { border-top-left-radius: 4px; border-bottom-left-radius: 4px; }"
"QPushButton:last-child { border-left: 0; border-top-right-radius: 4px; border-bottom-right-radius: 4px; }"
"QPushButton:checked { background: #0969da; border-color: #0969da; color: white; font-weight: 600; }"
"QRadioButton { min-width: 108px; min-height: 30px; padding: 5px 12px; "
"color: #24292f; spacing: 7px; }"
"QRadioButton:hover { color: #0969da; }"
"QRadioButton:focus { border: 1px solid #0969da; border-radius: 4px; }"
"QRadioButton::indicator { width: 15px; height: 15px; }"
)
self.cmhub_base_url_edit = QLineEdit()
self.cmhub_base_url_edit.setObjectName("cmhubBaseUrlEdit")
+1 -1
View File
@@ -171,7 +171,7 @@
## 设置
- 设置页整体布局:内容区居中,左右留白已从 T-506 初始实现缩短到约 40%;实现上用最大内容宽度 + 自适应 margin,而不是写死窗口像素。所有设置组默认响应式 3 列表单:短字段占 1 格,URL/API Key/路径等长字段跨 2 格或 3 格;窄窗口自动降为 2 列/1 列。点击「保存设置」成功后,状态栏显示“设置已保存”,并弹出轻量提示框。T-531 已完成:保存成功会清除未保存标记;保存失败时保留未保存标记并阻止离开。
- 生成网关(T-677):用具备选中态的「默认网关 / 自定义网关」来源选择器替代历史「AI 后端」下拉。切换只改变待保存状态并立即切换面板,点击「保存设置」才写入 `ai.backend=cmhub/direct`;默认网关的 `data/config/cmhub.json` 与自定义模型的 `data/config/ai_models.json` 始终并存,模型新增、保存、删除仍立即写盘,不受来源待保存语义影响。
- 生成网关(T-677/T-680):在原「AI 模型配置」标题下使用同行标准单选框「默认网关 / 自定义网关」替代历史「AI 后端」下拉和不明显的分段按钮;启动/放弃未保存更改时按已保存的 `ai.backend=cmhub/direct` 自动勾选对应项,圆点与焦点状态表达当前选择。切换只改变待保存状态并立即切换原有面板,点击「保存设置」才写入配置;默认网关的 `data/config/cmhub.json` 与自定义模型的 `data/config/ai_models.json` 始终并存,模型新增、保存、删除仍立即写盘,不受来源待保存语义影响。除这两个选择控件外,不改变设置页其它组件、表单布局或业务逻辑。
- 默认网关面板展示 Base URL、API Key、生文/生图/图片理解别名、连接超时、批量生成前检查余额、刷新别名和测试连接/查余额;Base URL 输入框旁提示“只填网关根,如 https://host”,保存/刷新前规整掉 `/api`、`/api/v1` 或其它路径。测试连接成功优先显示 `账号「<账号名>」连接默认网关成功`,邮箱脱敏;没有账号信息时显示「默认网关连接成功」。Key 只本地明文保存、UI 打码显示,不进入日志或导出。
- 自定义网关复用既有模型列表、模型详情、默认文本模型与默认图像模型。文本模型保留 `chat` / `auto`;图片模型固定使用「OpenAI 图片编辑接口」(`images_edits`),②和⑥通过 `/v1/images/edits` 以有序重复 `image[]` 上传,第一张是商品主体图、每次只请求 `n=1` 输出。历史 `chat` / `auto` 图片模型不会被改写或隐藏,但会标示「图片接口不支持」,生成前会被拦截。图片响应只接受 `data[].b64_json` / `data[].url`,不适配聊天响应或服务商私有结构。说明行明确「图片理解仅支持默认网关」。
- 图片模型按钮显示「检查图片配置」,只在本地检查接口类型、URL、模型 ID、密钥和超时,绝不对 `/images/edits` 发送无图片的测试请求;实际可用性必须以②或后续⑥的正常生成确认判断。允许保存不完整配置。②点击生成后、自定义 worker 创建前按本轮标题/封面选择校验所需模型的启用状态、类别、地址、模型 ID、API Key 和接口类型,失败提示去⑤补齐且不发请求、不改任务状态;自定义网关不显示默认网关余额,运行日志和状态栏说明“不计点数,费用由服务商收取”。
+5 -2
View File
@@ -3,7 +3,7 @@ id: T-680
title: 设置页生成网关改为同行单选框
phase: 5
deps: [T-677]
status: TODO
status: DONE
created: 2026-07-21
---
@@ -62,4 +62,7 @@ git diff --check
## 执行记录
(做完在这里写:控件替换范围、原型同步、GUI 验证和回归结果。)
- 将 `gateway_selector` 内原有的两个可勾选 `QPushButton` 替换为同一 `QButtonGroup` 管理的两个 `QRadioButton`,保留对象名、同行顺序、零间距布局、每项最小宽度以及原有选中、保存、放弃未保存更改和面板切换方法。
- 选中状态改由标准圆点、鼠标悬停色和键盘焦点边框表达;加载 `cmhub/direct`、鼠标点击和方向键切换均保持互斥。没有改动其它设置输入框、label、面板结构、表单网格或业务配置字段。
- 已将 `docs/ui/settings-gateway-radio-prototype-v1.html` 收缩为原位置的同行单选框方案,移除上下重排、侧栏、运行任务说明、深色预览和其它输入框改版;更新设置路由与 UI 索引。
- 测试:更新 `tests/test_gui.py`,覆盖控件类型、同行布局、已保存默认来源、方向键互斥切换、待保存状态、保存和放弃回填。验证:`py -3.10 -m unittest tests.test_gui`(211 项)、HTML 解析、内嵌 JavaScript 语法检查、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check` 均通过;完整 `py -3.10 -m unittest discover -s tests` 通过(651 项)。
+1
View File
@@ -19,6 +19,7 @@
| [tab6-suite-prompt-dialog-T637.svg](tab6-suite-prompt-dialog-T637.svg) | 商品套图(T-637 提案):标题行「AI帮写/取消」紧随 label、最右新增「提示词设置」;提示词设置弹窗——左编辑基础模板(行序对齐调研笔记组装顺序,套图名称+补充描述、插入变量菜单、占位符校验、只读规则占位符内联)、右只读实时最终预览(预览分类下拉)、保存/恢复默认共用原子写盘;第一版不做同类多张序号/差异化 |
| [tab6-suite-gateway-T679b.svg](tab6-suite-gateway-T679b.svg) | 商品套图(T-679b 已实现):自定义网关生成确认展示主图/参考图、近似比例尺寸、服务商费用与主体一致性边界;生成中锁定当前任务输入,停止等待已发出同步请求返回,同时仍可切换其它任务与查看历史/结果 |
| [tab6-suite-recovery-T679c.svg](tab6-suite-recovery-T679c.svg) | 商品套图(T-679c 已实现):结果卡和历史按已保存 job 显示默认网关/自定义网关;默认网关任务可继续查询,直连失败图只能手动重新生成并再次确认可能重复收费 |
| [settings-gateway-radio-prototype-v1.html](settings-gateway-radio-prototype-v1.html) | 设置(T-680 已实现):在原 AI 模型配置标题下保留同行位置,仅以原生单选框替换默认网关/自定义网关的分段按钮;可在浏览器中模拟切换、待保存状态和保存,不改其它设置组件 |
| [workbench-redesign-v1.html](workbench-redesign-v1.html) | 工作台视觉提案(HTML 可交互原型):以当前 6 Tab 信息架构为基础,统一「数据准备区→表格命令区→表格→状态反馈区→完成任务区」页面骨架;命令按作用域分区(导入/筛选/选择集/工作流完成),表格补齐当前行、选择集、三态全选与排序契约;范围确认、部分成功、取消中间态和破坏性确认按干扰层级分配到对话框、信息条与 Toast;覆盖浅色/深色/高对比度近似、键盘(Tab/方向键/F6/Ctrl+F/F5)与 Windows 断点。商品状态相关交互已对齐 T-662b/c/d、T-663~T-665、T-667 定稿(无状态列与状态下拉筛选,③无强制更新入口)。颜色 Token 取自 `app/gui/widgets.py`。使用模拟数据,不接生产接口;页脚「原型说明与验收边界」列出假设与不可仅靠 HTML 验收的项。 |
应用图标(非效果图,是随程序发布的资源):`app/assets/cmshopee-logo.svg` 是可编辑母版,`cmshopee.ico`(16–256 共 9 档)用于 exe 图标和 GUI 窗口左上角,`cmshopee-256.png` 为预览与非 Windows 回退。三者由 `py -3.10 scripts/gen_logo.py` 从同一份几何定义生成,改样式请改脚本后重新生成,不要单独手改某一个文件。
@@ -0,0 +1,364 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>设置 - 生成网关单选框原型</title>
<style>
:root {
--canvas: #f6f8fa;
--surface: #ffffff;
--surface-subtle: #f6f8fa;
--text: #24292f;
--muted: #57606a;
--border: #d0d7de;
--border-strong: #8c959f;
--accent: #0969da;
--accent-soft: #ddf4ff;
--warning: #9a6700;
--warning-soft: #fff8c5;
--radius: 4px;
}
* { box-sizing: border-box; }
body {
min-width: 640px;
min-height: 100vh;
margin: 0;
background: var(--canvas);
color: var(--text);
font: 14px/1.45 "Segoe UI", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif;
}
button, input, select { font: inherit; }
button:focus-visible, input:focus-visible, select:focus-visible {
outline: 3px solid rgba(9, 105, 218, .35);
outline-offset: 2px;
}
.window {
width: min(1180px, calc(100% - 40px));
min-height: 720px;
margin: 20px auto;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
box-shadow: 0 12px 30px rgba(31, 35, 40, .12);
overflow: hidden;
}
.titlebar {
display: flex;
align-items: center;
min-height: 44px;
padding: 0 16px;
background: #203451;
color: #ffffff;
font-weight: 650;
}
.tabs {
display: flex;
gap: 8px;
padding: 7px 14px 0;
border-bottom: 1px solid var(--border);
background: var(--surface-subtle);
}
.tab {
min-width: 104px;
min-height: 34px;
padding: 6px 12px;
border: 1px solid transparent;
border-bottom: 0;
border-radius: 5px 5px 0 0;
background: transparent;
color: var(--muted);
white-space: nowrap;
}
.tab[aria-selected="true"] {
border-color: var(--border);
background: var(--surface);
color: var(--accent);
font-weight: 650;
}
.settings-scroll { padding: 28px 18px 18px; }
.settings-panel { width: min(920px, 100%); margin: 0 auto; }
.section-title {
margin: 0 0 8px;
padding-top: 4px;
color: var(--text);
font-size: 16px;
font-weight: 650;
}
.gateway-selector {
display: flex;
align-items: center;
gap: 0;
min-height: 42px;
margin: 0 0 8px;
}
.gateway-selector fieldset {
display: flex;
align-items: center;
gap: 0;
margin: 0;
padding: 0;
border: 0;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
clip-path: inset(50%);
}
.gateway-radio {
display: inline-flex;
align-items: center;
min-width: 108px;
min-height: 30px;
padding: 5px 12px;
color: var(--text);
cursor: pointer;
}
.gateway-radio:hover { color: var(--accent); }
.gateway-radio input {
width: 16px;
height: 16px;
margin: 0 7px 0 0;
accent-color: var(--accent);
}
.gateway-radio:has(input:focus-visible) {
border: 1px solid var(--accent);
border-radius: var(--radius);
}
.section-gap { height: 14px; }
.form-grid {
display: grid;
grid-template-columns: 104px minmax(0, 1fr) 104px minmax(0, 1fr) 104px minmax(0, 1fr);
gap: 8px 18px;
align-items: center;
margin: 0;
}
.form-grid label { color: var(--text); }
.form-grid input, .form-grid select {
width: 100%;
min-height: 30px;
padding: 4px 8px;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
}
.span-wide { grid-column: span 3; }
.helper {
grid-column: 2 / -1;
margin: -2px 0 2px;
color: var(--muted);
font-size: 12px;
}
.panel-note {
margin: 8px 0 0 122px;
color: var(--muted);
font-size: 12px;
}
.hidden { display: none; }
.divider { height: 1px; margin: 18px 0; background: var(--border); }
.placeholder-section {
display: grid;
grid-template-columns: 104px minmax(0, 1fr) 104px minmax(0, 1fr) 104px minmax(0, 1fr);
gap: 8px 18px;
align-items: center;
color: var(--muted);
}
.placeholder-input {
min-height: 30px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface-subtle);
}
.save-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 24px;
}
.button {
min-height: 30px;
min-width: 86px;
padding: 4px 10px;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background: var(--surface-subtle);
color: var(--text);
font-weight: 600;
cursor: pointer;
}
.button:hover { background: #eef4ff; }
.button.primary { border-color: var(--accent); background: var(--accent); color: #ffffff; }
.unsaved {
color: var(--warning);
font-size: 13px;
font-weight: 600;
}
.status {
margin-left: auto;
color: var(--muted);
font-size: 12px;
}
@media (max-width: 840px) {
.window { width: 100%; min-height: 100vh; margin: 0; border: 0; border-radius: 0; }
.tabs { overflow-x: auto; }
.form-grid, .placeholder-section { grid-template-columns: 96px minmax(180px, 1fr); }
.form-grid label:nth-of-type(n+2), .placeholder-section span:nth-of-type(n+2) { grid-column: 1; }
.form-grid input, .form-grid select, .placeholder-input { grid-column: 2; }
.span-wide { grid-column: 2; }
.helper { grid-column: 2; }
.panel-note { margin-left: 104px; }
}
@media (forced-colors: active) {
.gateway-radio:has(input:checked) { color: HighlightText; background: Highlight; }
.gateway-radio:has(input:focus-visible) { outline: 2px solid Highlight; }
}
</style>
</head>
<body>
<div class="window">
<header class="titlebar">蝦皮圈優化助手</header>
<nav class="tabs" role="tablist" aria-label="主模块">
<button class="tab" type="button" role="tab" aria-selected="false">① 导入采集</button>
<button class="tab" type="button" role="tab" aria-selected="false">② AI生成</button>
<button class="tab" type="button" role="tab" aria-selected="false">③ 更新蝦皮</button>
<button class="tab" type="button" role="tab" aria-selected="false">账号管理</button>
<button class="tab" type="button" role="tab" aria-selected="true">设置</button>
<button class="tab" type="button" role="tab" aria-selected="false">商品套图</button>
</nav>
<main class="settings-scroll">
<div class="settings-panel">
<h1 class="section-title">AI 模型配置</h1>
<div class="gateway-selector">
<fieldset>
<legend class="visually-hidden">生成网关</legend>
<label class="gateway-radio">
<input type="radio" name="gateway" value="cmhub" checked>
默认网关
</label>
<label class="gateway-radio">
<input type="radio" name="gateway" value="direct">
自定义网关
</label>
</fieldset>
</div>
<section id="cmhubPanel" aria-label="默认网关配置">
<div class="form-grid">
<label for="baseUrl">网关 Base URL</label>
<input class="span-wide" id="baseUrl" value="https://cmhub.example.com">
<p class="helper">只填默认网关根地址,不要带 /api 或 /api/v1。</p>
<label for="apiKey">API Key</label>
<input id="apiKey" type="password" value="cmhub-local-key">
<label for="titleAlias">标题生成别名</label>
<select id="titleAlias"><option>title-standard</option></select>
<label for="imageAlias">图片生成别名</label>
<select id="imageAlias"><option>image-hd</option></select>
<label for="visionAlias">图片理解别名</label>
<select id="visionAlias"><option>vision-standard</option></select>
<label for="connectTimeout">连接超时</label>
<input id="connectTimeout" value="66">
<span>批量生成前检查余额</span>
<label><input type="checkbox"> 启用</label>
</div>
<p class="panel-note">默认网关的输入框、顺序和保存规则保持现有实现不变。</p>
</section>
<section id="directPanel" class="hidden" aria-label="自定义网关模型配置">
<div class="form-grid">
<label for="modelList">模型列表</label>
<select class="span-wide" id="modelList"><option>图片编辑模型 · 图像</option></select>
<p class="helper">选择、保存或删除模型的原有交互保持不变。</p>
<label for="modelName">模型名称</label>
<input id="modelName" value="图片编辑模型">
<label for="modelId">模型 ID</label>
<input id="modelId" value="image-edit">
<label for="modelUrl">网址</label>
<input id="modelUrl" value="https://example.com/v1">
</div>
<p class="panel-note">此处仅按现有面板显示,不新增字段或状态组件。</p>
</section>
<div class="divider"></div>
<h2 class="section-title">AI 生成参数</h2>
<div class="placeholder-section" aria-label="未修改的生成参数示意">
<span>标题并发</span><div class="placeholder-input"></div>
<span>图片并发</span><div class="placeholder-input"></div>
<span>失败重试</span><div class="placeholder-input"></div>
</div>
<div class="divider"></div>
<h2 class="section-title">路径与端口</h2>
<div class="placeholder-section" aria-label="未修改的路径与端口示意">
<span>Chrome 路径</span><div class="placeholder-input"></div>
<span>默认端口</span><div class="placeholder-input"></div>
<span>端口范围</span><div class="placeholder-input"></div>
</div>
<div class="save-row">
<button class="button primary" id="saveButton" type="button">保存设置</button>
<span class="unsaved hidden" id="unsavedLabel">● 未保存更改</span>
<span class="status" id="statusText" role="status" aria-live="polite">当前设置已保存</span>
</div>
</div>
</main>
</div>
<script>
(() => {
const radios = [...document.querySelectorAll('input[name="gateway"]')];
const cmhubPanel = document.getElementById("cmhubPanel");
const directPanel = document.getElementById("directPanel");
const unsavedLabel = document.getElementById("unsavedLabel");
const statusText = document.getElementById("statusText");
const saveButton = document.getElementById("saveButton");
const names = { cmhub: "默认网关", direct: "自定义网关" };
let saved = "cmhub";
let selected = "cmhub";
function render() {
const dirty = selected !== saved;
radios.forEach((radio) => { radio.checked = radio.value === selected; });
cmhubPanel.classList.toggle("hidden", selected !== "cmhub");
directPanel.classList.toggle("hidden", selected !== "direct");
unsavedLabel.classList.toggle("hidden", !dirty);
statusText.textContent = dirty
? `待保存:${names[selected]}`
: `当前使用${names[saved]}`;
}
radios.forEach((radio) => {
radio.addEventListener("change", () => {
if (radio.checked) {
selected = radio.value;
render();
}
});
});
saveButton.addEventListener("click", () => {
saved = selected;
render();
statusText.textContent = `设置已保存,当前使用${names[saved]}`;
});
render();
})();
</script>
</body>
</html>
+18
View File
@@ -42,6 +42,7 @@ from PySide6.QtWidgets import (
QPlainTextEdit,
QProgressBar,
QPushButton,
QRadioButton,
QTableView,
)
@@ -3055,11 +3056,22 @@ class GuiTests(TempDirMixin, unittest.TestCase):
)
self.addCleanup(tab.close)
self.assertIsInstance(tab.gateway_default_button, QRadioButton)
self.assertIsInstance(tab.gateway_custom_button, QRadioButton)
self.assertEqual(0, tab.gateway_selector.layout().spacing())
self.assertEqual(2, tab.gateway_selector.layout().count())
self.assertTrue(tab.gateway_default_button.isChecked())
self.assertFalse(tab.gateway_custom_button.isChecked())
self.assertFalse(tab.cmhub_panel.isHidden())
self.assertTrue(tab.model_picker_panel.isHidden())
tab.show()
self.app.processEvents()
tab.gateway_default_button.setFocus()
QTest.keyClick(tab.gateway_default_button, Qt.Key_Right)
self.assertTrue(tab.gateway_custom_button.isChecked())
self.assertFalse(tab.gateway_default_button.isChecked())
tab.gateway_custom_button.setChecked(True)
self.assertTrue(tab.gateway_custom_button.isChecked())
self.assertFalse(tab.gateway_default_button.isChecked())
@@ -3074,6 +3086,12 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.assertEqual("direct", appconfig.load_config(cfg["config_path"])["ai"]["backend"])
tab.gateway_default_button.setChecked(True)
self.assertTrue(tab.is_dirty())
self.assertTrue(tab.discard_unsaved_changes())
self.assertFalse(tab.gateway_default_button.isChecked())
self.assertTrue(tab.gateway_custom_button.isChecked())
self.assert_removed(temp_dir)
def test_settings_tab_cmhub_alias_refresh_filters_unpriced_and_keeps_saved(self):