# README 截图是怎么来的
七张图(中文一套、英文一套)里的余额、token 数、日期**全是合成数据**。这个目录就是那台"造图机"。
## 为什么不是随手截一张
本插件的立场之一是**不把真实额度放进公开仓库**。直接截日常实例会把真实余额、真实到期日、真实用户目录
一起带进 git 历史——那是删不干净的。桌面壁纸也会被半透明面板透出来(那上面还有画师的署名水印),
所以"随手截一张好看的"这条路一开始就该堵死。这里把"画面内容"做成了可重放的代码:任何人 clone 下来
跑一遍,得到的图和数据都是同一份假数。
## 三个设计决定
### 1. 拦响应,不造假上游
面板与徽标的渲染输入只有一个来源:同源 GET `/token-plan-quota/summary`。所以 `cdp.mjs` 用 Chrome DevTools
Protocol 的 `Fetch` 域,把这个响应(外加面板"更新于"按钮 POST 的 `/token-plan-quota/refresh`——它会覆盖快照,
漏拦就会拍到真数据)换成 `fixture.mjs` 造的对象。
好处:不必模拟任何供应商的上游契约(尤其是千问控制台网关那套 Cookie/secToken 语义——最容易写错,
而且一旦写进文档就会和真实上游长期不一致),并且真实数字**根本进不到浏览器**,跑图用的实例不需要任何凭据。
代价:依赖 summary 的形状。这个形状由 `lib/index.js` 里 `publicCard()` 的**字段白名单**决定,
`fixture.mjs` 在运行时把它正则读出来比对——以后加字段而 fixture 没跟上,出图会直接报错而不是悄悄拍一张
宿主永远发不出来的图。
### 2. 用宿主自带的 `?fixture` 拿到"活会话"
徽标注入的座位是 `conversation.input.left`,**没有打开的会话就没有那块 DOM**,徽标整个不挂载。
DeepSeek Harness 的 web 层自带 fixture 模式(URL 加 `?fixture`,客户端会把整个 api 层换成内存假宿主,
自带种子会话和默认模型),所以:
- 不需要真凭据、不需要种工作区、不需要原生目录对话框;
- 图里出现的会话名和模型名也来自宿主的 fixture 常量,不是任何人的真实数据。
于是"UI 是假的、额度也是假的"——双保险。
### 3. 要"当前模型"和卡片对得上,就得补全假宿主的模型目录
徽标和面板都跟着**当前模型的供应商**走(`lib/client.js:979`、`:994-1003`),所以一张讲「Token Plan 额度」
的图,活动模型必须真的就是 `qwen-token-plan-cn/…`。而假宿主的模型目录只有 DeepSeek 与 OpenAI 两家。
补法是在浏览器侧改写那个客户端包里的一段目录常量(`make-shots.mjs` 的 `patchFixtureCatalog`)。
**不改宿主源码**:`dsh web` 发的是**预构建**的客户端包,改 `packages/client` 下的 src 不重新构建就不生效
(踩过)。锚点找不到就直接失败——宁可不出图,也不拍一张"当前模型"和卡片对不上的假截图。
## 前置
| 需要 | 说明 |
| --- | --- |
| Node ≥ 22 | CDP 客户端用全局 `WebSocket`,零 npm 依赖 |
| Microsoft Edge(或 Chrome) | 只用来跑无头截图,脚本会探两个常见安装路径,找不到用 `--edge` 指定 |
| 一个装好本插件的 dsh web 实例 | 建议用**独立 `DSH_HOME`** 的实例,别在日常实例上跑(脚本对 3080 默认拒绝) |
| ffmpeg(只有 GIF 需要) | 装在你自己选的目录,**不要加进插件 `dependencies`**:`npm i --no-save --prefix
ffmpeg-static` |
起一个隔离实例(示例):
```bash
DSH_HOME=/tmp/dsh-quota-demo dsh plugin --profile web add /path/to/dsh-token-plan-quota
DSH_HOME=/tmp/dsh-quota-demo dsh web --port 3099 --no-open
```
## 跑
```bash
node scripts/shots/make-shots.mjs --url http://127.0.0.1:3099 --lang zh --out docs/images
node scripts/shots/make-shots.mjs --url http://127.0.0.1:3099 --lang en --out docs/images
```
`--lang` 决定浏览器语言,而插件界面文案跟着 `document.documentElement.lang` 走,所以中英各跑一次就是两套图。
`--shots` 可以只跑其中几张(默认 `2,3,4,5,6,7`,`1` 是要 ffmpeg 的 GIF)。产物:
| # | 文件 | 证明什么 |
| --- | --- | --- |
| 1 | `badge-follows-model.gif` | 徽标跟随当前模型的供应商切换:官方真值 ↔ 明确标注的「实测」 |
| 2 | `panel-official-plus-meters.png` | 明细面板:官方余量卡 + 一张卡里的多窗口计量条 |
| 3 | `floating-panel.png` | 面板拖成悬浮小窗(拖标题栏、双击复位) |
| 4 | `cookie-fallback-measured.png` | Cookie 掉线:官方卡变成不带任何数字的错误卡,实测兜底卡留在徽标上 |
| 5 | `state-deepseek-balance.png` | 按钱计费的官方卡:没有分母 ⇒ 不画条、不报百分比 |
| 6 | `state-token-plan-credits.png` | 官方给了分母的卡:画条、报百分比、报周期重置日 |
| 7 | `state-no-history.png` | 既没官方额度、本实例又还没调用过:徽标「无官方额度数据」,面板只说清为什么没有 |
5/6/7 是同一取景(面板浮在聊天区左上角 + 底部徽标行进同一个裁切框),只差当前模型 ——
读者要看的正是"换模型时这张卡怎么变"。所以 `panelScope` 设成 `current`(面板只列当前那一家)。
**英文套没有第 7 张**:那句解释文案宿主只有中文(`lib/index.js:376`),编一句英文就等于拍假图。
中间产物落在仓库根的 `.shots-work//.json`(当次用的合成 payload)与
`.shots-work//frames/`(GIF 帧),已 gitignore。**别把它们放到 `docs/` 下面**:
`package.json` 的 `files` 显式列了 `docs`,而 allowlist 会压过 `.gitignore`,帧文件会直接被打进 npm 包。
## 改图时注意的几条口径
这些不是审美问题,是**产品立场**——图里画错就等于 README 打自己的脸:
1. **实测卡永不画条、永不显示百分比**:没有官方分母的进度条就是估算。合成数据里连
`usedPercent`/`remainingPercent` 都不该出现(宿主对实测卡就是不发这两个字段)。
2. **`meters[0]` 与顶层 `remaining/total` 同源**:前端只渲染 `meters.slice(1)`,写错顺序会少一条窗口。
3. **百分比是派生值**:`usedPercent`/`remainingPercent` 由 `finalizeCard()`/`finalizeMeter()` 从
`{remaining,total}` 算出(取整到 4 位)。`fixture.mjs` 跑的是同一套公式,`assertFixture()` 还会复算比对——
手改一个数字让算式对不上,出图会被拒。
4. **余量条的配色有硬档位**:`client.js:218-222` 是 ≥70% 绿 / 40–70% 蓝 / <40% 橙红。
想让图里是绿条,合成数字就得真的落在 70% 以上,而不是去改前端。
5. **"还没调用过"的卡必须字段缺席**:宿主在 `startedAt === undefined` 分支根本不发
`tokens`/`calls`/`resetAt`(`lib/index.js:368-376`)。写成 `tokens: 0` 会拍出一张宿主永远发不出的
「0 tok」,徽标也不会是「无官方额度数据」。
另外 ④ 那张图里的"官方卡"必须**不带任何数字**:一旦带了数字,前端会把同供应商的实测兜底卡收掉
(一家只展示一张额度卡),徽标就变空白了。
## 两个静默失败的坑(都变成断言了)
- **「内测声明」模态在 fixture 模式下关不掉**:假宿主没有 settings 写入通道,
`WelcomeNoticeStore.acknowledge()` 永远判失败,点「继续」只会亮出「暂时无法保存确认状态」那行错误——
它正好压在徽标上,于是图里"面板有了、徽标没了",而脚本一路绿。现在 `dismissOnboarding()` 先按标题定位
到模态自己再点(不能用宽的 `[class*="dialog"]`:额度面板本身就是 `role="dialog"`),点不动就把这层宿主
遮罩连同压暗层隐藏掉,并且以"没有浮层挡着徽标"(`elementFromPoint`)为唯一通过条件。
- **文本匹配要两边一起归一化**:`deepClick` 把节点 `innerText` 去了空白,却拿原始串去比,
于是 `Qwen3.8 Flash` 这种带空格的名字永远点不到;`GPT-5` 没空格,坑藏了很久。
## 自检
```bash
node -e "import('./scripts/shots/fixture.mjs').then(m=>{const s=m.makeSnapshot({variant:'triptych',lang:'zh'});console.log(m.assertFixture(s,{lang:'zh'}).length+' 条断言通过')})"
```
`assertFixture` 会拦:真实痕迹(用户名/盘符/真实余额数字)、不在 `publicCard()` 白名单里的键、
实测卡带百分比、百分比与 `remaining/total` 不自洽、零记录实测卡带了本该缺席的字段、
以及**英文套里出现中文文案**(宿主单语字段除外)。`npm run check` 会连它一起跑。