dsh-tap 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/AGENTS.md +99 -0
  2. package/CHANGELOG.md +751 -0
  3. package/LICENSE +21 -0
  4. package/README.en.md +59 -0
  5. package/README.md +256 -0
  6. package/cordis.patch.yml +385 -0
  7. package/core/bridge.js +698 -0
  8. package/core/json-store.js +91 -0
  9. package/core/rotation.js +108 -0
  10. package/core/usage-meter.js +176 -0
  11. package/docs/diagnosis-cache-quota.md +181 -0
  12. package/docs/diagnosis-qoder-flash.md +67 -0
  13. package/docs/diagnosis-trae-3003.md +302 -0
  14. package/docs/goals/bridge-port-host-split.md +91 -0
  15. package/docs/goals/desktop-adaptation.md +106 -0
  16. package/docs/goals/qoder-cn-provider-design.md +308 -0
  17. package/docs/goals/settings-card-ux-redesign-plan.md +1680 -0
  18. package/docs/goals/settings-card-ux-redesign.md +162 -0
  19. package/docs/goals/trae-agent-v3.md +41 -0
  20. package/docs/goals/trae-work-cn-repair.md +44 -0
  21. package/docs/goals/v0.8-/351/242/235/345/272/246/345/217/257/350/247/201-/346/250/241/345/236/213/345/212/250/346/200/201/345/214/226-/345/244/232/346/234/215/345/212/241/345/225/206.md +85 -0
  22. package/docs/pitfalls.md +105 -0
  23. package/docs/reverse/trae-cloud-api.md +218 -0
  24. package/docs/reverse/trae-model-catalog.md +129 -0
  25. package/docs/reverse/traework-cn.md +520 -0
  26. package/docs/rules/STATE.md +198 -0
  27. package/docs/rules/content-moderation.md +54 -0
  28. package/docs/rules/dev-role-boundary.md +94 -0
  29. package/docs/rules/extra-providers.md +42 -0
  30. package/docs/rules/gateway-facts.md +91 -0
  31. package/docs/rules/oauth-handshake.md +76 -0
  32. package/docs/rules/prompt-cache.md +93 -0
  33. package/docs/rules/quota-signals.md +125 -0
  34. package/docs/rules/routing.md +84 -0
  35. package/docs/rules/templates/oauth-reverse-checklist.md +42 -0
  36. package/docs/rules/trae-surface.md +242 -0
  37. package/docs/rules/ua-validation.md +81 -0
  38. package/host-config.js +282 -0
  39. package/index.js +2306 -0
  40. package/lib/client.js +2893 -0
  41. package/local-scan.js +104 -0
  42. package/package.json +82 -0
  43. package/providers/ark/index.js +11 -0
  44. package/providers/bailian/index.js +10 -0
  45. package/providers/bigmodel/index.js +11 -0
  46. package/providers/codebuddy/agenttool.js +122 -0
  47. package/providers/codebuddy/catalog.js +227 -0
  48. package/providers/codebuddy/errors.js +44 -0
  49. package/providers/codebuddy/headers.js +36 -0
  50. package/providers/codebuddy/images.js +125 -0
  51. package/providers/codebuddy/index.js +123 -0
  52. package/providers/codebuddy/oauth.js +279 -0
  53. package/providers/deepseek/index.js +11 -0
  54. package/providers/moonshot/index.js +11 -0
  55. package/providers/openai-compat.js +177 -0
  56. package/providers/openrouter/index.js +27 -0
  57. package/providers/qoder/catalog.js +145 -0
  58. package/providers/qoder/cosy.js +419 -0
  59. package/providers/qoder/gateway.js +563 -0
  60. package/providers/qoder/index.js +116 -0
  61. package/providers/qoder/oauth.js +364 -0
  62. package/providers/qoder/qoder_auth.wasm +0 -0
  63. package/providers/qoder/quota.js +56 -0
  64. package/providers/qwen/index.js +15 -0
  65. package/providers/tool-pairing.js +129 -0
  66. package/providers/trae/catalog.js +103 -0
  67. package/providers/trae/errors.js +85 -0
  68. package/providers/trae/gateway.js +853 -0
  69. package/providers/trae/index.js +126 -0
  70. package/providers/trae/oauth.js +443 -0
  71. package/providers/trae/quota.js +75 -0
  72. package/providers/trae/remote.js +365 -0
  73. package/scripts/capture-cache.mjs +65 -0
  74. package/scripts/capture-traffic.mjs +83 -0
  75. package/scripts/hermes-probe-dev-role.mjs +263 -0
  76. package/scripts/measure-latency.mjs +253 -0
  77. package/scripts/probe-ark-thinking.mjs +298 -0
  78. package/scripts/probe-cache-decline.mjs +292 -0
  79. package/scripts/probe-cache-ttl.mjs +221 -0
  80. package/scripts/probe-cache.mjs +156 -0
  81. package/scripts/probe-codebuddy-efforts.mjs +355 -0
  82. package/scripts/probe-codebuddy-tier-wiring.mjs +244 -0
  83. package/scripts/probe-effort-gaps.mjs +133 -0
  84. package/scripts/probe-media.mjs +115 -0
  85. package/scripts/probe-moderation.mjs +159 -0
  86. package/scripts/probe-oauth.mjs +617 -0
  87. package/scripts/probe-qoder-attribution-arm8.mjs +92 -0
  88. package/scripts/probe-qoder-attribution-arm9.mjs +102 -0
  89. package/scripts/probe-qoder-attribution.mjs +266 -0
  90. package/scripts/probe-qoder-flash-confirm.mjs +94 -0
  91. package/scripts/probe-qoder-live.mjs +344 -0
  92. package/scripts/probe-qoder-matrix.mjs +274 -0
  93. package/scripts/probe-qoder-null-content.mjs +153 -0
  94. package/scripts/probe-qoder-pairing.mjs +308 -0
  95. package/scripts/probe-qoder-quota.mjs +162 -0
  96. package/scripts/probe-qoder-thinking-config.mjs +52 -0
  97. package/scripts/probe-qoder-thinking-efforts.mjs +269 -0
  98. package/scripts/probe-quota.mjs +136 -0
  99. package/scripts/probe-quota2.mjs +144 -0
  100. package/scripts/probe-routing.mjs +226 -0
  101. package/scripts/probe-trae-3003-diagnosis.mjs +139 -0
  102. package/scripts/probe-trae-agent-v3.mjs +399 -0
  103. package/scripts/probe-trae-efforts.mjs +149 -0
  104. package/scripts/probe-trae-live.mjs +147 -0
  105. package/scripts/probe-trae-max-effort.mjs +179 -0
  106. package/scripts/probe-trae-model-routing.mjs +381 -0
  107. package/scripts/probe-trae-thinking-scene.mjs +238 -0
  108. package/scripts/probe-trae-transport-outage.mjs +176 -0
  109. package/scripts/probe-ua.mjs +508 -0
  110. package/scripts/trae-model-catalog.mjs +632 -0
  111. package/scripts/verify-agents-md.mjs +45 -0
  112. package/scripts/verify-bridge.mjs +1041 -0
  113. package/scripts/verify-core-generic.mjs +302 -0
  114. package/scripts/verify-desktop-acceptance.mjs +184 -0
  115. package/scripts/verify-host-config.mjs +265 -0
  116. package/scripts/verify-models.mjs +390 -0
  117. package/scripts/verify-providers.mjs +255 -0
  118. package/scripts/verify-qoder-provider.mjs +1020 -0
  119. package/scripts/verify-rotation.mjs +308 -0
  120. package/scripts/verify-trae-model-catalog.mjs +284 -0
  121. package/scripts/verify-trae-provider.mjs +1451 -0
@@ -0,0 +1,1680 @@
1
+ # 设置卡通道手风琴重设计 实施计划
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** 把设置卡从「8 标签 + 三套状态展示」重写为「4 区块通道手风琴 + 区块头单一状态行」,消灭"找不到、太散"。
6
+
7
+ **Architecture:** 纯前端重写 `lib/client.js`(浏览器半,无构建步骤、手写 `React.createElement`)。顶层 4 个可展开区块(CodeBuddy / TraeWork CN / Qoder CN / 通用),区块头常显状态行(口径沿用 `buildChips`),展开才挂载分区、收起不卸载。后端 `/dsh-tap/settings` 契约、槽位注册、`core/`、`providers/` 全部零改动。
8
+
9
+ **Tech Stack:** React(宿主 seed 模块 `react`,经 `require`)、宿主原语 `@deepseek-ai/dsh-client-ui-primitives`(缺失时退原生)、`--dsw-alias-*` 设计 token、注入式 `cbc-` 样式;测试 = puppeteer-core + 系统 Chrome(仓库外 `dsh-ui-test/`)。
10
+
11
+ **Spec:** `docs/goals/settings-card-ux-redesign.md`(提交 `4d41f8d`,含证据核对与"必须原样迁移的硬修复清单")
12
+
13
+ ## Global Constraints
14
+
15
+ - 目标版本 **0.10.0**(`package.json` 现为 `0.9.11`)。
16
+ - 浏览器半无构建步骤:只能用 `var`/`function` + `React.createElement`,**禁止** JSX、ESM `import`、`require('@deepseek-ai/*')`(踩坑 #9)。
17
+ - 颜色一律走 `--dsw-alias-*` token;**不存在** `--dsw-alias-accent` / `--dsw-alias-label-error`,正确名是 `state-business-primary` / `state-error-primary`。
18
+ - 样式只有一个注入块 `<style data-plugin="dsh-tap" data-plugin-css="dsh-tap-card">`,类名一律 `cbc-` 前缀。
19
+ - 所有 hooks 必须在任何条件 `return` 之前调用;字段编辑器组件(`TextField`/`NumberField`/`LimitInput`)必须模块级定义(组件身份随父重渲染变化会丢焦点)。
20
+ - 受控 checkbox/select 用 `useRef` **同值去重**(不用时间窗),失败分支必须**销账**(踩坑 #27/#32)。
21
+ - `window.open` 必须在点击处理器内**同步**发起(异步被弹窗拦截器吃掉)。
22
+ - 后端契约零变化:`GET /dsh-tap/settings` 与全部 `POST {patch}` / `{action}` 的名称、参数、响应结构不动。
23
+ - 测试纪律:浏览器回归的 mock 通道**零真实写入**(跑前跑后对 `~/.dsh/codebuddy-plugin.json` 取哈希对账);选择器一律按 `.cbc-*` 类精确匹配(模糊匹配曾误删 Key)。
24
+ - **mock 隔离纪律**:`page.evaluateOnNewDocument` 装的 fetch 补丁**跨 reload 持久**——所有 mock 组一律用 `newPage(installer)` 开独立页面,主页面 `page` 自始至终不装 mock(否则后续组的 `getView(page)` 会被上一个 mock 拦掉,读到假视图)。
25
+ - `dsh-ui-test/` 在**仓库外**(`C:/Users/21613/dev/dsh-ui-test`)——它的改动**不进 git**,每个任务的 `git add` 只含仓库内文件。
26
+ - 文档中出现的 `lib/client.js` 行号均为提交 `4d41f8d` 时的位置;实施时**以 grep 锚点定位为准**(行号会随任务推进漂移)。
27
+
28
+ ## 测试环境速查(所有任务共用)
29
+
30
+ 已实测(2026-09-23):Windows 侧 `dsh` 不在 PATH,可执行入口在 launcher 的 node_modules;`~/.dsh/profiles/web/package.json` 用 `link:C:/Users/21613/dev/dsh-tap` 指向本仓库 ⇒ **改 `lib/client.js` 后刷新页面即生效,无需重装**。
31
+
32
+ ```bash
33
+ # ① 起测试服务(后台;启动行打印一次 token)
34
+ cd /c/Users/21613/dev/dsh-launcher && ./node_modules/.bin/dsh web \
35
+ > /c/Users/21613/dev/dsh-ui-test/dsh-web.log 2>&1 &
36
+ sleep 8
37
+ grep -o 'token=[A-Za-z0-9_.-]*' /c/Users/21613/dev/dsh-ui-test/dsh-web.log | head -1
38
+ # ↑ 取到的值拼成 http://127.0.0.1:3080/?token=<TOKEN>
39
+ # 若 grep 不中,直接看 dsh-web.log 的启动行(token 只打印一次)
40
+
41
+ # ② 跑回归
42
+ cd /c/Users/21613/dev/dsh-ui-test && node card-accordion.js "http://127.0.0.1:3080/?token=<TOKEN>"
43
+
44
+ # ③ 收尾:杀掉 3080(Windows 无 pkill;按 PID 杀,别用 taskkill //IM node.exe)
45
+ netstat -ano | grep ':3080' | head -3 # 最后一列是 PID
46
+ taskkill //PID <PID> //F
47
+ ```
48
+
49
+ 哈希对账(mock 通道任务必做):
50
+
51
+ ```bash
52
+ md5sum ~/.dsh/codebuddy-plugin.json # 跑前 / 跑后各一次,必须一致
53
+ ```
54
+
55
+ 离线回归(Task 9 收尾跑全量;纯前端改动预期零影响,作为保险):
56
+
57
+ ```bash
58
+ cd /c/Users/21613/dev/dsh-tap
59
+ npm run verify:bridge && npm run verify:core && npm run verify:providers \
60
+ && npm run verify:trae-provider && npm run verify:qoder && npm run verify:host-config \
61
+ && node scripts/verify-models.mjs --list
62
+ ```
63
+
64
+ ---
65
+
66
+ ### Task 1: 手风琴骨架(4 区块 + 状态行 + 懒挂载)
67
+
68
+ **Files:**
69
+ - Modify: `lib/client.js` — CSS 块(grep 锚点 `".cbc-help summary:hover"` 之后追加)、`TAB_DEFS`(`:363`)、`CodeBuddyCard`(`:381`–`:611`)、`tabBadge`(`:682`–`:712`,整个函数删除)、各 `PANEL_RENDERERS.x = Y`(8 处)
70
+ - Create: `C:/Users/21613/dev/dsh-ui-test/card-accordion.js`(仓库外,不进 git)
71
+
72
+ **Interfaces:**
73
+ - Consumes: `buildChips(data)`(`:619`,返回 `{login,models,bridge,search,image,trae,qoder}`,每项 `{tone,text,tab,tabTitle}`)、`Dot({tone})`、`PanelBoundary`、8 个分区组件(`LoginSection`/`ModelsSection`/`UsageSection`/`ToolsSection`/`ProvidersSection`/`TraeSection`/`QoderSection`/`BridgeAdvancedSection`)、`overriddenFor(data)`、`fetchWithTimeout`、`settleGateways`
74
+ - Produces(后续任务依赖,签名固定):
75
+ - `BLOCK_DEFS` = `[{id:"codebuddy",title:"CodeBuddy"},{id:"trae",title:"TraeWork CN"},{id:"qoder",title:"Qoder CN"},{id:"general",title:"通用"}]`
76
+ - `BLOCK_SECTIONS` = `{codebuddy:["login","models","tools","bridge"], trae:["trae"], qoder:["qoder"], general:["usage","providers"]}`
77
+ - `SECTION_RENDERERS`(由 `PANEL_RENDERERS` 重命名,键同上 8 个分区 id)
78
+ - `blockStatus(data, generalText)` → `{codebuddy:{tone,text}, trae:{...}, qoder:{...}, general:{...}}`
79
+ - `worstTone(chips)` → `"ok"|"warn"|"err"|"off"`
80
+ - `BlockHead(props)`,props = `{id,title,status:{tone,text},open,mounted,onToggle,control,flash}`
81
+ - `toggleBlock(id)`、`openBlocks`(`{[id]:true}`)、`mountedBlocks`(`{[id]:true}`)、`saved`(`{block,at}|null`)、`saveIn(blockId)` → `(patch)=>void`
82
+ - DOM 契约:`.cbc-acc[data-block=<id>]`(区块根)、`.cbc-acc.cbc-open`(展开态)、`.cbc-acc-toggle[data-block=<id>]`(展开按钮)、`.cbc-acc-title`、`.cbc-acc-status`、`.cbc-acc-body[data-block=<id>]#cbc-block-<id>`(`hidden` 表示收起)
83
+ - CSS 类:`.cbc-acc`、`.cbc-acc-head`、`.cbc-acc-toggle`、`.cbc-acc-title`、`.cbc-acc-status`、`.cbc-acc-body`
84
+
85
+ - [x] **Step 1: 写失败的回归脚本(新套件,从零建)**
86
+
87
+ 创建 `C:/Users/21613/dev/dsh-ui-test/card-accordion.js`,完整内容:
88
+
89
+ ```js
90
+ // dsh-tap 设置卡「通道手风琴」回归套件。
91
+ // 设计依据:dsh-tap/docs/goals/settings-card-ux-redesign.md
92
+ // 用法: node card-accordion.js <url-with-token>
93
+ const puppeteer = require("puppeteer-core");
94
+ const fs = require("fs");
95
+
96
+ const BASE = process.argv[2];
97
+ if (!BASE) { console.error("usage: node card-accordion.js <url-with-token>"); process.exit(2); }
98
+ const CHROME = "C:/Program Files/Google/Chrome/Application/chrome.exe";
99
+ fs.mkdirSync("shots", { recursive: true });
100
+
101
+ let passed = 0, failed = 0;
102
+ function check(name, cond, extra) {
103
+ if (cond) { passed++; console.log(" ok " + name); }
104
+ else { failed++; console.log(" FAIL " + name + (extra ? " — " + extra : "")); }
105
+ }
106
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
107
+
108
+ (async () => {
109
+ const browser = await puppeteer.launch({
110
+ executablePath: CHROME, headless: "new",
111
+ args: ["--disable-http-cache", "--window-size=1440,900", "--no-first-run"],
112
+ defaultViewport: { width: 1440, height: 900 },
113
+ });
114
+ const page = await browser.newPage();
115
+ const pageErrors = [];
116
+ page.on("pageerror", (e) => { const s = String(e).slice(0, 200); if (!/turnTail/.test(s)) pageErrors.push(s); });
117
+ await page.goto(BASE, { waitUntil: "networkidle2", timeout: 60000 });
118
+ await sleep(4000);
119
+
120
+ // ---- 驱动助手(宿主 DOM 精确匹配,踩坑 #16/#34)----
121
+ const openPM = (pg) => pg.evaluate(() => {
122
+ const els = [...document.querySelectorAll("button, [role=button], a, li, span")];
123
+ const hit = els.find((e) => /插件|Plugin/i.test(e.textContent || "") && (e.textContent || "").length < 30);
124
+ if (hit) { hit.click(); return true; }
125
+ return false;
126
+ });
127
+ const openCard = (pg) => pg.evaluate(() => {
128
+ const btns = [...document.querySelectorAll("button[class*=cardTitle], [class*=cardTitle]")];
129
+ const hit = btns.find((b) => (b.textContent || "").trim() === "dsh-tap");
130
+ if (hit) { hit.click(); return true; }
131
+ return false;
132
+ });
133
+ const getView = (pg) => pg.evaluate(async () =>
134
+ await (await fetch("/dsh-tap/settings", { headers: { accept: "application/json" } })).json());
135
+ const openBlock = (pg, id) => pg.evaluate((bid) => {
136
+ const b = document.querySelector('.cbc-acc-toggle[data-block="' + bid + '"]');
137
+ if (!b) return false;
138
+ b.click();
139
+ return true;
140
+ }, id);
141
+ const blockState = (pg) => pg.evaluate(() => ({
142
+ ids: [...document.querySelectorAll(".cbc-acc[data-block]")].map((x) => x.getAttribute("data-block")),
143
+ titles: [...document.querySelectorAll(".cbc-acc-title")].map((x) => (x.textContent || "").trim()),
144
+ open: [...document.querySelectorAll(".cbc-acc.cbc-open[data-block]")].map((x) => x.getAttribute("data-block")),
145
+ bodies: [...document.querySelectorAll(".cbc-acc-body[data-block]")].map((x) => x.getAttribute("data-block")),
146
+ visible: [...document.querySelectorAll(".cbc-acc-body[data-block]")]
147
+ .filter((x) => !x.hidden).map((x) => x.getAttribute("data-block")),
148
+ status: [...document.querySelectorAll(".cbc-acc[data-block]")].map((x) => ({
149
+ id: x.getAttribute("data-block"),
150
+ text: ((x.querySelector(".cbc-acc-status") || {}).textContent || "").trim(),
151
+ tone: (((x.querySelector(".cbc-dot") || {}).className || "").match(/cbc-(ok|warn|err|off)/) || [])[1] || "none",
152
+ })),
153
+ tabs: document.querySelectorAll(".cbc-tab").length,
154
+ strips: document.querySelectorAll(".cbc-strip").length,
155
+ headchips: document.querySelectorAll(".cbc-headchips").length,
156
+ badges: document.querySelectorAll(".cbc-tabcount").length,
157
+ }));
158
+ const setNativeValue = (pg, sel, val) => pg.evaluate((s, v) => {
159
+ const el = document.querySelector(s);
160
+ if (!el) return false;
161
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set;
162
+ setter.call(el, v);
163
+ el.dispatchEvent(new Event("input", { bubbles: true }));
164
+ return true;
165
+ }, sel, val);
166
+ // 每组断言用独立页面:evaluateOnNewDocument 的 fetch 补丁跨 reload 持久,
167
+ // 复用同一页面会污染后续断言(连 getView 都会被拦)。installMock 在 goto 之前调用。
168
+ const newPage = async (installMock) => {
169
+ const pg = await browser.newPage();
170
+ const errors = [];
171
+ pg.on("pageerror", (e) => { const s = String(e).slice(0, 200); if (!/turnTail/.test(s)) errors.push(s); });
172
+ if (installMock) await installMock(pg);
173
+ await pg.goto(BASE, { waitUntil: "networkidle2", timeout: 60000 });
174
+ await sleep(4000);
175
+ const pm = await openPM(pg);
176
+ await sleep(2500);
177
+ const card = await openCard(pg);
178
+ await sleep(4000);
179
+ return { page: pg, errors: errors, opened: !!(pm && card) };
180
+ };
181
+ // 幂等展开(已展开则不点,避免把区块又收起)
182
+ const ensureOpen = (pg, id) => pg.evaluate((bid) => {
183
+ const acc = document.querySelector('.cbc-acc[data-block="' + bid + '"]');
184
+ if (!acc) return false;
185
+ if (acc.classList.contains("cbc-open")) return true;
186
+ const t = acc.querySelector(".cbc-acc-toggle");
187
+ if (!t) return false;
188
+ t.click();
189
+ return true;
190
+ }, id);
191
+
192
+ check("[0] Plugin Manager 可开", await openPM(page));
193
+ await sleep(2500);
194
+ check("[0] dsh-tap 卡可点开", await openCard(page));
195
+ await sleep(4000);
196
+
197
+ // ---- [A1] 四区块齐全 + 顺序固定 + 标题 ----
198
+ let st = await blockState(page);
199
+ check("[A1] 四区块顺序固定",
200
+ JSON.stringify(st.ids) === JSON.stringify(["codebuddy", "trae", "qoder", "general"]), JSON.stringify(st.ids));
201
+ check("[A1] 区块标题",
202
+ JSON.stringify(st.titles) === JSON.stringify(["CodeBuddy", "TraeWork CN", "Qoder CN", "通用"]), JSON.stringify(st.titles));
203
+
204
+ // ---- [A2] 默认全部收起 ----
205
+ check("[A2] 默认全收(四区块都在、无一展开、无 body DOM)",
206
+ st.ids.length === 4 && st.open.length === 0 && st.bodies.length === 0,
207
+ JSON.stringify({ ids: st.ids, open: st.open, bodies: st.bodies }));
208
+
209
+ // ---- [A3] 展开才挂载 ----
210
+ check("[A3] 点开 CodeBuddy", await openBlock(page, "codebuddy"));
211
+ await sleep(2500);
212
+ st = await blockState(page);
213
+ check("[A3] 展开后 body 挂载且可见",
214
+ st.bodies.includes("codebuddy") && st.visible.includes("codebuddy"), JSON.stringify(st));
215
+ check("[A3] 只有点过的区块挂载(恰一个 body = codebuddy)",
216
+ st.bodies.length === 1 && st.bodies[0] === "codebuddy", JSON.stringify(st.bodies));
217
+ const hasCred = await page.evaluate(() =>
218
+ /登录方式/.test((document.querySelector('.cbc-acc-body[data-block=codebuddy]') || {}).textContent || ""));
219
+ check("[A3] CodeBuddy 区块含凭据内容", hasCred);
220
+
221
+ // ---- [A4] 收起不卸载 + 草稿保留 ----
222
+ const FILTER_SEL = '.cbc-acc-body[data-block=codebuddy] input[placeholder*="过滤"]';
223
+ check("[A4] 前置:筛选框可输入", await setNativeValue(page, FILTER_SEL, "glm"));
224
+ await sleep(800);
225
+ check("[A4] 收起 CodeBuddy", await openBlock(page, "codebuddy"));
226
+ await sleep(600);
227
+ st = await blockState(page);
228
+ check("[A4] 收起后 body 仍在 DOM(隐藏不卸载)",
229
+ st.bodies.includes("codebuddy") && !st.visible.includes("codebuddy"), JSON.stringify(st));
230
+ check("[A4] 重开 CodeBuddy", await openBlock(page, "codebuddy"));
231
+ await sleep(600);
232
+ const draft = await page.evaluate((s) => { const el = document.querySelector(s); return el ? el.value : null; }, FILTER_SEL);
233
+ check("[A4] 草稿跨收起/重开保留", draft === "glm", JSON.stringify(draft));
234
+ await setNativeValue(page, FILTER_SEL, ""); // 复原,避免影响后续断言
235
+
236
+ // ---- [A5] 多开独立 ----
237
+ check("[A5] 点开 Trae", await openBlock(page, "trae"));
238
+ await sleep(1500);
239
+ st = await blockState(page);
240
+ check("[A5] CodeBuddy 与 Trae 同时可见",
241
+ st.visible.includes("codebuddy") && st.visible.includes("trae"), JSON.stringify(st.visible));
242
+
243
+ // ---- [A6] 标签栏已退役 ----
244
+ check("[A6] 无 .cbc-tab 残留", st.tabs === 0, "cbc-tab=" + st.tabs);
245
+ check("[A7] 无 dsh-tap pageerror", pageErrors.length === 0, pageErrors.join(" | "));
246
+ await page.screenshot({ path: "shots/acc-task1.png" });
247
+
248
+ await browser.close();
249
+ console.log("\n=== card-accordion: " + passed + " 通过 / " + failed + " 失败 ===");
250
+ process.exit(failed ? 1 : 0);
251
+ })().catch((e) => { console.error("ERROR", e); process.exit(1); });
252
+ ```
253
+
254
+ - [x] **Step 2: 跑一次确认红**
255
+
256
+ ```bash
257
+ cd /c/Users/21613/dev/dsh-launcher && ./node_modules/.bin/dsh web > /c/Users/21613/dev/dsh-ui-test/dsh-web.log 2>&1 &
258
+ sleep 8 && grep -o 'token=[A-Za-z0-9_.-]*' /c/Users/21613/dev/dsh-ui-test/dsh-web.log | head -1
259
+ cd /c/Users/21613/dev/dsh-ui-test && node card-accordion.js "http://127.0.0.1:3080/?token=<TOKEN>"
260
+ ```
261
+
262
+ Expected: `[A1]`–`[A6]` 全 FAIL(`.cbc-acc*` 尚不存在,`st.ids` 为 `[]`),`[0]` 两条 ok,退出码 1。
263
+
264
+ - [x] **Step 3: 加 CSS(追加到 `CSS_TEXT` 数组末尾,锚点 `".cbc-help summary:hover{...}"` 之后)**
265
+
266
+ ```js
267
+ // ---- 通道手风琴(0.10.0 交互模型)----
268
+ ".cbc-acc{border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.25));border-radius:10px;margin-bottom:8px;background:var(--dsw-alias-bg-layer-1,transparent)}",
269
+ ".cbc-acc.cbc-open{background:var(--dsw-alias-bg-layer-2,#fff)}",
270
+ ".cbc-acc-head{display:flex;align-items:center;gap:8px;padding:10px 12px}",
271
+ ".cbc-acc-toggle{all:unset;flex:1 1 auto;display:flex;align-items:center;gap:8px;cursor:pointer;min-width:0}",
272
+ ".cbc-acc-toggle:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#1a66ff);outline-offset:-2px;border-radius:8px}",
273
+ ".cbc-acc-title{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary,inherit);flex:0 0 auto}",
274
+ ".cbc-acc-status{font-size:12px;color:var(--dsw-alias-label-tertiary,gray);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1 1 auto}",
275
+ ".cbc-acc-body{border-top:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.2));padding:8px 12px 10px}",
276
+ ".cbc-acc-body[hidden]{display:none}",
277
+ ```
278
+
279
+ - [x] **Step 4: 用区块定义替换标签定义(锚点 `var TAB_DEFS = [`,`:363`–`:373`)**
280
+
281
+ 把 `TAB_DEFS` 与 `var PANEL_RENDERERS = {};` 整体替换为:
282
+
283
+ ```js
284
+ // ------------------------------------------------------------------
285
+ // Blocks(通道手风琴)。顺序 = 主通道 → 两家订阅通道 → 跨通道通用;
286
+ // 归类原则「谁提供归谁」,证据见 docs/goals/settings-card-ux-redesign.md §3。
287
+ // ------------------------------------------------------------------
288
+ var BLOCK_DEFS = [
289
+ { id: "codebuddy", title: "CodeBuddy" },
290
+ { id: "trae", title: "TraeWork CN" },
291
+ { id: "qoder", title: "Qoder CN" },
292
+ { id: "general", title: "通用" },
293
+ ];
294
+ // 区块 → 分区序列(渲染顺序即数组顺序)。
295
+ var BLOCK_SECTIONS = {
296
+ codebuddy: ["login", "models", "tools", "bridge"],
297
+ trae: ["trae"],
298
+ qoder: ["qoder"],
299
+ general: ["usage", "providers"],
300
+ };
301
+ var SECTION_RENDERERS = {};
302
+ ```
303
+
304
+ 然后把 8 处 `PANEL_RENDERERS.xxx = YyySection;` 改名为 `SECTION_RENDERERS.xxx = YyySection;`(键不变:`login`/`models`/`usage`/`tools`/`providers`/`trae`/`qoder`/`bridge`)。
305
+
306
+ - [x] **Step 5: 加状态行与区块头(放在 `buildChips` 之后、`LoginSection` 之前;同时删掉 `tabBadge` 整个函数 `:682`–`:712`)**
307
+
308
+ ```js
309
+ // 区块头状态行:判定口径沿用 buildChips(唯一真源)。每个区块取自己那几枚
310
+ // 芯片拼一行,tone = 其中最差的一枚——warn/err 因此就地出现在所属区块头,
311
+ // 不再需要独立的注意条(0.10.0 状态收敛)。
312
+ var TONE_RANK = { off: 0, ok: 1, warn: 2, err: 3 };
313
+ function worstTone(chips) {
314
+ var t = "off";
315
+ chips.forEach(function (c) {
316
+ if (c && (TONE_RANK[c.tone] || 0) > TONE_RANK[t]) t = c.tone;
317
+ });
318
+ return t;
319
+ }
320
+ function blockStatus(data, generalText) {
321
+ var c = buildChips(data);
322
+ var cb = [c.login, c.models, c.bridge];
323
+ var join = function (list) {
324
+ return list.map(function (x) { return x.text; }).join(" · ");
325
+ };
326
+ return {
327
+ codebuddy: { tone: worstTone(cb), text: join(cb) },
328
+ trae: { tone: c.trae.tone, text: c.trae.text },
329
+ qoder: { tone: c.qoder.tone, text: c.qoder.text },
330
+ general: { tone: "off", text: generalText || "额度与用量 · 服务商" },
331
+ };
332
+ }
333
+
334
+ // 区块头:div 里两个独立交互元素(展开 button + 可选开关),避免嵌套交互。
335
+ // Task 3 起 control 槽放 Trae/Qoder 的启用开关。
336
+ function BlockHead(props) {
337
+ var chevron = IconChevron
338
+ ? createElement("span", { className: "cbc-chevron" + (props.open ? " cbc-open" : "") }, createElement(IconChevron, { size: 14 }))
339
+ : createElement("span", { className: "cbc-chevron" + (props.open ? " cbc-open" : "") }, "▾");
340
+ var kids = [
341
+ createElement("button", {
342
+ key: "t", type: "button", className: "cbc-acc-toggle", "data-block": props.id,
343
+ "aria-expanded": props.open ? "true" : "false",
344
+ // 未挂载时没有对应 body,不留空引用(同原 tablist 纪律)。
345
+ "aria-controls": props.mounted ? "cbc-block-" + props.id : undefined,
346
+ onClick: props.onToggle,
347
+ },
348
+ createElement(Dot, { tone: props.status.tone }),
349
+ createElement("span", { className: "cbc-acc-title" }, props.title),
350
+ createElement("span", { className: "cbc-acc-status" }, props.status.text)),
351
+ ];
352
+ if (props.control) kids.push(createElement("span", { key: "c", className: "cbc-acc-ctl" }, props.control));
353
+ if (props.flash) kids.push(createElement("span", { key: "f", className: "cbc-saveflash" }, "已保存 ✓"));
354
+ kids.push(createElement("span", { key: "v", className: "cbc-chevronwrap" }, chevron));
355
+ return createElement("div", { className: "cbc-acc-head" }, kids);
356
+ }
357
+ ```
358
+
359
+ 同时在 CSS 块补两条(接 Step 3 那组后面):
360
+
361
+ ```js
362
+ ".cbc-acc-ctl{flex:0 0 auto;display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-tertiary,gray)}",
363
+ ".cbc-chevronwrap{flex:0 0 auto;display:inline-flex}",
364
+ ```
365
+
366
+ - [x] **Step 6: 重写 `CodeBuddyCard` 的状态与渲染(`:381`–`:611`)**
367
+
368
+ 改动点(其余逻辑——`load`/`post`/`save`/`settleGateways`/`prevOpenRef` 副作用——原样保留):
369
+
370
+ 6a. 卡片自身折叠态改名,避免与区块展开态撞名(锚点 `var openState = useState(embedded);`):
371
+
372
+ ```js
373
+ var cardOpenState = useState(embedded);
374
+ var cardOpen = cardOpenState[0];
375
+ var setCardOpen = cardOpenState[1];
376
+ ```
377
+
378
+ 同函数内所有 `open` 引用改 `cardOpen`、`setOpen` 改 `setCardOpen`(共 5 处:`prevOpenRef` 副作用、`chevron`、`headChips` 条件、`cardClass`、`!open && !embedded` 早退、header 的 `aria-expanded`/`onClick`)。
379
+
380
+ 6b. 标签状态换区块状态(删掉 `tabState`/`activeTab`/`setActiveTab`/`mountedState`/`mounted`/`setMounted`/`savedState`/`savedAt`/`setSavedAt`/`switchTab`,换成):
381
+
382
+ ```js
383
+ // 手风琴:多开互不联动;展开才挂载、收起不卸载(hidden 保留 DOM,
384
+ // 草稿/滚动/已拉目录跨展开与保存保留)。
385
+ var openBlocksState = useState({});
386
+ var openBlocks = openBlocksState[0];
387
+ var setOpenBlocks = openBlocksState[1];
388
+ var mountedBlocksState = useState({});
389
+ var mountedBlocks = mountedBlocksState[0];
390
+ var setMountedBlocks = mountedBlocksState[1];
391
+ // 「已保存 ✓」落在触发该次保存的区块头右侧,1.8s 自愈。
392
+ var savedState = useState(null); // { block, at }
393
+ var saved = savedState[0];
394
+ var setSaved = savedState[1];
395
+
396
+ var toggleBlock = function (id) {
397
+ setOpenBlocks(function (prev) {
398
+ var next = Object.assign({}, prev);
399
+ if (next[id]) delete next[id]; else next[id] = true;
400
+ return next;
401
+ });
402
+ setMountedBlocks(function (prev) {
403
+ if (prev[id]) return prev;
404
+ var next = Object.assign({}, prev);
405
+ next[id] = true;
406
+ return next;
407
+ });
408
+ };
409
+ ```
410
+
411
+ 6c. `save` 带上区块归属(锚点 `var save = function (patch) {`):
412
+
413
+ ```js
414
+ var save = function (patch, blockId) {
415
+ post({ patch: patch }).then(function (res) {
416
+ if (!res.ok) { setErr(res.d && res.d.error ? res.d.error : "保存失败(HTTP " + res.status + ")"); return; }
417
+ setSaved({ block: blockId || null, at: Date.now() });
418
+ load().then(function (d) { if (gatewayPending(d)) settleGateways(0); });
419
+ }).catch(function (e) { setErr(e && e.message ? e.message : "保存失败(网络)"); });
420
+ };
421
+ // 分区拿到的是绑定了所属区块的 save —— 「已保存 ✓」才知道浮在哪个区块头。
422
+ var saveIn = function (blockId) {
423
+ return function (patch) { save(patch, blockId); };
424
+ };
425
+ ```
426
+
427
+ 6d. 闪存副作用改依赖 `saved`(锚点 `if (!savedAt) return undefined;`):
428
+
429
+ ```js
430
+ useEffect(function () {
431
+ if (!saved) return undefined;
432
+ var t = setTimeout(function () { setSaved(null); }, 1800);
433
+ return function () { clearTimeout(t); };
434
+ }, [saved]);
435
+ ```
436
+
437
+ 6e. 渲染部分:删掉 `attn`/`strip` 之外的 `tabBar`(含 `onTabKeyDown`、`tabIds`、`badge = tabBadge(data)`)与 `panels`,换成 `blocks`。`panelProps` 改名 `sectionProps`,并把每个分区的 `save` 换成 `saveIn(<所属区块>)`、`usage` 的 `active` 换成 `!!openBlocks.general`:
438
+
439
+ ```js
440
+ var sectionProps = {
441
+ login: { value: data.value || {}, oauth: data.oauth || {}, save: saveIn("codebuddy"), post: post, reload: load, setErr: setErr, overridden: overriddenFor(data) },
442
+ models: { post: post, setErr: setErr, reload: load, modelsInfo: data.models || {}, value: data.value || {}, save: saveIn("codebuddy") },
443
+ tools: { value: data.value || {}, save: saveIn("codebuddy"), overridden: overriddenFor(data) },
444
+ bridge: { value: data.value || {}, save: saveIn("codebuddy"), overridden: overriddenFor(data), bridgeView: data.bridge || {} },
445
+ trae: { value: data.value || {}, save: saveIn("trae"), post: post, reload: load, setErr: setErr, overridden: overriddenFor(data), trae: data.trae || {} },
446
+ qoder: { value: data.value || {}, save: saveIn("qoder"), post: post, reload: load, setErr: setErr, overridden: overriddenFor(data), qoder: data.qoder || {} },
447
+ usage: { post: post, value: data.value || {}, save: saveIn("general"), overridden: overriddenFor(data), active: !!openBlocks.general },
448
+ providers: { post: post, setErr: setErr },
449
+ };
450
+ var status = blockStatus(data, null);
451
+ var blocks = BLOCK_DEFS.map(function (def) {
452
+ var isOpen = !!openBlocks[def.id];
453
+ var isMounted = !!mountedBlocks[def.id];
454
+ return createElement("div", {
455
+ key: def.id, className: "cbc-acc" + (isOpen ? " cbc-open" : ""), "data-block": def.id,
456
+ },
457
+ createElement(BlockHead, {
458
+ id: def.id, title: def.title, status: status[def.id],
459
+ open: isOpen, mounted: isMounted,
460
+ onToggle: function () { toggleBlock(def.id); },
461
+ flash: !!(saved && saved.block === def.id),
462
+ }),
463
+ isMounted ? createElement("div", {
464
+ className: "cbc-acc-body cbc-block", "data-block": def.id,
465
+ id: "cbc-block-" + def.id,
466
+ hidden: isOpen ? undefined : true,
467
+ }, BLOCK_SECTIONS[def.id].map(function (sid) {
468
+ // PanelBoundary 粒度 = 每分区:一处塌落不影响同区块其它分区。
469
+ return createElement(PanelBoundary, { key: sid },
470
+ createElement(SECTION_RENDERERS[sid], sectionProps[sid]));
471
+ })) : null);
472
+ });
473
+ ```
474
+
475
+ 6f. 卡片返回体(锚点 `return createElement(cardTag, { className: cardClass }, embedded ? null : header,`):
476
+
477
+ ```js
478
+ return createElement(cardTag, { className: cardClass }, embedded ? null : header,
479
+ createElement("div", { className: "cbc-body" },
480
+ errorBanner,
481
+ strip,
482
+ blocks,
483
+ createElement("p", { className: "cbc-status" }, "修改即保存(写入 ~/.dsh/codebuddy-plugin.json),立即生效;标\u201C重置\u201D的字段可一键恢复默认值。")));
484
+ ```
485
+
486
+ (`strip` 本任务**保留**,Task 2 删;`headChips` 同样保留到 Task 2。)
487
+
488
+ - [x] **Step 7: 跑回归确认绿**
489
+
490
+ ```bash
491
+ cd /c/Users/21613/dev/dsh-ui-test && node card-accordion.js "http://127.0.0.1:3080/?token=<TOKEN>"
492
+ ```
493
+
494
+ Expected: `[A1]`–`[A7]` 全 ok(14 通过 / 0 失败),退出码 0。刷新页面即生效(profile 已 link 本仓库);若断言全红先确认没有浏览器缓存(脚本已带 `--disable-http-cache`)。
495
+
496
+ - [x] **Step 8: 提交**
497
+
498
+ ```bash
499
+ cd /c/Users/21613/dev/dsh-tap
500
+ git add lib/client.js
501
+ git commit -m "feat(client): 设置卡改通道手风琴骨架(4 区块 + 区块头状态行 + 懒挂载)"
502
+ ```
503
+
504
+ (`dsh-ui-test/card-accordion.js` 在仓库外,不进本次提交。)
505
+
506
+ ---
507
+
508
+ ### Task 2: 状态收敛(删注意条/折叠芯片/品牌前缀 + 移植两组 mock 通道)
509
+
510
+ **Files:**
511
+ - Modify: `lib/client.js` — `buildChips`(grep 锚点 `function buildChips(data) {`)、`CodeBuddyCard` 里的 `headChips`(锚点 `var headChips = null;`)与 `attn`/`strip`(锚点 `var attn = [chips.login`)、`gatewayPending`(锚点 `var gatewayPending = function (d) {`)
512
+ - Modify: `C:/Users/21613/dev/dsh-ui-test/card-accordion.js`(追加断言)
513
+
514
+ **Interfaces:**
515
+ - Consumes: Task 1 的 `blockStatus`/`BlockHead`/`.cbc-acc-*` DOM 契约、`settleGateways`/`GATEWAY_SETTLE_DELAYS`
516
+ - Produces: `buildChips(data)` 的 `text` 去掉品牌前缀(`"Trae 运行中"` → `"运行中"`)并删除 `tab`/`tabTitle` 字段(消费者已消失);`attentionCount(data)` → `number`(warn/err 芯片个数,旧槽折叠态按需芯片用);`blockStatus(data, generalText)` 签名不变
517
+
518
+ - [x] **Step 1: 追加失败断言(插在 `card-accordion.js` 的 `await browser.close();` 之前)**
519
+
520
+ ```js
521
+ // ---- [B] 状态收敛:三套状态展示退役,warn 只出现在区块头 ----
522
+ st = await blockState(page);
523
+ check("[B1] 无注意条", st.strips === 0, "strips=" + st.strips);
524
+ check("[B1] 无标签徽标", st.badges === 0, "badges=" + st.badges);
525
+
526
+ // [B2] 状态行口径 = 独立预言机(从 GET 视图另算一遍,双向对齐;
527
+ // 避免 .cbc-acc-status 缺席时 [].every() 恒真的空洞通过)
528
+ const view = await getView(page);
529
+ const oracle = (() => {
530
+ const v = view.value || {}, o = view.oauth || {}, b = view.bridge || {}, m = view.models || {};
531
+ const t = (view.trae || {}).bridge || {}, q = (view.qoder || {}).bridge || {};
532
+ const qo = ((view.qoder || {}).oauth || {});
533
+ const login = v.authMode === "oauth"
534
+ ? (o.needsRelogin ? "OAuth 需重新登录" : o.signedIn ? "OAuth 已登录" : (o.pending ? "OAuth 登录中…" : "OAuth 未登录"))
535
+ : (v.activeApiKey ? "API Key · " + v.activeApiKey : "API Key 未配置");
536
+ const models = "模型 " + (m.effectiveCount != null ? m.effectiveCount : "?") + " 个";
537
+ const bridge = v.bridgeEnabled === false ? "桥已禁用"
538
+ : b.running ? "桥 :" + b.port : "桥未监听 :" + (b.port || v.bridgePort || "?");
539
+ const trae = v.traeEnabled === true
540
+ ? (t.running === true ? "运行中" : "网关未监听 :" + (v.traeBridgePort || "?"))
541
+ : "未启用";
542
+ const qoder = v.qoderEnabled === true
543
+ ? (q.running === true ? "运行中" : "网关未监听 :" + (v.qoderBridgePort || "?"))
544
+ : (qo.signedIn ? (qo.needsRelogin ? "需重登" : "已登录·未启用") : (qo.pending ? "登录中" : "未启用"));
545
+ return { codebuddy: [login, models, bridge], trae: [trae], qoder: [qoder] };
546
+ })();
547
+ const byId = {};
548
+ st.status.forEach((s) => { byId[s.id] = s; });
549
+ ["codebuddy", "trae", "qoder"].forEach((id) => {
550
+ const got = (byId[id] || {}).text || "";
551
+ const missing = oracle[id].filter((frag) => got.indexOf(frag) === -1);
552
+ check("[B2] " + id + " 状态行含全部应有片段", missing.length === 0,
553
+ JSON.stringify({ got: got, missing: missing }));
554
+ });
555
+ check("[B2] 状态行不含品牌前缀(Trae/Qoder 已由区块标题承载)",
556
+ !/^(Trae|Qoder) /.test((byId.trae || {}).text || "") && !/^(Trae|Qoder) /.test((byId.qoder || {}).text || ""),
557
+ JSON.stringify({ trae: (byId.trae || {}).text, qoder: (byId.qoder || {}).text }));
558
+ check("[B2] 预言机与 DOM 数量双向对齐", Object.keys(oracle).length === 3 && st.status.length === 4,
559
+ JSON.stringify(st.status.map((s) => s.id)));
560
+
561
+ // [B3] mock 通道:启用但网关未监听 → warn 落在所属区块头(带端口),且无注意条
562
+ const mock = JSON.parse(JSON.stringify(view));
563
+ mock.value.traeEnabled = true;
564
+ mock.value.qoderEnabled = true;
565
+ mock.value.traeBridgePort = 3902;
566
+ mock.value.qoderBridgePort = 3903;
567
+ mock.trae = Object.assign({}, mock.trae, { bridge: { running: false, port: null, lastError: "mock-eaddrinuse" } });
568
+ mock.qoder = Object.assign({}, mock.qoder, { bridge: { running: false, port: null, lastError: "mock-eaddrinuse" } });
569
+ const b3 = await newPage((pg) => pg.evaluateOnNewDocument((m) => {
570
+ const orig = window.fetch;
571
+ window.fetch = function (input, init) {
572
+ const url = typeof input === "string" ? input : (input && input.url) || "";
573
+ const method = String((init && init.method) || (typeof input !== "string" && input && input.method) || "GET").toUpperCase();
574
+ if (url.indexOf("/dsh-tap/settings") !== -1 && method === "GET") {
575
+ return Promise.resolve(new Response(JSON.stringify(m), { status: 200, headers: { "content-type": "application/json" } }));
576
+ }
577
+ return orig.apply(this, arguments);
578
+ };
579
+ }, mock));
580
+ check("[B3] mock 通道:PM 与卡可开", b3.opened);
581
+ st = await blockState(b3.page);
582
+ const sById = {}; st.status.forEach((s) => { sById[s.id] = s; });
583
+ check("[B3] Trae/Qoder 区块头 tone=warn", sById.trae.tone === "warn" && sById.qoder.tone === "warn",
584
+ JSON.stringify({ trae: sById.trae, qoder: sById.qoder }));
585
+ check("[B3] 未监听文案带端口(:3902 / :3903)",
586
+ /:3902/.test(sById.trae.text) && /:3903/.test(sById.qoder.text),
587
+ JSON.stringify({ trae: sById.trae.text, qoder: sById.qoder.text }));
588
+ check("[B3] 仍无注意条(warn 只在区块头)", st.strips === 0, "strips=" + st.strips);
589
+ await b3.page.screenshot({ path: "shots/acc-task2-warn.png" });
590
+ await b3.page.close();
591
+
592
+ // [B4] mock 通道 2:保存后的网关退避补拉(踩坑 #45)在新结构下仍自愈
593
+ const settleView = JSON.parse(JSON.stringify(view));
594
+ settleView.value.bridgeEnabled = true;
595
+ settleView.value.traeEnabled = false;
596
+ settleView.value.qoderEnabled = false;
597
+ settleView.bridge = { running: false, port: 3901, lastError: null };
598
+ const b4 = await newPage((pg) => pg.evaluateOnNewDocument((v) => {
599
+ const stt = { posts: 0, gets: 0 };
600
+ const orig = window.fetch;
601
+ const json = (o) => Promise.resolve(new Response(JSON.stringify(o), { status: 200, headers: { "content-type": "application/json" } }));
602
+ window.fetch = function (input, init) {
603
+ const url = typeof input === "string" ? input : (input && input.url) || "";
604
+ const method = String((init && init.method) || (typeof input !== "string" && input && input.method) || "GET").toUpperCase();
605
+ if (url.indexOf("/dsh-tap/settings") === -1) return orig.apply(this, arguments);
606
+ if (method === "POST") { stt.posts++; stt.gets = 0; return json({ ok: true, value: v.value, user: {} }); }
607
+ stt.gets++;
608
+ const body = JSON.parse(JSON.stringify(v));
609
+ // 第 1 次 GET 仍未监听(复现竞态窗口),第 2 次起已监听(退避补拉应自愈)
610
+ body.bridge = { running: stt.gets >= 2, port: 3901, lastError: null };
611
+ return json(body);
612
+ };
613
+ window.__settleProbe = stt;
614
+ }, settleView));
615
+ const page2 = b4.page;
616
+ const pageErrors2 = b4.errors;
617
+ check("[B4] mock 通道 2 可开", b4.opened);
618
+ const headOf = (pg) => pg.evaluate(() => ({
619
+ text: ((document.querySelector('.cbc-acc[data-block=codebuddy] .cbc-acc-status') || {}).textContent || "").trim(),
620
+ probe: window.__settleProbe || null,
621
+ }));
622
+ const before = await headOf(page2);
623
+ check("[B4] 前置:CodeBuddy 区块头含「桥未监听 :3901」",
624
+ /桥未监听 :3901/.test(before.text), JSON.stringify(before));
625
+ check("[B4] 展开 CodeBuddy", await openBlock(page2, "codebuddy"));
626
+ await sleep(2000);
627
+ // 基线:展开区块时 ModelsSection 挂载即发 model-list POST(与保存无关),
628
+ // posts 绝对值恒 ≥2 —— 断言保存动作的增量恰为 1(绝对值 posts===1 在
629
+ // oauth 模式宿主机上不可达;语义等价收紧,判别力不变)。
630
+ const prePosts = (await headOf(page2)).probe.posts;
631
+ const toggled = await page2.evaluate(() => {
632
+ const cb = document.querySelector('.cbc-acc-body[data-block=codebuddy] input.cbc-check');
633
+ if (!cb) return false;
634
+ cb.click();
635
+ return true;
636
+ });
637
+ await sleep(600); // 首次 GET 已回,仍在 1s 退避窗内
638
+ const mid = await headOf(page2);
639
+ check("[B4] 保存后首次 GET 仍「桥未监听」(复现竞态窗口)",
640
+ toggled && !!mid.probe && mid.probe.posts === prePosts + 1 && mid.probe.gets >= 1 && /桥未监听/.test(mid.text),
641
+ JSON.stringify({ toggled: toggled, prePosts: prePosts, mid: mid }));
642
+ await sleep(2500); // 越过 1s 退避
643
+ const after = await headOf(page2);
644
+ check("[B4] 退避补拉后自愈:区块头假「桥未监听」消失",
645
+ !!after.probe && after.probe.gets >= 2 && !/桥未监听/.test(after.text), JSON.stringify(after));
646
+ check("[B4] mock 通道 2 无 pageerror", pageErrors2.length === 0, pageErrors2.join(" | "));
647
+ await page2.screenshot({ path: "shots/acc-task2-settle.png" });
648
+ await page2.close();
649
+ ```
650
+
651
+ - [x] **Step 2: 跑一次确认红**
652
+
653
+ Expected: `[B1]` 无注意条 FAIL(`strip` 仍在)、`[B2]` 品牌前缀 FAIL、`[B3]`/`[B4]` 相关项 FAIL;退出码 1。
654
+
655
+ - [x] **Step 3: 删三套状态展示 + 去品牌前缀**
656
+
657
+ 3a. `buildChips` 内:删掉每个芯片的 `tab`/`tabTitle` 包装(`Object.assign({ tab: ..., tabTitle: ... }, login)` → 直接 `login`),并把 trae/qoder 的 `text` 去掉品牌前缀:
658
+
659
+ ```js
660
+ text: value.traeEnabled === true
661
+ ? ((((data.trae || {}).bridge || {}).running === true) ? "运行中" : "网关未监听 :" + (value.traeBridgePort || "?"))
662
+ : "未启用",
663
+ ```
664
+ ```js
665
+ text: value.qoderEnabled === true
666
+ ? ((((data.qoder || {}).bridge || {}).running === true) ? "运行中" : "网关未监听 :" + (value.qoderBridgePort || "?"))
667
+ : (qoauth.signedIn ? (qoauth.needsRelogin ? "需重登" : "已登录·未启用") : (qoauth.pending ? "登录中" : "未启用")),
668
+ ```
669
+
670
+ 3b. `CodeBuddyCard` 内删掉 `attn`/`strip` 两个变量与渲染体里的 `strip,` 一行(Task 1 Step 6f 的返回体变成 `errorBanner, blocks, createElement("p", ...)`)。
671
+
672
+ 3c. `gatewayPending` 里对芯片 `text` 的 `/未监听/` 判定不变(文案仍含"未监听");确认它只读 `c.bridge`/`c.trae`/`c.qoder` 三枚,不依赖已删字段。
673
+
674
+ 3d. `headChips`(折叠态三芯片)换成按需单芯片:
675
+
676
+ ```js
677
+ // 折叠态(仅旧槽 settings.plugin.item 会走到):不再常显三芯片——
678
+ // 状态由区块头承载;只在确有待处理项时浮一枚计数芯片。
679
+ var headChips = null;
680
+ if (!cardOpen && data) {
681
+ var n = attentionCount(data);
682
+ if (n > 0) {
683
+ headChips = createElement("span", { className: "cbc-headchips" },
684
+ createElement("span", { className: "cbc-chip" },
685
+ createElement(Dot, { tone: "warn" }), n + " 项需处理"));
686
+ }
687
+ }
688
+ ```
689
+
690
+ 3e. 在 `blockStatus` 之后加:
691
+
692
+ ```js
693
+ // 待处理项计数(warn/err):旧槽折叠态的按需芯片用;展开态由区块头就地承载。
694
+ function attentionCount(data) {
695
+ var c = buildChips(data);
696
+ return [c.login, c.models, c.bridge, c.search, c.image, c.trae, c.qoder]
697
+ .filter(function (x) { return x.tone === "warn" || x.tone === "err"; }).length;
698
+ }
699
+ ```
700
+
701
+ 3f. CSS 清理:删 `".cbc-strip{...}"`(注意条已无),并删掉 Task 1 留下的死规则 `.cbc-tabs` / `.cbc-tab` / `.cbc-tab:hover` / `.cbc-tab:focus-visible` / `.cbc-tab.cbc-active` / `.cbc-tabcount` / `.cbc-tabcount.cbc-ok` / `.cbc-tabcount.cbc-warn` / `.cbc-panel` / `.cbc-panel[hidden]`(删前 grep 确认 JS 侧零引用)。**必须保留** `.cbc-saveflash`(`BlockHead` 的「已保存 ✓」在用)、`.cbc-chip` 与 `button.cbc-chip*`(折叠态按需芯片 + 用量页账户/流式桥行内芯片在用)、`.cbc-dot*`。
702
+
703
+ - [x] **Step 4: 跑回归确认绿**
704
+
705
+ Expected: 全部 ok(Task 1 的 14 条 + `[B1]`–`[B4]` 共 12 条 = 26 通过 / 0 失败)。
706
+
707
+ - [x] **Step 5: 哈希对账(mock 通道零写入)**
708
+
709
+ ```bash
710
+ md5sum ~/.dsh/codebuddy-plugin.json
711
+ ```
712
+
713
+ Expected: 与跑前一致(`[B3]`/`[B4]` 全走 mock,不应有任何真实写入)。
714
+
715
+ - [x] **Step 6: 提交**
716
+
717
+ ```bash
718
+ cd /c/Users/21613/dev/dsh-tap && git add lib/client.js
719
+ git commit -m "feat(client): 状态收敛到区块头——删注意条/折叠三芯片/标签徽标与品牌前缀"
720
+ ```
721
+
722
+ ---
723
+
724
+ ### Task 3: 区块头启用开关(Trae / Qoder)
725
+
726
+ **Files:**
727
+ - Modify: `lib/client.js` — `CodeBuddyCard` 的 `blocks` 构造(Task 1 Step 6e)、`TraeSection` 的「启用通道」行(`:1628`–`:1635`)、`QoderSection` 的「启用通道」行(`:1897`–`:1904`)
728
+ - Modify: `C:/Users/21613/dev/dsh-ui-test/card-accordion.js`
729
+
730
+ **Interfaces:**
731
+ - Consumes: `BlockHead` 的 `control` 槽、`saveIn(blockId)`、`data.value.traeEnabled`/`qoderEnabled`、`overriddenFor(data)`
732
+ - Produces: DOM 契约 `.cbc-acc-head input.cbc-check[data-field=traeEnabled|qoderEnabled]`;`BlockHead` 的 `control` 用法固定为「checkbox + 文案 + ResetButton」
733
+
734
+ - [x] **Step 1: 追加失败断言(插在 `[B4]` 组之后、`await browser.close();` 之前)**
735
+
736
+ ```js
737
+ // ---- [C] 区块头启用开关(Trae/Qoder 唯一落点)----
738
+ // `page` 自始至终不装 mock(mock 组都走 newPage 的独立页面),故此处无需 reload。
739
+ const heads = await page.evaluate(() => ({
740
+ codebuddy: !!document.querySelector('.cbc-acc[data-block=codebuddy] .cbc-acc-head input.cbc-check'),
741
+ general: !!document.querySelector('.cbc-acc[data-block=general] .cbc-acc-head input.cbc-check'),
742
+ trae: !!document.querySelector('.cbc-acc-head input.cbc-check[data-field=traeEnabled]'),
743
+ qoder: !!document.querySelector('.cbc-acc-head input.cbc-check[data-field=qoderEnabled]'),
744
+ }));
745
+ check("[C1] Trae/Qoder 头部有启用开关", heads.trae && heads.qoder, JSON.stringify(heads));
746
+ check("[C1] CodeBuddy/通用头部无开关", !heads.codebuddy && !heads.general, JSON.stringify(heads));
747
+
748
+ // [C2] 展开区内不再有「启用通道」行(唯一落点 = 头部)
749
+ await ensureOpen(page, "trae"); await sleep(2000);
750
+ const dupRow = await page.evaluate(() => {
751
+ const body = document.querySelector('.cbc-acc-body[data-block=trae]');
752
+ const labels = [...(body ? body.querySelectorAll(".cbc-row-label") : [])].map((x) => (x.textContent || "").trim());
753
+ return labels;
754
+ });
755
+ check("[C2] Trae 展开区无「启用通道」行", !dupRow.includes("启用通道"), JSON.stringify(dupRow));
756
+
757
+ // [C3] 点头部开关 = 恰好 1 次 POST,patch 只含该字段(mock,零真实写入)
758
+ const realView3 = await getView(page);
759
+ const c3 = await newPage((pg) => pg.evaluateOnNewDocument((v) => {
760
+ const stt = { posts: 0, bodies: [] };
761
+ const orig = window.fetch;
762
+ const json = (o) => Promise.resolve(new Response(JSON.stringify(o), { status: 200, headers: { "content-type": "application/json" } }));
763
+ window.fetch = function (input, init) {
764
+ const url = typeof input === "string" ? input : (input && input.url) || "";
765
+ const method = String((init && init.method) || (typeof input !== "string" && input && input.method) || "GET").toUpperCase();
766
+ if (url.indexOf("/dsh-tap/settings") === -1) return orig.apply(this, arguments);
767
+ if (method === "POST") {
768
+ stt.posts++;
769
+ try { stt.bodies.push(JSON.parse((init && init.body) || "{}")); } catch (e) { stt.bodies.push({}); }
770
+ const next = JSON.parse(JSON.stringify(v));
771
+ if (stt.bodies[stt.bodies.length - 1].patch) Object.assign(next.value, stt.bodies[stt.bodies.length - 1].patch);
772
+ return json({ ok: true, value: next.value, user: {}, trae: next.trae, qoder: next.qoder });
773
+ }
774
+ return json(JSON.parse(JSON.stringify(v)));
775
+ };
776
+ window.__swProbe = stt;
777
+ }, realView3));
778
+ const page3 = c3.page;
779
+ const pageErrors3 = c3.errors;
780
+ check("[C3] mock 通道可开", c3.opened);
781
+ const clicked = await page3.evaluate(() => {
782
+ const cb = document.querySelector('.cbc-acc-head input.cbc-check[data-field=traeEnabled]');
783
+ if (!cb) return false;
784
+ cb.click();
785
+ return true;
786
+ });
787
+ await sleep(1200);
788
+ const sw = await page3.evaluate(() => window.__swProbe);
789
+ check("[C3] 头部开关触发恰好 1 次 POST", clicked && sw.posts === 1, JSON.stringify({ clicked: clicked, posts: sw.posts }));
790
+ check("[C3] patch 只含 traeEnabled",
791
+ sw.bodies.length === 1 && sw.bodies[0].patch && Object.keys(sw.bodies[0].patch).length === 1
792
+ && "traeEnabled" in sw.bodies[0].patch, JSON.stringify(sw.bodies));
793
+ check("[C3] mock 通道 3 无 pageerror", pageErrors3.length === 0, pageErrors3.join(" | "));
794
+ await page3.close();
795
+ ```
796
+
797
+ - [x] **Step 2: 跑一次确认红**
798
+
799
+ Expected: `[C1]`–`[C3]` FAIL(头部还没有开关),退出码 1。
800
+
801
+ - [x] **Step 3: 在 `blocks` 构造里给 Trae/Qoder 传 `control`**
802
+
803
+ 在 Task 1 Step 6e 的 `blocks` map 内,`createElement(BlockHead, {...})` 的参数对象上加 `control`:
804
+
805
+ ```js
806
+ // 通道级启用开关:Trae/Qoder 的唯一落点(收起态也能一眼看状态并直接启停)。
807
+ // 头部是 div + 两个独立交互元素,不构成嵌套交互。
808
+ var channelSwitch = function (blockId, fieldKey) {
809
+ return createElement("span", null,
810
+ createElement("input", {
811
+ type: "checkbox", className: "cbc-check", "data-field": fieldKey,
812
+ checked: (data.value || {})[fieldKey] === true,
813
+ title: (data.value || {})[fieldKey] === true ? "停用该通道(其模型整体移出选择器)" : "启用该通道",
814
+ onChange: function (e) {
815
+ var p = {};
816
+ p[fieldKey] = e.target.checked;
817
+ save(p, blockId);
818
+ },
819
+ }),
820
+ createElement(ResetButton, { fieldKey: fieldKey, overridden: overriddenFor(data)(fieldKey), save: saveIn(blockId) }));
821
+ };
822
+ ```
823
+
824
+ `BlockHead` 调用处加:
825
+
826
+ ```js
827
+ control: def.id === "trae" ? channelSwitch("trae", "traeEnabled")
828
+ : def.id === "qoder" ? channelSwitch("qoder", "qoderEnabled")
829
+ : null,
830
+ ```
831
+
832
+ - [x] **Step 4: 删掉两个分区里的「启用通道」行**
833
+
834
+ `TraeSection`:删除 `createElement("div", { className: "cbc-row" }, createElement("div", { className: "cbc-row-label" }, "启用通道"), ...)` 整段(`:1628`–`:1635`,含其中的 `ResetButton`)。原行内那句"翻译网关已上线(:3902,运行中/未监听)"的信息不丢——它已由区块头状态行 + 网关组承载(Task 4 会在网关组补一行状态文字)。
835
+
836
+ `QoderSection`:同样删除「启用通道」整段(`:1897`–`:1904`)。
837
+
838
+ - [x] **Step 5: 跑回归确认绿**
839
+
840
+ Expected: 全部 ok(26 + 6 = 32 通过 / 0 失败)。
841
+
842
+ - [x] **Step 6: 哈希对账 + 提交**
843
+
844
+ ```bash
845
+ md5sum ~/.dsh/codebuddy-plugin.json # 必须与跑前一致
846
+ cd /c/Users/21613/dev/dsh-tap && git add lib/client.js
847
+ git commit -m "feat(client): Trae/Qoder 启用开关上移到区块头(收起态可直读可直控)"
848
+ ```
849
+
850
+ ---
851
+
852
+ ### Task 4: 通道内部分组(凭据 / 模型 / 工具 / 网关 / 高级)
853
+
854
+ **Files:**
855
+ - Modify: `lib/client.js` — `BridgeAdvancedSection`(`:1933`–`:1986`,拆成「网关」+「高级」两组)、`TraeSection` 的「连接域名」折叠组(`:1673`–`:1683`)、`QoderSection` 的「连接域名」折叠组(`:1916`–`:1927`)、`LoginSection`/`ModelsSection`/`ToolsSection` 的组标题
856
+ - Modify: `C:/Users/21613/dev/dsh-ui-test/card-accordion.js`
857
+
858
+ **Interfaces:**
859
+ - Consumes: 各分区组件现有 props(不变)
860
+ - Produces: DOM 契约 `.cbc-acc-body[data-block=X] .cbc-group-title` 文本序列;`高级` 组统一用 `<details className="cbc-adv">`(`summary` 文本 `高级`),与说明用的 `details.cbc-help`(`summary` 文本 `使用说明`)区分开
861
+
862
+ - [x] **Step 1: 追加失败断言**
863
+
864
+ ```js
865
+ // ---- [D] 通道内部分组:固定顺序 + 高级折叠 ----
866
+ const groupTitles = async (pg, id) => pg.evaluate((bid) => {
867
+ const body = document.querySelector('.cbc-acc-body[data-block="' + bid + '"]');
868
+ return [...(body ? body.querySelectorAll(".cbc-group-title") : [])].map((x) => (x.textContent || "").trim());
869
+ }, id);
870
+ await openBlock(page, "codebuddy"); await sleep(1500);
871
+ const cbGroups = await groupTitles(page, "codebuddy");
872
+ check("[D1] CodeBuddy 分组顺序", JSON.stringify(cbGroups) === JSON.stringify(["凭据", "模型", "工具", "网关", "高级"]), JSON.stringify(cbGroups));
873
+ await openBlock(page, "qoder"); await sleep(1500);
874
+ const qGroups = await groupTitles(page, "qoder");
875
+ check("[D1] Qoder 分组顺序", JSON.stringify(qGroups) === JSON.stringify(["凭据", "模型", "网关", "高级"]), JSON.stringify(qGroups));
876
+ const tGroups = await groupTitles(page, "trae");
877
+ check("[D1] Trae 分组顺序", JSON.stringify(tGroups) === JSON.stringify(["凭据", "模型", "网关", "高级"]), JSON.stringify(tGroups));
878
+
879
+ const advState = async (pg, id) => pg.evaluate((bid) => {
880
+ const d = document.querySelector('.cbc-acc-body[data-block="' + bid + '"] details.cbc-adv');
881
+ if (!d) return null;
882
+ return { open: d.open, text: (d.textContent || "").replace(/\s+/g, " ").slice(0, 200) };
883
+ }, id);
884
+ const cbAdv = await advState(page, "codebuddy");
885
+ check("[D2] CodeBuddy 高级组默认折叠且含网关地址", !!cbAdv && cbAdv.open === false && /baseURL|网关地址|copilot/.test(cbAdv.text), JSON.stringify(cbAdv));
886
+ const qAdv = await advState(page, "qoder");
887
+ check("[D2] Qoder 高级组含四个域名/client_id 字段", !!qAdv && /qoder|client_id|infer|openapi/i.test(qAdv.text), JSON.stringify(qAdv));
888
+ const tAdv = await advState(page, "trae");
889
+ check("[D2] Trae 高级组含认证/聊天/登录域", !!tAdv && /trae|认证|登录域/i.test(tAdv.text), JSON.stringify(tAdv));
890
+ // [D3] 工程项不再与日常项同层:端口在网关组、域名在高级组
891
+ const loc = await page.evaluate(() => {
892
+ const body = document.querySelector('.cbc-acc-body[data-block=trae]');
893
+ const rows = [...body.querySelectorAll(".cbc-row")];
894
+ const findRow = (label) => rows.find((r) => ((r.querySelector(".cbc-row-label") || {}).textContent || "").trim() === label);
895
+ const portRow = findRow("网关端口");
896
+ return { hasPortRow: !!portRow };
897
+ });
898
+ check("[D3] Trae 网关组仍有「网关端口」行", loc.hasPortRow, JSON.stringify(loc));
899
+ await page.screenshot({ path: "shots/acc-task4-groups.png" });
900
+ ```
901
+
902
+ - [x] **Step 2: 跑一次确认红**
903
+
904
+ Expected: `[D1]`–`[D3]` FAIL(分组标题与 `details.cbc-adv` 尚不存在)。
905
+
906
+ - [x] **Step 3: 加 CSS 与各分区的组标题**
907
+
908
+ CSS 追加:
909
+
910
+ ```js
911
+ ".cbc-adv{margin:10px 0 2px}",
912
+ ".cbc-adv>summary{cursor:pointer;font-size:12px;font-weight:600;color:var(--dsw-alias-label-tertiary,gray);list-style:none;display:inline-flex;align-items:center;gap:4px;user-select:none}",
913
+ ".cbc-adv>summary::-webkit-details-marker{display:none}",
914
+ ".cbc-adv>summary:after{content:\"▸\";font-size:10px}",
915
+ ".cbc-adv[open]>summary:after{content:\"▾\"}",
916
+ ".cbc-adv>summary:hover{color:var(--dsw-alias-label-primary,inherit)}",
917
+ ".cbc-advbody{margin-top:6px}",
918
+ ```
919
+
920
+ 各分区在返回体的最前面插入组标题(`createElement("p", { className: "cbc-group-title" }, "凭据")` 等):
921
+
922
+ - `LoginSection`(锚点 `return createElement("div", { className: "cbc-section" }, modeRow, body);`)→ 改为 `return createElement("div", { className: "cbc-section" }, createElement("p", { className: "cbc-group-title" }, "凭据"), modeRow, body);`
923
+ - `ModelsSection`(锚点 `return createElement("div", { className: "cbc-section" },` 后紧跟「目录同步」行)→ 在最前面插 `createElement("p", { className: "cbc-group-title" }, "模型"),`
924
+ - `ToolsSection`(锚点 `createElement("p", { className: "cbc-group-title" }, "网络搜索与抓取")`)→ 把它替换为两条:`createElement("p", { className: "cbc-group-title" }, "工具"), createElement("p", { className: "cbc-hint" }, "网络搜索与抓取"),`(后半段的 `"图像生成"` 小标题同样降为 `cbc-hint`,避免与一级组标题同级)
925
+ - `TraeSection` / `QoderSection`:返回体最前插 `createElement("p", { className: "cbc-group-title" }, "凭据"),`;在模型启停组之前插 `createElement("p", { className: "cbc-group-title" }, "模型"),`(`tmodelRows`/`qmodelRows` 之前);在传输/端口行之前插 `createElement("p", { className: "cbc-group-title" }, "网关"),`
926
+ - `UsageSection`:返回体最前插 `createElement("p", { className: "cbc-group-title" }, "额度与用量"),`
927
+ - `ProvidersSection`:返回体最前插 `createElement("p", { className: "cbc-group-title" }, "服务商"),`
928
+
929
+ - [x] **Step 4: 拆 `BridgeAdvancedSection` 为「网关」+「高级」**
930
+
931
+ 把函数体重排为(保留全部字段与 `ResetButton`,只改分组与容器):
932
+
933
+ ```js
934
+ function BridgeAdvancedSection(props) {
935
+ var value = props.value;
936
+ var save = props.save;
937
+ var overridden = props.overridden;
938
+ var bridgeView = props.bridgeView || {};
939
+ return createElement("div", { className: "cbc-section" },
940
+ createElement("p", { className: "cbc-group-title" }, "网关"),
941
+ // 流式桥开关行(原文案与 ResetButton 不变)
942
+ /* …bridgeEnabled 行、端口行、会话归因行、会话头格式行、并发上限行原样… */
943
+ createElement(HelpNote, null, "桥统一解析凭据(凭据组选 OAuth 或 Key),主聊天也经由此桥。同一会话超过上限的请求排队(FIFO);无会话 id 的请求不限流。非流式入站聚合成标准 JSON,chat/completions 之外的请求直接透传。"),
944
+ createElement("details", { className: "cbc-adv" },
945
+ createElement("summary", null, "高级"),
946
+ createElement("div", { className: "cbc-advbody" },
947
+ /* …原「高级」组的网关地址 baseURL 行 + 一行 hint 原样搬进来… */)));
948
+ }
949
+ ```
950
+
951
+ (`/* … */` 处是把现有 `:1940`–`:1976` 的 5 个 `cbc-row` 与 `:1980`–`:1984` 的 baseURL 行**原样剪切**过来,不改字段、不改文案、不改 `ResetButton`;删掉中间的 `hr.cbc-divider` 与旧的 `"流式桥"`/`"高级"` 两个 `cbc-group-title`。)
952
+
953
+ - [x] **Step 5: Trae/Qoder 的域名组统一为 `details.cbc-adv`**
954
+
955
+ `TraeSection`:把「连接域名」那一行 `cbc-toggle` 按钮 + `advOpen ? ... : null` 的两段(`:1673`–`:1683`)替换为:
956
+
957
+ ```js
958
+ createElement("details", { className: "cbc-adv" },
959
+ createElement("summary", null, "高级"),
960
+ createElement("div", { className: "cbc-advbody" },
961
+ createElement(TextField, { fieldKey: "traeAuthBaseURL", value: value.traeAuthBaseURL, save: save, widthClass: "cbc-w220", overridden: overridden("traeAuthBaseURL") }),
962
+ createElement(TextField, { fieldKey: "traeChatBaseURL", value: value.traeChatBaseURL, save: save, widthClass: "cbc-w220", overridden: overridden("traeChatBaseURL") }),
963
+ createElement(TextField, { fieldKey: "traeLoginHost", value: value.traeLoginHost, save: save, widthClass: "cbc-w220", overridden: overridden("traeLoginHost") })))),
964
+ ```
965
+
966
+ 同时删掉 `advOpenState`/`advOpen`/`setAdvOpen`(`details` 自带状态,零 JS——与 `HelpNote` 同纪律)。
967
+
968
+ `QoderSection`:同样替换 `:1916`–`:1927`,四个字段 `qoderLoginHost`/`qoderOpenapiBaseURL`/`qoderInferBaseURL`/`qoderClientId` 原样搬进 `details.cbc-adv`,删掉 `advOpenState`/`advOpen`/`setAdvOpen`。
969
+
970
+ - [x] **Step 5b: 网关组补失败原因出口(Task 3 审查 Minor 2 路由至此)**
971
+
972
+ Trae/Qoder 的网关组内加一行状态文字,取 `props.trae.bridge` / `props.qoder.bridge` 的 `running`/`port`/`lastError`:未监听时把 `lastError`(如 `EADDRINUSE`)显示出来——踩坑 #7「错误提示要带原因」,Task 3 删「启用通道」行后它是 :3902/:3903 失败原因的唯一可能出口。口径照 CodeBuddy 流式桥行(`bridgeView.lastError` 那段)写。
973
+
974
+ 断言 `[D4]`:mock 视图给 `trae.bridge = {running:false, port:null, lastError:"mock-eaddrinuse"}`(qoder 同),展开 Trae/Qoder 后其网关组文案含 `mock-eaddrinuse`。
975
+
976
+ - [x] **Step 6: 跑回归确认绿**
977
+
978
+ Expected: 全部 ok(32 + 7 = 39 通过 / 0 失败)。
979
+
980
+ - [x] **Step 7: 提交**
981
+
982
+ ```bash
983
+ cd /c/Users/21613/dev/dsh-tap && git add lib/client.js
984
+ git commit -m "feat(client): 通道内分组(凭据/模型/工具/网关/高级),工程项收进 details.cbc-adv"
985
+ ```
986
+
987
+ ---
988
+
989
+ ### Task 5: 模型组统一(同步按钮合并 + 三家都有筛选框)
990
+
991
+ **Files:**
992
+ - Modify: `lib/client.js` — `ModelsSection` 的「目录同步」行(`:1281`–`:1291`,两个按钮 → 一个)、`TraeSection` 的「模型目录」行(`:1646`–`:1650`)+ 模型组加筛选、`QoderSection` 的「模型目录」行(`:1905`–`:1909`)+ 模型组加筛选
993
+ - Modify: `C:/Users/21613/dev/dsh-ui-test/card-accordion.js`
994
+
995
+ **Interfaces:**
996
+ - Consumes: `post({action:'model-sync'})` / `post({action:'model-list'})` / `post({action:'trae-model-sync'})` / `post({action:'trae-model-list'})` / `post({action:'qoder-model-sync'})` / `post({action:'qoder-model-list'})`(全部现有 action,不改)
997
+ - Produces: DOM 契约 `.cbc-acc-body[data-block=X] .cbc-syncbar`(操作条:1 个 `button` + `.cbc-muted` 状态文字 + 筛选 `input[placeholder*="过滤"]`)
998
+
999
+ - [x] **Step 1: 追加失败断言**
1000
+
1001
+ ```js
1002
+ // ---- [E] 模型组统一 ----
1003
+ await ensureOpen(page, "codebuddy"); await ensureOpen(page, "trae"); await ensureOpen(page, "qoder");
1004
+ await sleep(1500);
1005
+ const syncBar = async (pg, id) => pg.evaluate((bid) => {
1006
+ const bar = document.querySelector('.cbc-acc-body[data-block="' + bid + '"] .cbc-syncbar');
1007
+ if (!bar) return null;
1008
+ return {
1009
+ buttons: [...bar.querySelectorAll("button")].map((b) => (b.textContent || "").trim()),
1010
+ hasFilter: !!bar.querySelector('input[placeholder*="过滤"]'),
1011
+ text: (bar.textContent || "").replace(/\s+/g, " ").trim(),
1012
+ };
1013
+ }, id);
1014
+ const cbBar = await syncBar(page, "codebuddy");
1015
+ check("[E1] CodeBuddy 只有一个同步按钮,文案「同步目录」",
1016
+ !!cbBar && cbBar.buttons.length === 1 && cbBar.buttons[0] === "同步目录", JSON.stringify(cbBar));
1017
+ check("[E1] CodeBuddy 操作条含筛选框", !!cbBar && cbBar.hasFilter, JSON.stringify(cbBar));
1018
+ const qBar = await syncBar(page, "qoder");
1019
+ check("[E1] Qoder 操作条同构(1 按钮 + 筛选)",
1020
+ !!qBar && qBar.buttons.length === 1 && qBar.buttons[0] === "同步目录" && qBar.hasFilter, JSON.stringify(qBar));
1021
+ const tBar = await syncBar(page, "trae");
1022
+ check("[E1] Trae 操作条同构(1 按钮 + 筛选)",
1023
+ !!tBar && tBar.buttons.length === 1 && tBar.buttons[0] === "同步目录" && tBar.hasFilter, JSON.stringify(tBar));
1024
+
1025
+ // [E2] 点「同步目录」= 先 model-sync 后 model-list(mock 记 POST 序列,零真实写入)
1026
+ const realView4 = await getView(page);
1027
+ const e2 = await newPage((pg) => pg.evaluateOnNewDocument((v) => {
1028
+ const stt = { actions: [] };
1029
+ const orig = window.fetch;
1030
+ const json = (o) => Promise.resolve(new Response(JSON.stringify(o), { status: 200, headers: { "content-type": "application/json" } }));
1031
+ window.fetch = function (input, init) {
1032
+ const url = typeof input === "string" ? input : (input && input.url) || "";
1033
+ const method = String((init && init.method) || (typeof input !== "string" && input && input.method) || "GET").toUpperCase();
1034
+ if (url.indexOf("/dsh-tap/settings") === -1) return orig.apply(this, arguments);
1035
+ if (method === "POST") {
1036
+ let b = {};
1037
+ try { b = JSON.parse((init && init.body) || "{}"); } catch (e) { b = {}; }
1038
+ if (b.action) stt.actions.push(b.action);
1039
+ if (b.action === "model-sync") return json({ ok: true, sync: { ok: true, count: 24, kept: false } });
1040
+ if (b.action === "model-list") return json({ ok: true, catalog: { models: [] }, staticIds: [], effectiveIds: [], state: { disabled: {}, extra: {}, overrides: {} } });
1041
+ return json({ ok: true, value: v.value, user: {} });
1042
+ }
1043
+ return json(JSON.parse(JSON.stringify(v)));
1044
+ };
1045
+ window.__syncProbe = stt;
1046
+ }, realView4));
1047
+ const page4 = e2.page;
1048
+ check("[E2] mock 通道可开", e2.opened);
1049
+ await ensureOpen(page4, "codebuddy"); await sleep(2500);
1050
+ const syncClicked = await page4.evaluate(() => {
1051
+ const bar = document.querySelector('.cbc-acc-body[data-block=codebuddy] .cbc-syncbar');
1052
+ const btn = bar && [...bar.querySelectorAll("button")].find((b) => (b.textContent || "").trim() === "同步目录");
1053
+ if (!btn) return false;
1054
+ btn.click();
1055
+ return true;
1056
+ });
1057
+ await sleep(2000);
1058
+ const acts = await page4.evaluate(() => window.__syncProbe.actions);
1059
+ const iSync = acts.indexOf("model-sync"), iList = acts.indexOf("model-list");
1060
+ check("[E2] 点「同步目录」触发 model-sync 与 model-list",
1061
+ syncClicked && iSync >= 0 && iList >= 0, JSON.stringify(acts));
1062
+ check("[E2] 顺序 = 先 sync 后 list", iSync >= 0 && iList > iSync, JSON.stringify(acts));
1063
+ check("[E2] 不重复触发(各恰好一次)",
1064
+ acts.filter((a) => a === "model-sync").length === 1 && acts.filter((a) => a === "model-list").length === 1,
1065
+ JSON.stringify(acts));
1066
+ await page4.close();
1067
+ ```
1068
+
1069
+ - [x] **Step 2: 跑一次确认红**
1070
+
1071
+ Expected: `[E1]`/`[E2]` FAIL(`.cbc-syncbar` 不存在;CodeBuddy 仍是两个按钮)。
1072
+
1073
+ - [x] **Step 3: CodeBuddy 模型组——两按钮合并 + 操作条容器**
1074
+
1075
+ 把 `ModelsSection` 的「目录同步」行与「筛选」行(`:1281`–`:1291`)合并成一个 `.cbc-syncbar`:
1076
+
1077
+ ```js
1078
+ // 「同步目录」= 一次点按做两件事:拉网关目录并铺镜像(model-sync)、
1079
+ // 刷新管理列表(model-list)。原先是两个按钮,语义重叠、用户要判断点哪个。
1080
+ var syncBusyState = useState(false);
1081
+ var syncBusy = syncBusyState[0];
1082
+ var setSyncBusy = syncBusyState[1];
1083
+ var syncNow = function () {
1084
+ setSyncBusy(true);
1085
+ post({ action: "model-sync" }).then(function (res) {
1086
+ if (!res.ok) { setSyncBusy(false); setErr(res.d && res.d.error ? res.d.error : "同步失败"); return; }
1087
+ if (res.d.sync && res.d.sync.ok === false) setErr("目录同步失败(已" + (res.d.sync.kept ? "保留上次清单" : "回落静态清单") + "):" + res.d.sync.error);
1088
+ reload();
1089
+ // 目录同步失败也要刷新管理列表——两侧原因分别可见,不互相掩盖。
1090
+ return post({ action: "model-list" }).then(function (r2) {
1091
+ setSyncBusy(false);
1092
+ if (r2.ok) setData(r2.d);
1093
+ else setErr(r2.d && r2.d.error ? r2.d.error : "获取失败");
1094
+ });
1095
+ }).catch(function (e) { setSyncBusy(false); setErr(e && e.message ? e.message : "同步失败(网络)"); });
1096
+ };
1097
+ ```
1098
+
1099
+ (删掉原有的独立 `fetchList` 里的 `setBusy` 双态:`busy`/`setBusy` 仅保留给首次挂载拉取用;`syncNow` 用 `syncBusy`。)
1100
+
1101
+ 返回体里那两行换成:
1102
+
1103
+ ```js
1104
+ createElement("div", { className: "cbc-syncbar" },
1105
+ createElement(CbcButton, { variant: "outline", icon: IconRefresh ? createElement(IconRefresh, { size: 14 }) : undefined, disabled: syncBusy, onClick: syncNow }, syncBusy ? "同步中…" : "同步目录"),
1106
+ createElement("span", { className: "cbc-muted" }, syncText),
1107
+ createElement(CbcInput, { widthClass: "cbc-w220", placeholder: "输入 id 关键字过滤模型…", value: filter, onInput: function (e) { setFilter(e.target.value); } })),
1108
+ ```
1109
+
1110
+ CSS 追加:
1111
+
1112
+ ```js
1113
+ ".cbc-syncbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:4px 0 8px}",
1114
+ ```
1115
+
1116
+ - [x] **Step 4: Trae/Qoder 模型组同构(加 `.cbc-syncbar` + 筛选)**
1117
+
1118
+ 两个分区各加一个 `filter` 本地态与同款操作条。`TraeSection`(把「模型目录」行 `:1646`–`:1650` 换掉,并在 `tmodelRows` 的列表渲染前按 filter 过滤):
1119
+
1120
+ ```js
1121
+ var tfilterState = useState("");
1122
+ var tfilter = tfilterState[0];
1123
+ var setTfilter = tfilterState[1];
1124
+ ```
1125
+
1126
+ ```js
1127
+ createElement("div", { className: "cbc-syncbar" },
1128
+ createElement(CbcButton, { variant: "outline", icon: IconRefresh ? createElement(IconRefresh, { size: 14 }) : undefined, disabled: busy, onClick: syncModels }, busy ? "同步中…" : "同步目录"),
1129
+ createElement("span", { className: "cbc-muted" }, syncInfo),
1130
+ createElement(CbcInput, { widthClass: "cbc-w220", placeholder: "输入 id 关键字过滤模型…", value: tfilter, onInput: function (e) { setTfilter(e.target.value); } })),
1131
+ ```
1132
+
1133
+ `tmodelRows` 里的 `tlist.profiles.map(...)` 改为先过滤:
1134
+
1135
+ ```js
1136
+ var tneedle = tfilter.trim().toLowerCase();
1137
+ var tshown = tlist.profiles.filter(function (p) {
1138
+ if (!tneedle) return true;
1139
+ return String(p.id).toLowerCase().indexOf(tneedle) >= 0
1140
+ || (p.name && String(p.name).toLowerCase().indexOf(tneedle) >= 0);
1141
+ });
1142
+ ```
1143
+
1144
+ (`tshown.map(...)` 替代 `tlist.profiles.map(...)`;组标题的计数仍用全量 `tlist.profiles.length` 与 `enabledCount`,不受筛选影响。)
1145
+
1146
+ `QoderSection` 同款:加 `qfilter`/`setQfilter`,把「模型目录」行(`:1905`–`:1909`)换成 `.cbc-syncbar`(按钮文案「同步目录」、`onClick: syncModels`、状态文字 `syncInfo`、筛选框),`qlist.profiles.map` 前加同款过滤得 `qshown`。
1147
+
1148
+ **注意**:Trae/Qoder 的操作条要在「通道未启用」时也渲染(现在模型组整体在 `value.xxxEnabled === true` 才出现)——把 `.cbc-syncbar` 提到该条件之外,只有**列表**部分保留条件;未启用时状态文字后面追加一句「(通道未启用)」,避免用户对着一个不生效的按钮发愣。
1149
+
1150
+ - [x] **Step 4b: 模型子标题降一级 + [D1] 收紧 + 高级 summary 补 title + adv 输入计数(Task 4 审查 Minor 2/3/4/5 路由至此)**
1151
+
1152
+ 1. 新增 CSS `.cbc-subtitle{font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary,inherit);margin:8px 0 4px}`;把模型列表的子标题(ModelsSection 的「当前可用(N)」「未启用(N)」、Trae/Qoder 的「X 模型(选择器内 n/m)」)从 `.cbc-group-title` 改为 `.cbc-subtitle`——一级序列契约(`.cbc-group-title` = 凭据/模型/工具/网关/高级)由此恢复逐字成立,视觉层级也不再与一级标题平级。
1153
+ 2. `[D1]` 由 isSubsequence 收紧为**直接子元素逐字全等**:`.cbc-section > .cbc-group-title` 与 `.cbc-section > details.cbc-adv > summary` 的并集序列 == 期望序列(乱序/缺组/一级标题进错分区都必须红)。
1154
+ 3. 两处 `details.cbc-adv` 的 `summary` 加 `title` 属性复述旧折叠按钮携带的提示(Trae:「认证 / 聊天 / 登录域」;Qoder:「登录域 / OpenAPI / infer / client_id」);可见文本仍为「高级」,断言不变。
1155
+ 4. `[D2]` 补输入计数断言:qoder 的 `details.cbc-adv input` 数 === 4、codebuddy 的 === 1(trae 的 === 3 已有)。
1156
+
1157
+ - [x] **Step 5: 跑回归确认绿**
1158
+
1159
+ Expected: 全部 ok(Task 4 收尾 56 通过 + 本任务 [E1]×2 + [E2]×4 + Step 4b [D2]×2 = 64 通过 / 0 失败;实际数目若不符,须在报告里解释差额)。
1160
+
1161
+ - [x] **Step 6: 提交**
1162
+
1163
+ ```bash
1164
+ cd /c/Users/21613/dev/dsh-tap && git add lib/client.js
1165
+ git commit -m "feat(client): 三家模型组同构——「同步目录」单按钮 + 统一筛选框"
1166
+ ```
1167
+
1168
+ ---
1169
+
1170
+ ### Task 6: 通用区块头取样(额度 / 服务商数)+ 轮询随展开
1171
+
1172
+ **Files:**
1173
+ - Modify: `lib/client.js` — `CodeBuddyCard`(挂载取样 + `generalText` 传入 `blockStatus`)、`UsageSection`(`active` 语义已是"区块展开",Task 1 已接;此处补首次取样与卡片级共享)、`ProvidersSection`(`credential-scan` 保持在分区挂载时)
1174
+ - Modify: `C:/Users/21613/dev/dsh-ui-test/card-accordion.js`
1175
+
1176
+ **Interfaces:**
1177
+ - Consumes: `post({action:'usage'})` → `{ok,usage,bridge,quota}`;`post({action:'provider-list'})` → `{ok,providers,presets}`;`blockStatus(data, generalText)`
1178
+ - Produces: `generalSummary(usageRes, providersRes)` → `string`(区块头文案);`useGeneralSample(post)` → `{text, usage, providers}`(卡片级一次性取样,不轮询)
1179
+
1180
+ - [x] **Step 1: 追加失败断言**
1181
+
1182
+ **变量名裁定(落盘时改,其余逐字照抄)**:下面代码里的 `page5` / `pageErrors5` / `page6` 与 Task 5 已落地的 `[E2]` 组 `const page5`(`card-accordion.js:516`)**同作用域重名**,直接落盘会 SyntaxError ⇒ 统一改名为 `pageF1` / `pageErrorsF1` / `pageF6`。
1183
+
1184
+ ```js
1185
+ // ---- [F] 通用区块头取样 + 轮询随展开 ----
1186
+ const realView5 = await getView(page);
1187
+ const f1 = await newPage((pg) => pg.evaluateOnNewDocument((v) => {
1188
+ const stt = { usage: 0, providerList: 0, credentialScan: 0, log: [] };
1189
+ const orig = window.fetch;
1190
+ const json = (o) => Promise.resolve(new Response(JSON.stringify(o), { status: 200, headers: { "content-type": "application/json" } }));
1191
+ window.fetch = function (input, init) {
1192
+ const url = typeof input === "string" ? input : (input && input.url) || "";
1193
+ const method = String((init && init.method) || (typeof input !== "string" && input && input.method) || "GET").toUpperCase();
1194
+ if (url.indexOf("/dsh-tap/settings") === -1) return orig.apply(this, arguments);
1195
+ if (method === "POST") {
1196
+ let b = {};
1197
+ try { b = JSON.parse((init && init.body) || "{}"); } catch (e) { b = {}; }
1198
+ stt.log.push(b.action || (b.patch ? "patch" : "?"));
1199
+ if (b.action === "usage") {
1200
+ stt.usage++;
1201
+ return json({
1202
+ ok: true,
1203
+ usage: { today: { credit: 1.5, requests: 3 }, totalCredit: 42, totalRequests: 90, since: Date.now() - 86400000, turns: [] },
1204
+ bridge: { enabled: true, running: true, port: 3901, lastError: null },
1205
+ quota: { numericQuota: true, resource: { totalRemain: 1879.5, cycleRemain: 1200.25, cycleSize: 2000, cycleUsed: 799.75, packs: [] }, account: { nickname: "mock" } },
1206
+ });
1207
+ }
1208
+ if (b.action === "provider-list") { stt.providerList++; return json({ ok: true, providers: [{ id: "ark", displayName: "火山方舟", modelCount: 3, maskedKey: "x…y", keyRef: "ARK_API_KEY", baseURL: "https://ark.cn-hangzhou.volces.com/api/v3" }], presets: [] }); }
1209
+ if (b.action === "credential-scan") { stt.credentialScan++; return json({ ok: true, findings: [] }); }
1210
+ return json({ ok: true, value: v.value, user: {} });
1211
+ }
1212
+ return json(JSON.parse(JSON.stringify(v)));
1213
+ };
1214
+ window.__genProbe = stt;
1215
+ }, realView5));
1216
+ const page5 = f1.page;
1217
+ const pageErrors5 = f1.errors;
1218
+ check("[F1] mock 通道可开", f1.opened);
1219
+
1220
+ let gen = await page5.evaluate(() => window.__genProbe);
1221
+ check("[F1] 挂载即取样:usage 与 provider-list 各恰好一次",
1222
+ gen.usage === 1 && gen.providerList === 1, JSON.stringify(gen.log));
1223
+ check("[F1] 挂载时不做本机凭据扫描(不上移)", gen.credentialScan === 0, JSON.stringify(gen.log));
1224
+ const genHead = await page5.evaluate(() =>
1225
+ ((document.querySelector('.cbc-acc[data-block=general] .cbc-acc-status') || {}).textContent || "").trim());
1226
+ check("[F2] 通用区块头显示额度与服务商数",
1227
+ /1200\.25|1,?200/.test(genHead) && /服务商 1/.test(genHead), JSON.stringify(genHead));
1228
+ await sleep(12000);
1229
+ gen = await page5.evaluate(() => window.__genProbe);
1230
+ check("[F3] 收起状态不轮询(12s 内 usage 仍为 1)", gen.usage === 1, JSON.stringify(gen.log));
1231
+
1232
+ await openBlock(page5, "general"); await sleep(13000);
1233
+ gen = await page5.evaluate(() => window.__genProbe);
1234
+ check("[F4] 展开后进入 10s 轮询(usage ≥ 2)", gen.usage >= 2, JSON.stringify(gen.log));
1235
+ check("[F4] 展开时才做本机凭据扫描", gen.credentialScan === 1, JSON.stringify(gen.log));
1236
+ await openBlock(page5, "general"); await sleep(1500);
1237
+ const usageBefore = (await page5.evaluate(() => window.__genProbe)).usage;
1238
+ await sleep(12000);
1239
+ const usageAfter = (await page5.evaluate(() => window.__genProbe)).usage;
1240
+ check("[F5] 收起后停止轮询(12s 内无新增)", usageAfter === usageBefore,
1241
+ JSON.stringify({ usageBefore: usageBefore, usageAfter: usageAfter }));
1242
+ check("[F5] mock 通道 5 无 pageerror", pageErrors5.length === 0, pageErrors5.join(" | "));
1243
+ await page5.screenshot({ path: "shots/acc-task6-general.png" });
1244
+ await page5.close();
1245
+
1246
+ // [F6] api-key 模式降级:无 numericQuota 时不编造数字
1247
+ const realView6 = await getView(page);
1248
+ const f6 = await newPage((pg) => pg.evaluateOnNewDocument((v) => {
1249
+ const orig = window.fetch;
1250
+ const json = (o) => Promise.resolve(new Response(JSON.stringify(o), { status: 200, headers: { "content-type": "application/json" } }));
1251
+ window.fetch = function (input, init) {
1252
+ const url = typeof input === "string" ? input : (input && input.url) || "";
1253
+ const method = String((init && init.method) || (typeof input !== "string" && input && input.method) || "GET").toUpperCase();
1254
+ if (url.indexOf("/dsh-tap/settings") === -1) return orig.apply(this, arguments);
1255
+ if (method === "POST") {
1256
+ let b = {};
1257
+ try { b = JSON.parse((init && init.body) || "{}"); } catch (e) { b = {}; }
1258
+ if (b.action === "usage") {
1259
+ return json({ ok: true, usage: { today: { credit: 0, requests: 0 }, totalCredit: 7, totalRequests: 2, since: Date.now(), turns: [] }, bridge: { enabled: true, running: true, port: 3901 }, quota: { numericQuota: false, error: "api-key 模式无数值额度" } });
1260
+ }
1261
+ if (b.action === "provider-list") return json({ ok: true, providers: [], presets: [] });
1262
+ if (b.action === "credential-scan") return json({ ok: true, findings: [] });
1263
+ return json({ ok: true, value: v.value, user: {} });
1264
+ }
1265
+ return json(JSON.parse(JSON.stringify(v)));
1266
+ };
1267
+ }, realView6));
1268
+ const page6 = f6.page;
1269
+ check("[F6] mock 通道可开", f6.opened);
1270
+ const genHead6 = await page6.evaluate(() =>
1271
+ ((document.querySelector('.cbc-acc[data-block=general] .cbc-acc-status') || {}).textContent || "").trim());
1272
+ check("[F6] api-key 模式头部标注「估算」且不编造数字",
1273
+ /估算/.test(genHead6) && !/credit/.test(genHead6.replace(/估算/g, "")), JSON.stringify(genHead6));
1274
+ await page6.close();
1275
+ ```
1276
+
1277
+ - [x] **Step 2: 跑一次确认红**
1278
+
1279
+ Expected: `[F1]`–`[F6]` FAIL(挂载时没有取样,通用头部还是占位文案)。
1280
+
1281
+ - [x] **Step 3: 卡片级一次性取样 + `generalText`**
1282
+
1283
+ 在 `CodeBuddyCard` 里(`load` 之后)加:
1284
+
1285
+ ```js
1286
+ // 通用区块头的「额度 / 服务商数」不在 GET 视图里(额度来自 action:'usage'、
1287
+ // 服务商数来自 provider-list)⇒ 卡片挂载时各取样一次(不轮询);通用区块
1288
+ // 展开后才进入 UsageSection 自己的 10s 轮询。credential-scan 不上移——它扫
1289
+ // 本机文件,仍只在通用区块首次挂载时做。
1290
+ var genSampleState = useState(null); // { text }
1291
+ var genSample = genSampleState[0];
1292
+ var setGenSample = genSampleState[1];
1293
+ useEffect(function () {
1294
+ var cancelled = false;
1295
+ Promise.all([
1296
+ post({ action: "usage" }).catch(function () { return null; }),
1297
+ post({ action: "provider-list" }).catch(function () { return null; }),
1298
+ ]).then(function (res) {
1299
+ if (cancelled) return;
1300
+ setGenSample({ text: generalSummary(res[0], res[1]) });
1301
+ });
1302
+ return function () { cancelled = true; };
1303
+ }, []);
1304
+ ```
1305
+
1306
+ `blockStatus(data, null)` 调用处改为 `blockStatus(data, genSample ? genSample.text : null)`。
1307
+
1308
+ 在 `blockStatus` 附近加纯函数:
1309
+
1310
+ ```js
1311
+ // 通用区块头文案:额度(OAuth = 真实周期余量;api-key = 标注估算,不编数字)
1312
+ // + 服务商数。取样失败时回落到无数字文案,绝不显示假值。
1313
+ function generalSummary(usageRes, providersRes) {
1314
+ var parts = [];
1315
+ var q = usageRes && usageRes.ok ? usageRes.d && usageRes.d.quota : null;
1316
+ var u = usageRes && usageRes.ok ? usageRes.d && usageRes.d.usage : null;
1317
+ if (q && q.numericQuota && q.resource && q.resource.cycleRemain != null) {
1318
+ parts.push("额度 " + fmtCredit(q.resource.cycleRemain) + " credit");
1319
+ } else if (u && typeof u.totalCredit === "number") {
1320
+ parts.push("额度 估算(累计 " + fmtCredit(u.totalCredit) + ")");
1321
+ } else {
1322
+ parts.push("额度 —");
1323
+ }
1324
+ var list = providersRes && providersRes.ok && providersRes.d ? providersRes.d.providers : null;
1325
+ parts.push("服务商 " + (list ? list.length : "—"));
1326
+ return parts.join(" · ");
1327
+ }
1328
+ ```
1329
+
1330
+ **注意 `post()` 的返回形状**:现有 `post` 解析成 `{ok, status, d}`,所以取样读 `res.d.quota` / `res.d.providers`(上面代码已按此写)。
1331
+
1332
+ - [x] **Step 4: 确认 `UsageSection` 的轮询条件已是"区块展开"**
1333
+
1334
+ Task 1 已把 `usage` 的 `active` 接到 `!!openBlocks.general`;本步只做核对:`UsageSection` 内 `useEffect(..., [active])` 在 `active === false` 时 `return undefined`(不设 interval),且分区**只在通用区块首次展开时才挂载** ⇒ `[F4]`/`[F5]` 的语义成立。若发现挂载即轮询(`active` 默认 `true`),改为 `props.active === true`。
1335
+
1336
+ - [x] **Step 4b: general 一级标题契约对齐(控制器指派,承接 Task 5 的 `.cbc-subtitle` 裁定)**
1337
+
1338
+ 1. `UsageSection` 里两处**子标题**从 `.cbc-group-title` 降为 `.cbc-subtitle`:资源包表头(`grep -n 'key: "pkh"' lib/client.js`)与「最近轮次(按间隔聚类,近似)」(`grep -n '最近轮次' lib/client.js`)。general 的一级标题只留「额度与用量」(`UsageSection`)与「服务商」(`ProvidersSection`)。
1339
+ 2. 套件追加 `[F7]` **两条**断言:`.cbc-acc-body[data-block=general]` 内每个 `.cbc-section` 的**直接子级** `.cbc-group-title` 文本序列逐字全等——`UsageSection` === `["额度与用量"]`、`ProvidersSection` === `["服务商"]`。选择器不中时必须红(不许 `[].every()` 型恒真)。general 是懒挂载,**复用 `[F4]` 已展开的 `pageF1`**(在 `[F5]` 收起之前读),别新开页面。
1340
+
1341
+ - [x] **Step 4c: 审查路由至此的加固(Task 6 审查 Minor 2/3/6/7/9)**
1342
+
1343
+ 1. `generalSummary`:`numericQuota` 为真但带 `resourceError`(分区自己会渲染「数值额度读取失败」)时,头部**不得**落进「估算(累计 …)」分支——`q.resourceError` 存在即出 `额度 —`。理由:已声明数值额度却读失败时给一个累计估算数字,语义偏(spec §3 的意图是不编数字)。
1344
+ 2. 卡片层取样的外层 `.catch` 补 `console.warn`(两个输入各自 `.catch(() => null)` ⇒ 外层只可能捕到 `generalSummary`/`setGenSample` 自己的 bug,静默置 null 会让头部永远停在占位文案且无线索,违踩坑 #7)。
1345
+ 3. `[F7]` 补一条**正向**断言封住"整段删掉子标题也绿"的洞:`.cbc-acc-body[data-block=general] .cbc-subtitle` 文本序列 === `["资源包(2)", "最近轮次(按间隔聚类,近似)"]`(与 [F1] mock 的 packs 条数联动)。
1346
+ 4. `[F6]` 的负向半句 `!/credit/.test(head.replace(/估算/g,""))` 里那个 `replace` 对 `/credit/` 是无效操作 ⇒ 改为钉**全等**:`额度 估算(累计 7.00) · 服务商 0`(正向 `/估算/` 半句保留,防回落文案假绿)。
1347
+ 5. `[F7]` 的两处内联 `JSON.stringify(…) === JSON.stringify(…)` 改用套件既有的 `sameSeq` helper。
1348
+ 6. 新增 `[F8]` mock 组(**独立 `newPage(installer)`**,主 page 不装 mock):`action:'usage'` 返回 `{ok:true, quota:{numericQuota:true, resourceError:"mock-quota-401"}, usage:{totalCredit:7,…}}`、`provider-list` 返回 1 条 ⇒ 断言头部含 `额度 —`、**不含** `估算`/`累计`/任何数字额度。这是 4c-1 的覆盖测试:改前必须红。两条断言(mock 通道可开 + 头部不编数字)。
1349
+
1350
+ - [x] **Step 5: 跑回归确认绿**
1351
+
1352
+ Expected: 全部 ok(Task 5 收尾 **70** 通过 + `[F1]`×3 + `[F2]`×1 + `[F3]`×1 + `[F4]`×2 + `[F5]`×2 + `[F6]`×2 + `[F7]`×3 + `[F8]`×2 = **86 通过 / 0 失败**;实际数目若不符须在报告里解释差额,**不许改断言凑数**)。注意 `[F3]`/`[F4]`/`[F5]` 合计约 37s 等待,整套耗时约 2 分钟。
1353
+
1354
+ - [x] **Step 6: 哈希对账 + 提交**
1355
+
1356
+ ```bash
1357
+ md5sum ~/.dsh/codebuddy-plugin.json # 必须与跑前一致
1358
+ cd /c/Users/21613/dev/dsh-tap && git add lib/client.js
1359
+ git commit -m "feat(client): 通用区块头挂载取样(额度/服务商数)+ 轮询严格随展开启停"
1360
+ ```
1361
+
1362
+ ---
1363
+
1364
+ ### Task 7: 存活断言移植 + 老套件退役
1365
+
1366
+ **Files:**
1367
+ - Modify: `C:/Users/21613/dev/dsh-ui-test/card-accordion.js`(移植 `card-regression.js` 中仍然成立的断言)
1368
+ - Delete: `C:/Users/21613/dev/dsh-ui-test/card-regression.js`
1369
+ - Modify: `lib/client.js`(仅当移植断言暴露真缺陷时才改;无缺陷则本任务零代码改动)
1370
+
1371
+ **Interfaces:**
1372
+ - Consumes: 前 6 个任务的全部 DOM 契约
1373
+ - Produces: `card-accordion.js` 成为设置卡唯一回归套件(承接 `card-regression.js` 的全部仍然有效的不变式)
1374
+
1375
+ - [x] **Step 1: 逐条移植(把 `card-regression.js` 里下列断言改选择器后追加到新套件)**
1376
+
1377
+ | 老断言(实测标签,共 14 组 28 条) | 移植后的选择器/做法 |
1378
+ |---|---|
1379
+ | `[1]` PM 入口可点 / 列出 dsh-tap 卡 + summary 行 | 新套件 `[0]` 已覆盖,补 summary 文案断言(`body.innerText` 含 `CodeBuddy / Trae / Qoder CN 通道`) |
1380
+ | `[2]` 标签栏 8 标签齐全 | **作废**(标签栏退役)→ 由 `[A1]` 四区块顺序 + 标题断言接替 |
1381
+ | `[3]` 注意条与预言机双向一致 | **作废**(注意条删除)→ 由 `[B1]`(无注意条)+ `[B2]`(状态行独立预言机双向对齐)+ `[B3]`(warn 落区块头)接替 |
1382
+ | `[4]` 模型标签可点 / models 面板可见 + login 隐藏 | 已由 `[A3]`/`[A4]`/`[A5]` 覆盖(展开才挂载、收起隐藏、多开独立),跳过 |
1383
+ | `[5]` 模型筛选(组标题计数 + 行全匹配) | 选择器换 `.cbc-acc-body[data-block=codebuddy] input[placeholder*="过滤"]`,组标题断言改查 `.cbc-group-title` 里「筛选命中 N(可用 x / 未启用 y)」 |
1384
+ | `[6]` 幽灵输入存在(`input[aria-label*="上限"]`) | 选择器前缀换 `.cbc-acc-body[data-block=codebuddy]` |
1385
+ | `[7]` HelpNote 折叠 / 展开 / 再收起 | 选择器换 `.cbc-acc-body[data-block=codebuddy] details.cbc-help summary` |
1386
+ | `[8]` 用量刷新按钮 + 「更新于 HH:MM:SS」时间戳 | 用 `newPage()` 的独立页面(不要复用 `page`——`[G1]` 依赖 general 区块处于收起态),`ensureOpen(pg,'general')` 后查 `.cbc-acc-body[data-block=general]` 内的刷新按钮与 `.cbc-updated` 文案 |
1387
+ | `[9]` 标签键盘导航(roving tabIndex / tabpanel / 方向键) | **删除**(tablist 已退役);替换为区块头键盘断言:`.cbc-acc-toggle` 可 focus、`aria-expanded` 随 Enter 翻转、`aria-controls` 指向存在的 `#cbc-block-<id>` |
1388
+ | `[10]` 浅色主题渲染 | `page.emulateMediaFeatures([{name:'prefers-color-scheme',value:'light'}])` 后重开卡截图,断言无 pageerror + 四区块仍在 |
1389
+ | `[11]`/`[13]` 无 pageerror | 每个 mock 通道各自断言(`[B4]`/`[C3]`/`[F5]` 已有),真实视图阶段补一条 |
1390
+ | `[12]` 启用+未监听 → warn 芯片带端口 | 已由 `[B3]` 覆盖(落到区块头),跳过 |
1391
+ | `[14]` 保存后退避补拉自愈 | 已由 `[B4]` 覆盖,跳过;**加固一行**:`[B4]` 的 after 断言补 `after.probe.posts === prePosts + 1` 复核,关死"mid→after 窗口内出现第二次保存导致自愈"的假说(Task 2 审查 Minor 1 路由至此) |
1392
+
1393
+ 新区块头键盘断言代码:
1394
+
1395
+ ```js
1396
+ // ---- [G] 区块头键盘可达性(接替老 [9] 的 tablist 断言)----
1397
+ const kb = await page.evaluate(() => {
1398
+ const t = document.querySelector('.cbc-acc-toggle[data-block=general]');
1399
+ if (!t) return null;
1400
+ t.focus();
1401
+ const before = t.getAttribute("aria-expanded");
1402
+ t.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
1403
+ return { focused: document.activeElement === t, before: before, controls: t.getAttribute("aria-controls") };
1404
+ });
1405
+ check("[G1] 区块头按钮可聚焦", !!kb && kb.focused, JSON.stringify(kb));
1406
+ check("[G1] 收起态 aria-expanded=false", !!kb && kb.before === "false", JSON.stringify(kb));
1407
+ await sleep(1500);
1408
+ const ctrlOk = await page.evaluate(() => {
1409
+ const t = document.querySelector('.cbc-acc-toggle[data-block=general]');
1410
+ const id = t && t.getAttribute("aria-controls");
1411
+ return { id: id, exists: !!id && !!document.getElementById(id), expanded: t && t.getAttribute("aria-expanded") };
1412
+ });
1413
+ check("[G1] aria-controls 指向真实存在的 body 且已翻转",
1414
+ ctrlOk.exists && ctrlOk.expanded === "true", JSON.stringify(ctrlOk));
1415
+ // 未挂载的区块不留空引用
1416
+ const dangling = await page.evaluate(() =>
1417
+ [...document.querySelectorAll(".cbc-acc-toggle")].filter((t) => {
1418
+ const id = t.getAttribute("aria-controls");
1419
+ return id && !document.getElementById(id);
1420
+ }).length);
1421
+ check("[G2] 无悬空 aria-controls", dangling === 0, "dangling=" + dangling);
1422
+ ```
1423
+
1424
+ - [x] **Step 1b: 套件卫生(Task 2/4/6 审查路由至此)**:① `[B4]` 的 after 断言补 `after.probe.posts === prePosts + 1` 复核(关死"mid→after 窗口内第二次保存导致自愈"的假说);② `card-accordion.js` 的 `pageerror` 采集里 `turnTail` 过滤加注释说明理由(宿主已知噪声)或收窄匹配面;③ **补 `console` 采集**——套件目前只监听 `pageerror`,而 Task 6 的 Step 4c-2 给取样失败加了 `console.warn("dsh-tap: …")` 却无回归锁:给主 page 与 `newPage` 开的每个 mock 页都挂 `console` 监听,**只收**匹配 `/dsh-tap:/` 前缀的 `warning`/`error` 级消息(宽匹配会被宿主噪声打成 flaky),在真实视图阶段与各 mock 组收尾各断言一次「无 dsh-tap 自身告警」。
1425
+
1426
+ - [x] **Step 1c: 抽 fetch-mock installer 工厂(Task 6 审查 Important 1,plan-mandated 债务)**
1427
+
1428
+ `card-accordion.js` 现有 **8 份**逐字复制的 fetch-mock 骨架(`:206`、`:235`、`:317`、`:435`、`:495`、`:572`、`:677`、`:712`,每份约 12 行:`/dsh-tap/settings` URL 过滤、method 推断、`json()` 构造 `Response`、GET 透传真实视图)。7~8 份各自独立变绿 = 分叉不可见,harness 级教训要改就得改 8 处 ⇒ 抽一个 Node 侧工厂,变化全部走**可序列化参数**:
1429
+
1430
+ ```js
1431
+ // mock 骨架只此一份。evaluateOnNewDocument 只能传可序列化实参 ⇒ 每组的差异
1432
+ // 用 plain object 描述(哪些 action 返回什么、要不要记 action 序列),在浏览器
1433
+ // 侧解释;不要试图把函数传进去。
1434
+ const mockInstaller = (realView, spec) => (pg) =>
1435
+ pg.evaluateOnNewDocument((v, s) => { /* 唯一一份 fetch 补丁 */ }, realView, spec);
1436
+ ```
1437
+
1438
+ 硬约束(逐条守住,否则断言会**静默退化**):
1439
+
1440
+ - **主 `page` 自始至终不装 mock**(既有纪律,工厂只用于 `newPage`)。
1441
+ - `[F8]` 的 `usage` 响应必须保留 `resource: null` + `resourceError` 的**配对**——少了 `resource:null`,「数值额度读失败时头部不编数字」会退化为空洞通过(Task 6 实现者交接)。
1442
+ - `[F6]` 的 mock 现用 `quota:{numericQuota:false, error:"api-key 模式无数值额度"}`,其中 `error` 是**非契约键**(真实 api-key 产物是 `{resource:null, resourceError:null}`)⇒ 抽工厂时改成契约形状,断言不变(仍须绿)。
1443
+ - `[F7]`#3 的期望文本 `资源包(2)` 与 `[F1]` mock 的 packs 条数 + `showAgg` 为假强耦合 ⇒ packs 条数若变,同步改期望文本,别让它静默失效。
1444
+ - 卡片挂载会先打 **2 个 POST**(`usage` + `provider-list`)⇒ 任何"绝对 POST 计数"断言一律用**增量基线**(`[B4]`/`[C3]`/`[E2]` 已是增量式,不许退回绝对式)。
1445
+ - general 区块头文案已**动态化**(取样落地后才有数字)⇒ 不要钉静态占位文案。
1446
+ - 重构后**逐组比对**:8 组的响应体与计数语义必须与重构前逐字等价;跑一次全套件,断言数只允许因 Step 1/1b 的新增而上升,任何下降或意外变化都要在报告里解释。
1447
+
1448
+ - [x] **Step 1d: 审查裁定(Task 7 审查 2 Important 的最终形态,覆盖上面 Step 1 的两处写法)**
1449
+
1450
+ 1. **`[G1]` 的 Enter 必须用可信按键**:上面代码块里的 `t.dispatchEvent(new KeyboardEvent("keydown", …))` 派发的是**不可信**事件——不可信事件不触发原生 `<button>` 的默认激活行为,因此测出的"不翻转"是 harness 手法造成的伪缺陷(真实键盘用户一直可用)。最终写法:`focus()` 后 `await page.keyboard.press("Enter")`(CDP 受信任事件)。**本任务据此零产品代码改动**(曾加的 `onKeyDown` 7 行已 revert)。
1451
+ 2. **`[H6]` 浅色主题必须先立 dark 基线**:只设 `prefers-color-scheme: light` 没有鉴别力——headless Chrome 默认即 light,那条 `matchMedia` 守卫无论仿真是否生效都为真(死断言);且宿主的暗色实际由 `body[data-ds-dark-theme]` 驱动。最终写法:先仿真 `dark` 并断言 dark 基线命中,再切 `light` 断言翻转,同时把 `document.body.hasAttribute("data-ds-dark-theme")` 读进失败详情;截图名不暗示"浅色基线"(`acc-task7-h6-theme-switch.png`)。
1452
+
1453
+ - [x] **Step 2: 跑新套件确认绿**
1454
+
1455
+ Expected: 全部 ok,退出码 0;记录总断言数(写进 Task 9 的 CHANGELOG 条目)。
1456
+
1457
+ - [x] **Step 3: 删除老套件**
1458
+
1459
+ ```bash
1460
+ cd /c/Users/21613/dev/dsh-ui-test && rm card-regression.js && ls *.js
1461
+ ```
1462
+
1463
+ Expected: 列出 `card-accordion.js`、`debug-dom.js`、`debug-inputs.js`、`qoder-e2e.js`、`qoder-prefs-check.js`、`qoder-slot-check.js`、`qoder-tab-phase2.js`、`shots-baseline.js`。
1464
+
1465
+ - [x] **Step 4: 提交(仅当本任务改了 `lib/client.js`)**
1466
+
1467
+ ```bash
1468
+ cd /c/Users/21613/dev/dsh-tap && git status --short
1469
+ # 有改动才提交:
1470
+ git add lib/client.js && git commit -m "fix(client): 回归移植暴露的缺陷修复"
1471
+ ```
1472
+
1473
+ (`dsh-ui-test/` 的改动不进 git;若本任务未改仓库文件,则无提交。)
1474
+
1475
+ ---
1476
+
1477
+ ### Task 8: 其余浏览器脚本适配 + 截图基线重拍
1478
+
1479
+ **Files:**
1480
+ - Modify: `C:/Users/21613/dev/dsh-ui-test/qoder-slot-check.js`(10 断言)、`qoder-e2e.js`(8)、`qoder-prefs-check.js`(32)、`qoder-tab-phase2.js`(8)、`shots-baseline.js`
1481
+ - Modify: `lib/client.js`(仅当适配暴露真缺陷)
1482
+
1483
+ **Interfaces:**
1484
+ - Consumes: Task 1–6 的 DOM 契约(`.cbc-acc[data-block]`、`.cbc-acc-toggle[data-block]`、`.cbc-acc-body[data-block]`、`.cbc-syncbar`、`details.cbc-adv`、`.cbc-acc-head input[data-field]`)
1485
+ - Produces: 全部脚本跑绿的证据(写进 Task 9 的 CHANGELOG)
1486
+
1487
+ - [x] **Step 0: 环境事实与写盘纪律(2026-09-23 实施期更新,覆盖下面所有步骤里的地址)**
1488
+
1489
+ - **测试地址已换**:控制方自有的 `dsh web`(3080)于 10:06 被外部终止;用户的 dsh-launcher 起了**托管实例** `dsh web --no-open --port 3090`(Edge 正连着它)。本任务一律用 `http://127.0.0.1:3090/?token=<launcher 日志里的 token>`(token 见 `dsh-launcher/native-web-3090.log`)。**不许启停 dsh、不许另起实例、不许占端口**——第二个实例会抢 3901/3902/3903 桥端口,启动期的目录镜像写入还会触发宿主 profile 重载,干扰用户会话。
1490
+ - **md5 基线已重立**为 `~/.dsh/codebuddy-plugin.json` = `7127964e84619be3ef21ea371516f575`(10:07 被外部合法改写,新增 `qoderEnabled:true` + qoderModelPrefs)。旧值 `be0b8864…` 作废,**不许回滚用户这次改动**。
1491
+ - **写盘分级**(先 `grep` 核自己手上的脚本):`card-accordion.js` / `qoder-slot-check.js` / `shots-baseline.js` = **只读**(真实页面只 GET,写入组一律 page-local mock)⇒ 可直接在 3090 上跑。`qoder-prefs-check.js` / `qoder-tab-phase2.js` = **经 UI 触发真实 `POST {patch}`**,且按 AGENTS.md 的"收尾复原(含文件层擦除)"纪律会把文件层擦回 pristine ⇒ 在用户实例上跑**有抹掉其 10:07 改动的风险**,须先备份 `~/.dsh/codebuddy-plugin.json` 再跑、跑完从备份还原,或由人类 partner 明确放弃这批改动。`qoder-e2e.js` 额外**真实发消息消耗额度** ⇒ 默认跳过,CHANGELOG 记"e2e 未跑(额度考虑)",除非人类 partner 明确要求跑。
1492
+ - **Task 7 路由至此的套件卫生**(都在 `dsh-ui-test/`,不进 git):①`card-accordion.js:954` 的 general 收尾复位只收布局不收焦点 ⇒ 补一次 `document.activeElement.blur()`,否则全页截图 `acc-task4-groups.png` 带 focus ring、Task 8 的明暗基线会撞上;②`shots/` 孤儿清理:`acc-task7-light.png`(已废弃写法的产物,现无写者)与老套件遗留的 `reg-*.png` ×5 删掉,`tmp-h6-{default,dark,light}.png` ×3 是 `[H6]` 结论的物证,**本轮结束前保留**;③三处失效指针:`:793-794` 仍指仓库外报告、`:874` 引用已删除的 `tmp-h6-probe` 脚本、`:20`/`:45`/`:961` 的"10 条 `warns===0`"与实况(9 条同型 + `[H6]`/主 page 各 1 条)不符 ⇒ 一次改齐。
1493
+ - **浅色可读性人工核验项**(Step 4 一并看):fix round 后的浅色截图里 **TraeWork CN 区块头未勾选的启用 checkbox 呈实心深方块**,浅底上对比度可疑——若确认不可读,报为缺陷(这是 Task 3 把开关上移到区块头后在浅色下才暴露的组合),不要在本任务里顺手堆 UI 改动。
1494
+
1495
+ - [x] **Step 1: 统一驱动改法(每个脚本同一套替换)**
1496
+
1497
+ | 老写法 | 新写法 |
1498
+ |---|---|
1499
+ | `[...document.querySelectorAll(".cbc-tab")].find(t => t.textContent.includes("Qoder CN")).click()` | `document.querySelector('.cbc-acc-toggle[data-block=qoder]').click()` |
1500
+ | `document.querySelector('.cbc-panel[data-tab=qoder] …')` | `document.querySelector('.cbc-acc-body[data-block=qoder] …')` |
1501
+ | `.cbc-panel[data-tab=models]` | `.cbc-acc-body[data-block=codebuddy]` |
1502
+ | `.cbc-panel[data-tab=usage]` / `[data-tab=providers]` | `.cbc-acc-body[data-block=general]` |
1503
+ | `.cbc-panel[data-tab=bridge]` | `.cbc-acc-body[data-block=codebuddy]`(网关组) |
1504
+ | 「Qoder CN 标签」相关文案断言 | 改为「Qoder CN 区块」;`qoder-tab-phase2.js` 的「启用开关」断言改查 `.cbc-acc-head input[data-field=qoderEnabled]` |
1505
+ | 「连接域名」折叠组(`cbc-toggle` 按钮点开) | `details.cbc-adv` 的 `summary`(点开方式:`d.open = true` 或 `summary.click()`) |
1506
+ | 模型组两个按钮(刷新列表 / 立即同步) | 单按钮「同步目录」(在 `.cbc-syncbar` 内) |
1507
+
1508
+ - [x] **Step 2: 逐脚本跑绿**
1509
+
1510
+ ```bash
1511
+ cd /c/Users/21613/dev/dsh-ui-test
1512
+ node qoder-slot-check.js "http://127.0.0.1:3080/?token=<TOKEN>"
1513
+ node qoder-prefs-check.js "http://127.0.0.1:3080/?token=<TOKEN>"
1514
+ node qoder-tab-phase2.js "http://127.0.0.1:3080/?token=<TOKEN>"
1515
+ node qoder-e2e.js "http://127.0.0.1:3080/?token=<TOKEN>"
1516
+ ```
1517
+
1518
+ Expected: 四个脚本各自 `0 失败`,退出码 0。`qoder-e2e.js` 会真实发消息(消耗额度)——若不希望消耗,跳过并在 CHANGELOG 注明"e2e 未跑(额度考虑)",其余三个必须绿。
1519
+
1520
+ - [x] **Step 3: 重拍明暗基线**
1521
+
1522
+ ```bash
1523
+ cd /c/Users/21613/dev/dsh-ui-test && node shots-baseline.js "http://127.0.0.1:3080/?token=<TOKEN>" acc
1524
+ ls shots/ | grep '^acc' | head -20
1525
+ ```
1526
+
1527
+ `shots-baseline.js` 顶部有 `const TABS = ["登录","模型","额度与用量","工具","服务商","TraeWork CN","Qoder CN","桥与高级"];`(实测现状)——改为四区块驱动:把 `TABS` 换成 `const BLOCKS = ["codebuddy","trae","qoder","general"];`,逐个点 `.cbc-acc-toggle[data-block=<id>]` 展开后截图。第二个参数 `acc` 是脚本已有的 out-prefix,实测命名规则为 `shots/<OUT>-<NN>-<名称>.png` ⇒ 输出 `shots/acc-00-overview.png`、`shots/acc-01-codebuddy.png` … `shots/acc-04-general.png`;暗色由脚本现有的 `document.body.setAttribute("data-ds-dark-theme","true")` 切换,收尾图 `shots/acc-dark-active-tab.png`。
1528
+
1529
+ - [x] **Step 4: 人工核验截图**
1530
+
1531
+ 用 Read 工具看 `shots/acc-01-codebuddy.png` 与 `shots/acc-04-general.png`(以及 `shots/acc-dark-active-tab.png`):确认区块头状态行不溢出、幽灵输入边框在 hover/focus 才出现、`details.cbc-adv` 折叠标记正常、浅色主题下无不可读对比。发现问题回到对应任务修(不要在此任务里堆积 UI 修改)。
1532
+
1533
+ - [x] **Step 5: 提交(仅当改了 `lib/client.js`)**
1534
+
1535
+ ```bash
1536
+ cd /c/Users/21613/dev/dsh-tap && git status --short && git add lib/client.js \
1537
+ && git commit -m "fix(client): 脚本适配暴露的缺陷修复"
1538
+ ```
1539
+
1540
+ ---
1541
+
1542
+ ### Task 9: 文档、版本与全量回归
1543
+
1544
+ **Files:**
1545
+ - Modify: `wiki/07-web-client.md`(重写"卡片结构"与"改 UI 后的回归"两节)、`AGENTS.md`(架构表 `lib/client.js` 行)、`CHANGELOG.md`(新增 0.10.0 段)、`package.json`(version)
1546
+ - Modify: `docs/goals/settings-card-ux-redesign.md`(状态行改为"已实施",附实测结论)
1547
+
1548
+ **Interfaces:**
1549
+ - Consumes: Task 1–8 的实测结果(断言数、脚本清单、截图名)
1550
+ - Produces: 0.10.0 发版所需的文档一致性
1551
+
1552
+ - [x] **Step 0: 前 8 个任务路由至此的收口项(控制方裁定,逐条做完)**
1553
+
1554
+ 1. **数字口径(Task 7/8 实测;CHANGELOG 与 wiki 一律从这里取,不许沿用文档旧数)**:`card-accordion.js` **129 断言全绿**(静态 `check(` 站点 126 + `[B2]` 循环多跑 2 次 + `[C2]` 循环多跑 1 次;四段口径 = 迁移期 117 → 跨分支终审轮 118(`[C2]` 补 Qoder 半边)→ 终审残余修复轮 119(`[F9]` 补「收起边界重采恰一次」的正向锁)→ 复审修复轮 129(`[B5]` 十断言:退避链**启动点**在竞争包作废时照旧要启动,兼作 A2 请求代次门的时序锁)——Task 7/8 当时实测值就是那条历史里的 117,不改写);`qoder-slot-check.js` 13/13、`qoder-tab-phase2.js` 11/11、`qoder-prefs-check.js` 37 通过 / 0 失败 / 跳过 0(静态 39 站点,2 条在未触发分支)、`shots-baseline.js` 十张元素级基线 0 失败、`debug-inputs.js` 实跑 exit 0。**`qoder-e2e.js` 未跑**(会真实发消息消耗用户额度,控制方裁定跳过)⇒ CHANGELOG 必须如实写"未跑(额度考虑)",不得声称通过。
1555
+ 2. **用户可见文案(Task 6 审查 Minor 11 路由至此)**:`UsageSection` 仍写「本页可见时每 10 秒自动刷新」,而语义自 Task 1 起是「通用区块展开时」(`grep -n '本页可见时' lib/client.js`)⇒ 改成「通用区块展开时每 10 秒自动刷新」,改完重跑 `card-accordion.js` 确认仍 117/117(该步当时的实况;现口径 129,见上一条)。
1556
+ 3. **CHANGELOG 要记两条反复,不能只记结果**:① 键盘激活补丁 `48329a7` 加了又 revert(`4dd9f52`)——起因是回归锁用 `dispatchEvent` 派发**不可信** keydown 测出伪缺陷;② 浅色原生控件配色缺陷(宿主无条件 `color-scheme: dark`)由截图基线**人工看图**发现、`a421d35` 修复。
1557
+ 4. **新增踩坑三条**(`docs/pitfalls.md` 取新编号 #46/#47/#48,并在 AGENTS.md「踩坑速查」各加一行——这是对 Step 3 里"本轮无新坑"那句的更正,本轮确实踩到三条,每条都付了一轮 fix 的学费):
1558
+ - **#46 键盘可达性断言必须用可信按键**:`dispatchEvent(new KeyboardEvent(...))` 不触发原生 `<button>` 的默认激活 ⇒ 测出的"不翻转"是 harness 伪缺陷;曾为此在产品代码加 7 行 `onKeyDown` 又撤销。正解 = `page.keyboard.press(...)`。
1559
+ - **#47 宿主主题由 `body[data-ds-dark-theme]` 属性驱动,`prefers-color-scheme` 媒体仿真对本宿主零效果**(三种仿真下截图 md5 互等)⇒ 浅色不变式要走摘属性路径;且原生控件配色必须显式绑该属性——`color-scheme` 的 used value 由 html/body 传播,插件不写就跟着宿主恒深色,浅色下未勾选 checkbox 呈**深色实心块**(看起来像已开启)。
1560
+ - **#48 `page.screenshot({fullPage:true})` 在本宿主是空操作**(产出恒为视口 1440×900;"尺寸对 ≠ 内容在")⇒ 区块级基线一律走元素句柄截图 + **逐张看图**;配套教训:文档自述要与产物同批更新(本轮出现过"报告声称的适配在产物里零命中"与"两向同验"过誉两类)。
1561
+ 5. **文档一致性 grep 清单**(Step 3 的扩展):`grep -rn "8 标签\|标签页\|注意条\|状态芯片\|cbc-tab\|cbc-panel\|启用通道\|立即同步\|刷新列表" README.md wiki/ AGENTS.md docs/goals/settings-card-ux-redesign.md` ⇒ 命中处逐个判"是否已被 4 区块手风琴取代"并改口径(Task 3/6 已点名 `README.md:50/62`、`wiki/09-run-and-test.md:33`)。
1562
+ 6. **两处失效指针**(Task 8 复审三条 Low 之二):`card-accordion.js:1003-1004` 仍把 `acc-task4-groups.png` 说成"Task 8 明暗基线的口径"(现基线已元素级,该图仍是 1440×900 视口图)⇒ 改注释;`task-8-report.md:247/:283` 仍留"两向同验(改反了也必红)"⇒ 与 §11.8 一次改齐,别让喂给 CHANGELOG 的文档自相矛盾。
1563
+ 7. **`shots/` 清理用归档不用删**:现 62 张,把三类误导性产物**移动**到 `shots/_archive-2026-09-23/`(可逆)——18 张标签时代 `baseline-*`/`r1-*`、6 张被取代的 1440×900 假基线(`acc-00..04`、`acc-dark-active-tab.png`)、5 张无写者探针(`probe-dark-body-{general,qoder}`、`probe-heads-{light,dark}`、`probe-head-check-checked-light`)。**保留**:新基线 `acc-{light,dark}-0[0-4]-*.png` ×10、套件产物 `acc-task*.png`、物证 `tmp-h6-*.png` ×3 与 `probe-head-check-unchecked-{dark,light}.png`。
1564
+ 8. **Step 8 作废**:控制方自有的 3080 实例早已不在,现在跑的是**用户 launcher 托管的 3090**(Edge 连着)⇒ **不要杀它、不要动任何端口**。改为:`netstat -ano | grep ':3080'` 确认 3080 本就空闲,并在收尾里写明"测试服务由用户侧 launcher 管理,本流程未启停"。
1565
+ 9. `docs/probes/qoder-quota-1790135931035.json`(未跟踪、非本流程产物)**不入库、不删除**,收尾时向用户报来源待认。
1566
+
1567
+ - [x] **Step 1: 重写 `wiki/07-web-client.md` 的"卡片结构"节**
1568
+
1569
+ 把"状态芯片 + 8 标签页"整节换成通道手风琴的描述(4 区块、区块头状态行 = 单一真源、展开才挂载/收起不卸载、通道内 5 分组、`details.cbc-adv` 与 `details.cbc-help` 的分工、通用区块头挂载取样与轮询条件)。保留"请求契约"与"React 纪律"两节,并在纪律节补一条:**区块头是 `div` + 两个独立交互元素(展开 button + 启用开关),不要把开关嵌进 button**。
1570
+
1571
+ - [x] **Step 2: 修掉文档漂移并写清回归驱动**
1572
+
1573
+ 同文件"改 UI 后的回归"节:删掉不存在的 `window.__cbc.tab('分区名')` 说法(实测 `lib/client.js` 与 `dsh-ui-test/*.js` 均无此钩子),改为实测驱动方式:
1574
+
1575
+ ```
1576
+ 驱动 = 点 `.cbc-acc-toggle[data-block=<id>]` 展开区块,再查 `.cbc-acc-body[data-block=<id>]` 内的行/控件。
1577
+ 选择器清单:.cbc-acc[data-block] / .cbc-acc.cbc-open / .cbc-acc-toggle / .cbc-acc-title /
1578
+ .cbc-acc-status / .cbc-acc-body / .cbc-acc-head input.cbc-check[data-field] / .cbc-syncbar /
1579
+ details.cbc-adv(高级组)/ details.cbc-help(使用说明)。
1580
+ 套件:card-accordion.js(设置卡唯一套件,N 断言)、qoder-slot-check.js、qoder-prefs-check.js、
1581
+ qoder-tab-phase2.js、qoder-e2e.js、shots-baseline.js、debug-dom.js/debug-inputs.js。
1582
+ 跑法:dsh 入口 /c/Users/21613/dev/dsh-launcher/node_modules/.bin/dsh web(token 见启动行);
1583
+ profile ~/.dsh/profiles/web 已 link 本仓库 ⇒ 改 lib/client.js 刷新页面即生效;跑后按 PID 杀 3080。
1584
+ ```
1585
+
1586
+ (`N` 用 Task 7 Step 2 记录的真实数字。)
1587
+
1588
+ - [x] **Step 3: 更新 `AGENTS.md`**
1589
+
1590
+ 架构表"浏览器半 `lib/client.js`"那一行的职责描述:把"折叠态状态芯片 + 8 标签页懒挂载隐藏不卸载"改为"4 区块通道手风琴(区块头常显状态行 = 单一真源)+ 展开才挂载/收起不卸载"。踩坑速查节**不新增条目**(本轮无新坑;若实施中真踩到新坑,按纪律取新编号追加到 `docs/pitfalls.md` 并在此加一行)。
1591
+
1592
+ 同一步里 grep 全仓文档的旧口径并修正(Task 3 交接):`grep -rn "启用通道" README.md wiki/ AGENTS.md` —— `README.md:50/62` 与 `wiki/09-run-and-test.md:33` 仍写"分区内启用通道",实际已上移到区块头;一并改口径。
1593
+
1594
+ - [x] **Step 4: 写 `CHANGELOG.md` 0.10.0 段**
1595
+
1596
+ 按仓库既有风格(驱动 → 逐条改 → 验证)写:用户"交互有点麻烦、不够简单"驱动;痛点定位 = 找不到/太散;4 区块手风琴取代 8 标签;删三套状态展示;模型组同构(同步目录单按钮 + 统一筛选);Trae/Qoder 启用开关上移区块头;通用区块头挂载取样 + api-key 估算降级;工程项收进 `details.cbc-adv`;后端契约零变化。验证段填实测数字:`card-accordion.js` N 断言全绿、`qoder-slot-check` / `qoder-prefs-check` / `qoder-tab-phase2` 各自断言数与结果、离线七套件全绿、截图基线重拍。
1597
+
1598
+ - [x] **Step 5: 版本号与设计文档状态**
1599
+
1600
+ ```bash
1601
+ cd /c/Users/21613/dev/dsh-tap
1602
+ node -e "const f='package.json',j=require('./'+f);j.version='0.10.0';require('fs').writeFileSync(f,JSON.stringify(j,null,2)+'\n')"
1603
+ grep -n '"version"' package.json
1604
+ ```
1605
+
1606
+ Expected: `"version": "0.10.0"`。再把 `docs/goals/settings-card-ux-redesign.md` 头部状态行改为"已实施(0.10.0,2026-09-23)",并把标题的"(待评审)"去掉。
1607
+
1608
+ - [x] **Step 6: 全量离线回归**
1609
+
1610
+ ```bash
1611
+ cd /c/Users/21613/dev/dsh-tap
1612
+ npm run verify:bridge && npm run verify:core && npm run verify:providers \
1613
+ && npm run verify:trae-provider && npm run verify:qoder && npm run verify:host-config \
1614
+ && node scripts/verify-models.mjs --list
1615
+ ```
1616
+
1617
+ Expected: 七个命令全部退出码 0(纯前端改动,预期零影响;任何一个红都说明误改了宿主半)。
1618
+
1619
+ - [ ] **Step 7: 在线探测 —— 未执行(控制方裁定跳过,非遗漏)**
1620
+
1621
+ ```bash
1622
+ cd /c/Users/21613/dev/dsh-tap && npm run verify
1623
+ ```
1624
+
1625
+ Expected: 18/18(需 `CODEBUDDY_API_KEY` 或已登录 OAuth)。若环境无凭据导致失败,**如实记录跳过原因**到 CHANGELOG 验证段,不得声称通过。
1626
+
1627
+ **实况**:`npm run verify` 会向网关发 18 次真实请求消耗用户额度,与 `qoder-e2e.js` 同一口径 ⇒ 本轮不跑,CHANGELOG 验证段已如实记「未跑 + 原因」;纯前端改动由离线七套件覆盖(全 exit 0,见 Task 9 报告)。
1628
+
1629
+ - [ ] **Step 8: 杀掉测试服务 —— 作废(本流程未启停测试服务)**
1630
+
1631
+ ```bash
1632
+ netstat -ano | grep ':3080' | head -3 # 取 PID
1633
+ taskkill //PID <PID> //F
1634
+ netstat -ano | grep ':3080' | head -1 # 确认已释放(无输出)
1635
+ ```
1636
+
1637
+ **实况**:控制方自有的 3080 实例于 10:06 被外部终止;测试改跑在**用户 launcher 托管的 3090 实例**上(Edge 正连着)⇒ 不杀、不动任何端口。已确认 3080 本就空闲(Step 0.8)。
1638
+
1639
+ - [x] **Step 9: 提交**
1640
+
1641
+ ```bash
1642
+ cd /c/Users/21613/dev/dsh-tap
1643
+ git add package.json CHANGELOG.md AGENTS.md wiki/07-web-client.md docs/goals/settings-card-ux-redesign.md
1644
+ git status --short
1645
+ git commit -m "feat(client)!: 设置卡改通道手风琴(v0.10.0)——4 区块取代 8 标签,状态收敛到区块头"
1646
+ ```
1647
+
1648
+ (提交前用 `git status --short` 复核暂存内容,确认没有误加 `docs/probes/`、`node_modules/` 或凭据文件。)
1649
+
1650
+ ---
1651
+
1652
+ ## Self-Review(作者自查,已执行)
1653
+
1654
+ **1. Spec 覆盖**(逐条对 `docs/goals/settings-card-ux-redesign.md`):
1655
+
1656
+ | Spec 条目 | 任务 |
1657
+ |---|---|
1658
+ | §3 顶层 4 区块 + 固定顺序 + 默认全收 | Task 1 |
1659
+ | §3 状态行真源(沿用 `buildChips`、删 `tabBadge`) | Task 1(`blockStatus`)+ Task 2(去前缀、删 `tab`/`tabTitle`) |
1660
+ | §3 通用区块头取样 + api-key 降级 + `credential-scan` 不上移 | Task 6 |
1661
+ | §3 删注意条/折叠三芯片/标签徽标;旧槽按需单芯片 | Task 2 |
1662
+ | §3 硬修复清单 **12 条**(`settleGateways`/`fetchWithTimeout`/`PanelBoundary`/同步开窗/图标候选/幽灵输入/同值去重/模块级编辑器/`e.message`/脱敏/失焦即保存 + 终审补的第 12 条「写后读必须带请求代次」) | 贯穿 Task 1–6;`settleGateways` 由 `[B4]`(补拉自愈)+ `[B5]`(补拉链**启动点**在竞争包作废时照旧启动)锁定,第 12 条的代次门由 `[B5]` 的"迟到包不得覆写新态"时序断言锁定,`PanelBoundary` 粒度在 Task 1 Step 6e,同值去重与模块级编辑器为"不动即保留"(Task 3/5 明确沿用) |
1663
+ | §4 通道内 5 分组 + `details.cbc-adv` | Task 4 |
1664
+ | §4 模型组同构(同步合并 + 三家筛选) | Task 5 |
1665
+ | §4 区块头开关(Trae/Qoder 唯一落点) | Task 3 |
1666
+ | §4 保存提示落到触发保存的区块头 | Task 1 Step 6c/6d(`saveIn`/`saved.block`) |
1667
+ | §4 轮询条件改"区块展开" | Task 1(接线)+ Task 6(`[F3]`–`[F5]` 锁定) |
1668
+ | §4 键盘/ARIA(删 tablist、区块头普通 button) | Task 1(删)+ Task 7(`[G1]`/`[G2]` 新断言) |
1669
+ | §5 31 字段落点 | Task 3/4/5 的搬迁步骤逐字段点名;Task 9 Step 1 的 wiki 重写复核 |
1670
+ | §6 回归脚本适配 + 老套件退役 | Task 7 + Task 8 |
1671
+ | §6 文档修正(`__cbc.tab` 漂移) | Task 9 Step 2 |
1672
+ | §7 实施顺序 ①–④ | Task 1–2 = ①,Task 3–6 = ②,Task 7–8 = ③,Task 9 = ④ |
1673
+ | §8 风险(头部开关误触回退方案) | Task 3 的 `control` 槽是单一改动点,回退 = 把 `channelSwitch` 挪进分区首行 |
1674
+ | §9 验收标准 | Task 7 Step 2 / Task 8 Step 2 / Task 9 Step 6–7 |
1675
+
1676
+ **2. 占位符扫描**:无 TBD/TODO;Task 4 Step 4 的 `/* … */` 是"原样剪切现有行"的明确指令(附了源行号与"不改字段/文案/ResetButton"的约束),不是待填内容。
1677
+
1678
+ **3. 类型/命名一致性**:`BLOCK_DEFS`/`BLOCK_SECTIONS`/`SECTION_RENDERERS`/`blockStatus`/`worstTone`/`BlockHead`/`toggleBlock`/`openBlocks`/`mountedBlocks`/`saved`/`saveIn`/`channelSwitch`/`generalSummary`/`attentionCount` 在定义处与使用处同名;DOM 契约(`.cbc-acc*`/`.cbc-syncbar`/`details.cbc-adv`/`data-field`)在 Task 1/3/4/5 的定义与 Task 1–8 的断言中一致;`post()` 返回 `{ok,status,d}` 的形状在 Task 6 Step 3 显式提醒。
1679
+
1680
+ **4. 已知取舍**:Task 1 结束到 Task 2 之间,区块头状态行会短暂带品牌前缀("Trae 未启用")——因为 `strip` 还在用带前缀的文案;Task 2 一并清理。这是有意的中间态,不是遗漏。