docs(ui): apply T-677 gateway source switch to workbench prototype

⑤ 设置的网关分区按 T-677 改为生成来源二选一:

- 分区标题「cmhub 网关」改为「生成网关」并保留,新增互斥选中态的
  「默认网关 / 自定义网关」分段选择器(radiogroup,表达状态而非动作)。
- 双面板显隐:默认网关面板含网关地址、API Key、生文/生图/图片理解别名、
  连接超时、测试连接·查余额、批量生成前检查余额;自定义网关面板含模型
  列表、模型详情(名称/类别/接口地址/模型ID/API Key/接口类型/超时/启用)、
  独立的测试按钮与结果标签、默认文本与默认图像模型选择。
- 保存语义区分:来源切换只标记未保存,点「保存设置」才生效;模型的新增、
  保存、删除立即写入配置,「放弃更改」不回滚这些操作,两者各有中文反馈。
- 接口类型按现有 direct 实现说明三种行为,明确「自动」不探测对方服务。
- 文案收敛:⑤面板、标题栏网关状态与②余额展示不再出现 cmhub 字样;
  自定义网关下②显示「不计点数,费用由服务商收取」而非 0 点或旧余额,
  稳定取值 cmhub/direct 仅保留在 value 与 data-* 中。
- ⑥联动:自定义网关下停用「生成套图」与「AI 帮写」,同时保留
  「继续查询已提交图片」入口取回已扣点的默认网关任务,不新建、不重复计费。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
chengma
2026-07-20 15:50:59 +08:00
co-authored by Claude Fable 5
parent e65a1595fe
commit ed1d8ee764
+278 -17
View File
@@ -517,6 +517,42 @@
.form-section { padding: var(--space-4); border-bottom: 1px solid var(--stroke-divider); }
.form-section:last-child { border-bottom: 0; }
.form-section h2 { margin: 0 0 var(--space-3); font-size: 13px; font-weight: 600; }
.section-head { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3); margin-bottom: var(--space-3); }
.section-head h2 { margin: 0; }
.section-head .push { margin-left: auto; }
/* ---- 分段选择器:表达“当前来源”这一状态,不是动作按钮 ----
Qt 端对应 QButtonGroup(setExclusive) + setCheckable 的两个按钮,
或两个 QRadioButton;不得退化为普通 QPushButton。 */
.segmented { display: inline-flex; border: 1px solid var(--stroke-control); border-radius: var(--radius-control); overflow: hidden; }
.segmented label {
display: flex; align-items: center;
min-height: 32px; padding: 0 var(--space-3);
border-right: 1px solid var(--stroke-control);
color: var(--text-primary); font-size: 13px; cursor: pointer;
transition: background var(--motion-fast) var(--ease);
}
.segmented label:last-of-type { border-right: 0; }
.segmented label:hover { background: var(--surface-hover); }
.segmented label.is-checked { background: var(--accent-default); color: #fff; font-weight: 600; }
.segmented label.is-focused { outline: 2px solid var(--focus-stroke); outline-offset: -2px; }
.segmented input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.gateway-panel[hidden] { display: none; }
.gateway-note { margin: 0 0 var(--space-3); }
.model-layout { display: grid; grid-template-columns: minmax(170px, .55fr) minmax(0, 1.45fr); gap: var(--space-3); align-items: start; }
.model-list { max-height: 232px; overflow: auto; padding: var(--space-1); border: 1px solid var(--stroke-control); border-radius: var(--radius-control); background: var(--surface-layer); }
.model-item {
display: grid; grid-template-columns: 1fr auto; align-items: center; gap: var(--space-2);
width: 100%; min-height: 40px; padding: var(--space-1) var(--space-2);
border: 0; border-radius: var(--radius-control); background: transparent; text-align: left;
}
.model-item[aria-current="true"] { background: var(--accent-subtle); box-shadow: inset 3px 0 0 var(--accent-default); }
.model-item .name { display: block; font-size: 13px; font-weight: 600; }
.model-item .meta { display: block; overflow: hidden; color: var(--text-secondary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.test-result { display: inline-flex; align-items: center; font-size: 12px; color: var(--text-secondary); }
@media (max-width: 1000px) { .model-layout { grid-template-columns: minmax(0, 1fr); } .model-list { max-height: 160px; } }
.form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); }
.form-grid .span-2 { grid-column: span 2; }
.form-grid .span-3 { grid-column: 1 / -1; }
@@ -654,7 +690,7 @@
<span class="brand-mark" aria-hidden="true">虾</span>
<strong class="app-name">蝦皮圈優化助手</strong>
<span class="push"></span>
<span class="gateway-state">cmhub 已连接</span>
<span class="gateway-state" id="titlebar-gateway">默认网关 已连接</span>
<button class="btn-quiet" type="button" id="theme-toggle" aria-pressed="false">深色主题</button>
<div class="window-actions" aria-label="窗口操作(原型示意,非原生标题栏)">
<button type="button" aria-label="最小化">&#8722;</button>
@@ -784,8 +820,10 @@
<section class="view active" id="view-generate" role="tabpanel" aria-labelledby="tab-generate" tabindex="-1">
<header class="page-header">
<h1 class="page-title">AI生成</h1>
<span class="page-scope">调用 cmhub 生成新标题与新封面,会消耗点数</span>
<span class="page-scope" id="generate-scope-text">通过默认网关生成新标题与新封面,会消耗点数</span>
<span class="push"></span>
<!-- 余额文案随⑤生成网关来源变化(T-677 第 5 节:不再出现 cmhub 字样) -->
<span class="status-chip chip-success" id="generate-balance">默认网关余额:1,280 点</span>
<span class="status-chip chip-info">批次 2026-07-20 14:32</span>
<button type="button" data-toast="已打开当前批次的图片文件夹">打开图片文件夹</button>
</header>
@@ -1093,6 +1131,17 @@
</div>
</div>
<!-- 自定义网关下的入口拦截与已提交任务取回(T-677 第 4 节) -->
<div class="infobar caution" id="suite-gateway-bar">
<div class="body">
<strong>当前生成来源为自定义网关,商品套图已停用</strong>
<span class="detail">商品套图与「AI 帮写」仅支持默认网关。此前已提交到默认网关的任务仍可继续查询并取回,不会重复扣点。需要重新生成请到⑤把生成来源切回默认网关。</span>
</div>
<div class="actions">
<button type="button" data-toast="示意:正在查询已提交到默认网关的套图任务,不会新建任务,也不会再次扣点">继续查询已提交图片</button>
</div>
</div>
<div class="zone-complete">
<div class="inline-field">
<label for="suite-structure">套图结构</label>
@@ -1105,8 +1154,8 @@
<span class="scope-note">3 类共 3 张 · 预计消耗 6 点</span>
<span class="push"></span>
<div class="actions">
<button type="button" data-toast="示意:AI 帮写将理解已选的 3 张原图">AI 帮写</button>
<button class="btn-primary" type="button" data-open="suite-confirm-dialog">生成套图</button>
<button type="button" data-toast="示意:AI 帮写将理解已选的 3 张原图" data-suite-gated>AI 帮写</button>
<button class="btn-primary" type="button" data-open="suite-confirm-dialog" data-suite-gated>生成套图</button>
</div>
</div>
</section>
@@ -1179,25 +1228,99 @@
</header>
<div class="detail-pane" style="margin: var(--space-3) var(--space-4) var(--space-4); border-radius: var(--radius-control);" data-settings-form>
<!-- 生成网关:来源二选一(T-677)。分区标题保留,承担长页面导航作用。 -->
<div class="form-section">
<h2>cmhub 网关</h2>
<div class="form-grid">
<div class="field span-2"><label for="set-gateway">网关地址</label><input type="text" id="set-gateway" value="https://cm.833729.com"><span class="field-hint">只填写网关根地址,保存时自动规整路径。</span></div>
<div class="field"><label for="set-key">API Key</label><input type="password" id="set-key" value="cmhub_api_key_placeholder"><span class="field-hint">保存在本地配置,不会写入日志。</span></div>
<div class="field"><label for="set-text-model">生文别名</label><select id="set-text-model"><option>text-standard</option></select></div>
<div class="field"><label for="set-image-model">生图别名</label><select id="set-image-model"><option>image-hd</option></select></div>
<div class="field"><label for="set-vision-model">图片理解别名</label><select id="set-vision-model"><option>vision-standard</option></select></div>
<div class="section-head">
<h2>生成网关</h2>
<div class="segmented" role="radiogroup" aria-label="生成网关来源" id="gateway-source">
<label data-source="cmhub"><input type="radio" name="gateway-source" value="cmhub" checked>默认网关</label>
<label data-source="direct"><input type="radio" name="gateway-source" value="direct">自定义网关</label>
</div>
<span class="push"></span>
<span class="field-hint">切换来源只标记未保存,点击「保存设置」后才生效。</span>
</div>
<!-- 默认网关面板 -->
<div class="gateway-panel" data-gateway-panel="cmhub">
<div class="form-grid">
<div class="field span-2"><label for="set-gateway">网关地址</label><input type="text" id="set-gateway" value="https://cm.833729.com"><span class="field-hint">只填写网关根地址,保存时自动规整路径。</span></div>
<div class="field"><label for="set-key">API Key</label><input type="password" id="set-key" value="default_gateway_key_placeholder"><span class="field-hint">以明文保存在本地配置文件中,请勿在共用电脑上使用。不会写入日志。</span></div>
<div class="field"><label for="set-text-alias">生文别名</label><select id="set-text-alias"><option>text-standard</option><option>text-pro</option></select></div>
<div class="field"><label for="set-image-alias">生图别名</label><select id="set-image-alias"><option>image-hd</option><option>image-fast</option></select></div>
<div class="field"><label for="set-vision-alias">图片理解别名</label><select id="set-vision-alias"><option>vision-standard</option></select></div>
<div class="field"><label for="set-connect">连接超时秒数</label><input type="text" id="set-connect" value="66"></div>
</div>
<div class="button-row" style="margin-top:var(--space-3)">
<button type="button" id="test-default-gateway">测试连接·查余额</button>
<span class="test-result" id="default-gateway-result" aria-live="polite">尚未测试</span>
</div>
<div class="button-row" style="margin-top:var(--space-3)">
<label style="display:flex;align-items:center;gap:var(--space-2);color:var(--text-primary);font-size:13px">
<input type="checkbox" id="set-check-balance" checked>批量生成前检查余额
</label>
</div>
</div>
<!-- 自定义网关面板 -->
<div class="gateway-panel" data-gateway-panel="direct" hidden>
<div class="infobar info show gateway-note">
<div class="body">
<strong>图片理解与商品套图仅默认网关支持</strong>
<span class="detail">切换到自定义网关后,②可以正常生成标题和封面;⑥商品套图的生成、重新生成和「AI 帮写」会被停用。此前已提交到默认网关的套图任务仍可在⑥继续查询并取回,不会重复计费。</span>
</div>
</div>
<div class="model-layout">
<div>
<label style="display:block;margin-bottom:var(--space-1)">自定义模型</label>
<div class="model-list" role="listbox" aria-label="自定义模型列表" id="model-list">
<button class="model-item" type="button" role="option" aria-current="true" aria-selected="true"><span><span class="name">本地 Qwen 文本</span><span class="meta">文本 · qwen2.5-72b-instruct</span></span><span class="status-chip chip-success">已启用</span></button>
<button class="model-item" type="button" role="option" aria-current="false" aria-selected="false"><span><span class="name">自建 SD 图像</span><span class="meta">图像 · sd3.5-large</span></span><span class="status-chip chip-success">已启用</span></button>
<button class="model-item" type="button" role="option" aria-current="false" aria-selected="false"><span><span class="name">备用文本网关</span><span class="meta">文本 · gpt-4o-mini</span></span><span class="status-chip chip-muted">已停用</span></button>
</div>
<div class="button-row" style="margin-top:var(--space-2)">
<button class="btn-quiet" type="button" data-model-crud="新增模型">新增</button>
<button class="btn-quiet" type="button" data-model-crud="保存模型">保存模型</button>
<button class="btn-quiet btn-danger" type="button" data-open="delete-model-dialog">删除</button>
</div>
<span class="field-hint">模型的新增、保存和删除会立即写入配置,不需要点「保存设置」。</span>
</div>
<div>
<div class="form-grid">
<div class="field"><label for="model-name">模型名称</label><input type="text" id="model-name" value="本地 Qwen 文本"></div>
<div class="field"><label for="model-category">类别</label><select id="model-category"><option>文本</option><option>图像</option></select></div>
<div class="field"><label for="model-enabled">启用状态</label><select id="model-enabled"><option>已启用</option><option>已停用</option></select></div>
<div class="field span-2"><label for="model-endpoint">接口地址</label><input type="text" id="model-endpoint" value="https://api.example-llm.local/v1"><span class="field-hint">填写 OpenAI 兼容服务的根地址,保存时自动补全路径。</span></div>
<div class="field"><label for="model-id">模型 ID</label><input type="text" id="model-id" value="qwen2.5-72b-instruct"></div>
<div class="field"><label for="model-key">API Key</label><input type="password" id="model-key" value="custom_gateway_key_placeholder"><span class="field-hint">以明文保存在本地配置文件中,不会写入日志。</span></div>
<div class="field"><label for="model-api-type">接口类型</label><select id="model-api-type"><option>对话接口(chat)</option><option>图片编辑接口(images_edits)</option><option>自动(auto)</option></select></div>
<div class="field"><label for="model-timeout">超时秒数</label><input type="text" id="model-timeout" value="120"></div>
<div class="field span-3"><span class="field-hint">接口类型说明:「对话接口」和「自动」都按对话请求发送,只有「图片编辑接口」按图片编辑请求发送。「自动」不会探测对方服务支持哪些接口,请按你的服务实际情况选择。</span></div>
</div>
<div class="button-row" style="margin-top:var(--space-3)">
<button type="button" id="test-custom-model">测试该模型</button>
<span class="test-result" id="custom-model-result" aria-live="polite">尚未测试</span>
</div>
</div>
</div>
<div class="form-grid" style="margin-top:var(--space-4)">
<div class="field"><label for="set-default-text">默认文本模型</label><select id="set-default-text"><option>本地 Qwen 文本</option><option>备用文本网关</option></select></div>
<div class="field"><label for="set-default-image">默认图像模型</label><select id="set-default-image"><option>自建 SD 图像</option></select></div>
<div class="field"><span class="field-hint">配置不完整也可以保存;②开始生成时会按本轮生成内容提示需要补齐哪一项。</span></div>
</div>
</div>
</div>
<div class="form-section">
<h2>生成参数</h2>
<h2>角色与生成参数</h2>
<div class="form-grid">
<div class="field"><label for="set-title-conc">标题并发</label><input type="text" id="set-title-conc" value="4"></div>
<div class="field"><label for="set-image-conc">图片并发</label><input type="text" id="set-image-conc" value="5"></div>
<div class="field"><label for="set-retry">失败重试次数</label><input type="text" id="set-retry" value="2"></div>
<div class="field"><label for="set-image-wait">生图等待秒数</label><input type="text" id="set-image-wait" value="900"></div>
<div class="field"><label for="set-download">下载超时秒数</label><input type="text" id="set-download" value="650"></div>
<div class="field"><label for="set-connect">连接超时秒数</label><input type="text" id="set-connect" value="66"></div>
</div>
</div>
<div class="form-section">
@@ -1354,6 +1477,18 @@
</div>
</dialog>
<!-- 破坏性:删除自定义模型(立即写入,不等待「保存设置」) -->
<dialog id="delete-model-dialog" aria-labelledby="delete-model-title">
<div class="dialog-body">
<h2 class="dialog-title" id="delete-model-title">删除模型“本地 Qwen 文本”?</h2>
<p class="dialog-copy">将立即从自定义模型配置中删除,不需要点「保存设置」,也无法通过「放弃更改」恢复。如果它正被设为默认文本模型,②生成标题时会提示去⑤补齐。</p>
</div>
<div class="dialog-actions">
<button type="button" data-close data-default-focus>取消</button>
<button class="btn-danger" type="button" data-confirm="已删除模型「本地 Qwen 文本」(已立即写入配置)">删除模型</button>
</div>
</dialog>
<!-- 破坏性:删除套图项目 -->
<dialog id="delete-project-dialog" aria-labelledby="delete-project-title">
<div class="dialog-body">
@@ -1404,6 +1539,8 @@
<li>按钮文案为<strong>「采集架上商品 / 采集全部商品」</strong>与<strong>「生成架上商品 / 生成全部商品」</strong>(T-664、T-665)。</li>
<li><strong>③不提供任何强制更新异常商品的入口</strong>,商品状态异常一律后台拦截(T-662d)。</li>
<li>历史批次已由数据库迁移默认为架上商品,<strong>不出现「待检测」状态和状态重检入口</strong>(T-667)。</li>
<li>⑤「生成网关」分区提供<strong>互斥选中态的「默认网关 / 自定义网关」</strong>来源选择器与双面板;切换只标记未保存,点「保存设置」才生效;模型的新增/保存/删除立即写入,「放弃更改」不回滚这些操作。⑤与②的可见文案不再出现 <span class="nowrap-id">cmhub</span> 字样(T-677)。</li>
<li>切到自定义网关后,②照常生成,⑥商品套图的生成与「AI 帮写」停用,但<strong>保留「继续查询已提交图片」入口</strong>取回已扣点任务(T-677 第 4 节)。</li>
</ul>
<h3 style="margin:var(--space-4) 0 var(--space-1);font-size:13px">仍需确认的假设</h3>
@@ -1943,14 +2080,138 @@
settingsForm.addEventListener('input', function () { setDirty(true); });
settingsForm.addEventListener('change', function () { setDirty(true); });
settingsSave.addEventListener('click', function () { setDirty(false); showToast('设置已保存'); });
settingsDiscard.addEventListener('click', function () { setDirty(false); showToast('已放弃未保存的更改'); });
/* ---------- ⑤ 生成网关来源(T-677) ----------
savedSource 是已落盘的来源;pendingSource 只在点「保存设置」后才生效。
②的余额文案跟随 savedSource,不跟随未保存的选择。 */
var savedSource = 'cmhub';
var pendingSource = 'cmhub';
var sourceRadios = Array.prototype.slice.call(document.querySelectorAll('input[name="gateway-source"]'));
var generateBalance = document.getElementById('generate-balance');
var generateScopeText = document.getElementById('generate-scope-text');
function renderSourcePanels(source) {
document.querySelectorAll('[data-gateway-panel]').forEach(function (panel) {
panel.hidden = panel.dataset.gatewayPanel !== source;
});
document.querySelectorAll('#gateway-source label').forEach(function (label) {
label.classList.toggle('is-checked', label.dataset.source === source);
});
}
/* 已保存来源决定②的点数口径与⑥的可用性。
保存后立即刷新,不需要重启程序,也不等待用户重新进入⑥。 */
function renderSavedSource() {
var custom = savedSource === 'direct';
if (custom) {
generateBalance.className = 'status-chip chip-caution';
generateBalance.textContent = '自定义网关(不计点数,费用由服务商收取)';
generateScopeText.textContent = '通过自定义网关生成新标题与新封面,费用由服务商收取';
} else {
generateBalance.className = 'status-chip chip-success';
generateBalance.textContent = '默认网关余额:1,280 点';
generateScopeText.textContent = '通过默认网关生成新标题与新封面,会消耗点数';
}
document.getElementById('titlebar-gateway').textContent = custom ? '自定义网关 已配置' : '默认网关 已连接';
/* ⑥:拦截新建与新提交,但保留已扣点任务的取回入口 */
document.querySelectorAll('[data-suite-gated]').forEach(function (button) {
button.disabled = custom;
button.title = custom ? '商品套图仅支持默认网关,请到⑤把生成来源切回默认网关' : '';
});
document.getElementById('suite-gateway-bar').classList.toggle('show', custom);
}
sourceRadios.forEach(function (radio) {
radio.addEventListener('change', function () {
pendingSource = radio.value;
renderSourcePanels(pendingSource);
/* dirty 由 settingsForm 的 change 监听统一标记,这里不单独清除,
避免把同页其他字段的未保存更改误判为已恢复 */
});
radio.addEventListener('focus', function () {
radio.closest('label').classList.add('is-focused');
});
radio.addEventListener('blur', function () {
radio.closest('label').classList.remove('is-focused');
});
});
/* 模型 CRUD 立即写入,与来源的待保存语义区分开 */
document.querySelectorAll('[data-model-crud]').forEach(function (button) {
button.addEventListener('click', function () {
showToast(button.dataset.modelCrud + '已立即写入配置,不需要点「保存设置」');
});
});
document.querySelectorAll('#model-list .model-item').forEach(function (item, index, items) {
item.addEventListener('click', function () {
Array.prototype.slice.call(items).forEach(function (other) {
other.setAttribute('aria-current', other === item ? 'true' : 'false');
other.setAttribute('aria-selected', other === item ? 'true' : 'false');
});
});
});
var testDefault = document.getElementById('test-default-gateway');
if (testDefault) {
testDefault.addEventListener('click', function () {
var result = document.getElementById('default-gateway-result');
result.textContent = '连接中…';
testDefault.disabled = true;
window.setTimeout(function () {
result.textContent = '连接成功 · 余额 1,280 点';
testDefault.disabled = false;
}, 900);
});
}
var testCustom = document.getElementById('test-custom-model');
if (testCustom) {
testCustom.addEventListener('click', function () {
var result = document.getElementById('custom-model-result');
result.textContent = '连接中…';
testCustom.disabled = true;
window.setTimeout(function () {
result.textContent = '连接成功 · 已返回结果';
testCustom.disabled = false;
}, 900);
});
}
function saveSettings() {
var switched = pendingSource !== savedSource;
savedSource = pendingSource;
setDirty(false);
renderSavedSource();
showToast(switched
? (savedSource === 'direct'
? '设置已保存:生成来源已切换为自定义网关,商品套图已停用'
: '设置已保存:生成来源已切换为默认网关')
: '设置已保存');
}
/* 放弃更改只恢复来源和普通字段,不回滚已经立即保存的模型 CRUD */
function discardSettings() {
pendingSource = savedSource;
sourceRadios.forEach(function (radio) { radio.checked = radio.value === savedSource; });
renderSourcePanels(savedSource);
setDirty(false);
showToast('已恢复未保存的更改;已单独保存的模型不受影响');
}
settingsSave.addEventListener('click', saveSettings);
settingsDiscard.addEventListener('click', discardSettings);
renderSourcePanels(savedSource);
renderSavedSource();
document.querySelectorAll('[data-unsaved]').forEach(function (button) {
button.addEventListener('click', function () {
button.closest('dialog').close();
if (button.dataset.unsaved === 'save') { setDirty(false); showToast('设置已保存'); }
else { setDirty(false); showToast('已放弃未保存的更改'); }
if (button.dataset.unsaved === 'save') { saveSettings(); } else { discardSettings(); }
});
});