Compare commits

..
8 Commits
Author SHA1 Message Date
skycurtain c5f7d93d67 feat(api): 实装三个公开接口——groups/perf-metrics/rankings(全站视角数据)
- 新增 /api/user/groups:用户分组与倍率(未启用分组时为空对象),与 /api/pricing group_ratio 互补
- 新增 /api/perf-metrics/summary:全站模型服务质量(延迟/成功率/TPS/小时级序列),hours 参数;对拼车工具补「模型健康度」视角
- 新增 /api/rankings:全站模型用量排行(名次/份额/环比增长),period 参数;车长选主推模型的数据依据
- 三者均 authMode none,公开接口组置于清单最前;/api/status 的「唯一」表述改为公开接口组之一
- spec 断言 6→9(路径顺序、鉴权分布 none×4+strict×3+lenient×2);交互设计文档 §1 清单重排补全站视角定位说明;README 计数更新
- 验证:test 89/89 + type-check + lint + build 四绿
2026-09-10 00:47:30 +08:00
skycurtain a77cd71888 feat(view): JSON 折叠交互——chevron、行尾摘要、滚动补偿与折叠全部
- json-text-view:投影层接线(可视窗口/spacer/行号反查/指针换算/边缘滚动八接入点);折叠事务带 scrollTop 补偿(锚点=视口顶部 model 行号,折叠视口上方块无跳变);选区端点写入前钳制,syncDomSelection 修正坐标混用隐患;props.json 变化重置折叠态;chevron(hover 显/已折叠常显)与行尾摘要 {…} N 行;非 JSON 响应折叠 UI 整体关闭(hasFolds 门控)
- endpoint-panel:meta 行「折叠全部/展开全部」(收 depth=1 顶层块,经 defineExpose 通道)
- 新增 6 个视图层测试(折叠渲染/chevron 交互/选区隔离/补偿无跳变/状态重置/非 JSON 降级)
- 附技术博客 docs/JSON预览器代码折叠技术博客.md
- 全量 89/89 四绿(test/type-check/lint/build)
2026-09-09 14:17:42 +08:00
skycurtain b3ad8a5a8e feat(view): JSON 折叠纯函数——括号栈扫描与可见性投影
- scanJsonFolds:括号栈单遍扫描(行尾 {/[ 开块、行首 }/] 收块),end=闭括号行(闭括号随块隐藏;LSP 文档的「闭括号前一行」是 0-based 记法的转述陷阱,注释已记录),单行块不产出,非法结构健壮跳过
- buildVisibleProjection:可见性投影,隐藏语义同 VS Code hidesLine(start < line ≤ end,折叠头可见),嵌套折叠区间覆盖天然正确
- findVisibleIndex:model 行号 → 可视索引反查
- clampLineToVisible:选区端点钳制到折叠头行(Monaco adjustSelections 语义)
- 新增 20 个纯函数单测(扫描规则/嵌套/非法结构/投影边界/钳制)
- 附调研文档 docs/JSON预览器代码折叠调研.md(VS Code/CM6 源码考证 + 交互惯例)
2026-09-09 14:16:51 +08:00
skycurtain 8d6a842055 docs(api): 注意事项文案审查——去除内部调研叙事,改用户视角
- 移除「接口①④」「双重验证」等文档编号/调研过程叙事,改写为用户可独立理解的完整因果或操作指引
- 接口指代全部点名完整路径(usage 接口/同 subscription/billing 接口 → 完整路径)
- ÷500000 硬编码改为 ÷ quota_per_unit(见 /api/status)——消除与「不要硬编码」自相矛盾
- log/token 的 id desc 排序补关键警示:id 是展示序号跨请求无意义,勿用于去重或游标
- 源码细节泛化溯源(Update() Select/MaxRecentItems/abilities 表 → NewAPI 源码验证/硬编码/内部存储)
- 「虚拟滚动的核心场景」改为用户视角「页面已做虚拟滚动优化,可直接流畅浏览」
- 验证:test 64/64 + type-check + lint + build 四绿
2026-09-09 01:40:03 +08:00
skycurtain 86d2ac304b feat(api): 实装 /api/status 接口——无鉴权公开接口,换算比率权威来源
- endpoints:AuthMode 扩展 'none'(完全公开),追加 /api/status 定义(考证实证文案 + 实测示例响应),置于列表首位
- endpoint-info-line:鉴权徽标三态化(公开/宽松/严格,map 驱动 + none 中性样式)
- endpoint-panel:requiresKey——无鉴权接口隐藏 sk-key 输入行、按钮不因空 key 禁用、空态文案区分
- spec:接口数 5→6、顺序、鉴权分布(none+strict×3+lenient×2)断言更新
- docs:接口文档同步 479 行新版(/api/status 详解 + QuotaPerUnit 考证);交互设计 §1 清单加第 0 行;README 与 side-nav 计数更新
- 验证:test 64/64 + type-check + lint + build 四绿
2026-09-09 01:27:02 +08:00
skycurtain 19b69e2d5a docs(deploy): 部署方案——开发机构建 + 服务器轻构建(Caddy)
按熟悉的工作流调整:构建在开发机完成(四绿验证后的 dist 才是部署物),SFTP 上传 dist/Caddyfile/compose.yaml/Dockerfile 四件到服务器,服务器上 docker build 仅做 COPY(无构建工具链需求,秒级)。单阶段 Dockerfile;.dockerignore 不再排除 dist(部署必需品)且继续排除 .env*;README 部署章节重写为三步流程(本机构建/SFTP/服务器 compose)。compose 文件采用 Compose 规范标准名 compose.yaml(docker-compose.yml 仅为向后兼容保留)。
2026-09-08 21:00:19 +08:00
skycurtain d1ddead178 chore: .gitignore 补 .env 规则(防真实网关地址误提交)
compose 部署引入 .env 文件后需要兜底:.env/.env.* 全忽略,!.env.example 保留示例。
2026-09-08 19:48:40 +08:00
skycurtain 00ccfc4849 docs: 新增 AGENTS.md(AI 协作规范与经验教训)
只记录 README 没有的协作专属信息:Bun 运行时约定(禁混用其他包管理器)、验证命令清单、命名规范、SFC 注释位置、测试盲区经验(stub useElementSize/断言渲染内容/能杀死 bug)、架构红线(model 层选区/tabs 纯导航/节流 trailing/rAF 每帧重读输入)、已知边界。
2026-09-08 15:37:39 +08:00
20 changed files with 1312 additions and 80 deletions
+11
View File
@@ -0,0 +1,11 @@
# 服务器轻构建的上下文:只需 dist + Caddyfile(新部署模式,产物在开发机构建)
# 注意:dist 是部署必需品,不再排除
node_modules
.git
.eslintcache
*.tsbuildinfo
# 环境变量文件绝不能进镜像层(真实网关地址在 .env.local 里)
.env
.env.local
.env.*.local
+5
View File
@@ -32,6 +32,11 @@ coverage
/cypress/videos/
/cypress/screenshots/
# 环境变量文件绝不能提交(真实网关地址)
.env
.env.*
!.env.example
# Vitest
__screenshots__/
+50
View File
@@ -0,0 +1,50 @@
# AGENTS.md
面向 AI 编码工具(Claude / Cursor / Codex 等)的项目协作说明。项目概述、运行方式、架构说明见 [README.md](./README.md),本文件只记录 README 没有的协作规范与经验教训。
## 运行时:Bun
- 运行时与包管理器是 **Bun**(依赖锁是 `bun.lock`)。安装依赖用 `bun install`,跑脚本用 `bun run <script>`(如 `bun run dev` / `bun run test`)。
- **不要引入 npm/pnpm/yarn 的产物**:不提交 `package-lock.json` / `pnpm-lock.yaml`,不建议任何需要 lockfile 迁移的依赖操作。
## 常用验证命令
```bash
bun run test # vitest 全量(单元 + 组件 + 集成冒烟)
bun run type-check # vue-tsc
bun run lint # oxlint + eslint
bun run build # type-check + vite build
```
改动完成后按此顺序验证,全绿才算完成。prettier 不在 lint 链里,涉及 `.vue`/`.ts` 文件改动后需另跑 `bunx prettier --write <文件>`。
## 命名规范
- 代码文件名一律 **kebab-case 且语义化完整,宁长勿缩写**:`json-text-view.vue` 而非 `jtv.vue`;变量同理(`endpointList` 优于 `epList`)。
- 模板中引用组件转大驼峰:`import SideNav from '@/components/side-nav.vue'` → `<SideNav />`。
- 项目文档用**中文命名**(`bench实施计划.md`);README.md / AGENTS.md 等约定俗成者除外。
## Vue SFC 书写规范
- **块注释必须写在 `<script setup>` 内部顶部**(import 之前),不能写在 SFC 顶层(块标签之外)——SFC 顶层不是 prettier 认识的注释位置,JSDoc 块会被拍平重排。
- composable(`useXxx`)必须在 `<script setup>` 顶层调用,不能在事件回调/异步函数内调用(provide/inject 失效)。
## 测试经验(防踩坑)
- **jsdom 下容器高度恒为 0**:涉及视口尺寸的组件测试必须 stub `useElementSize`(见 `json-text-view.spec.ts` 顶部的 `vi.mock`),否则虚拟滚动窗口恒空、断言失效。
- **断言"实际渲染内容",不要只断言"结构存在"**。曾发生 script 层重构后模板漏改、结构断言全绿而页面全空的事故。
- **测试有效性的判据是"能杀死 bug"**:修 bug 后可用 `git stash` 对照验证——旧代码下新测试应当失败。
- 测试需要 TanStack Query 时要提供 `QueryClient`(挂载 `EndpointPanel` 一类的组件必须包 `VueQueryPlugin`)。
## 架构红线
- **滚动/选区等交互状态放 model 层**(`json-text-view.vue` 的行号锚点),DOM 只是投影。不要把选区状态存在 DOM Selection 里——虚拟滚动跨窗口边界会卸载 DOM,原生选区必丢。
- **不要往 t-tabs 的 panel 里塞内容**。tabs 采用"纯导航模式"(list 不传 panel,面板外置渲染,`v-show` 切换),面板显隐/布局完全自有。仅存的一条样式钩子(隐藏空 panel 壳)写在 `request-tabs.vue` 样式区,注释里有依赖类名与版本记录。
- **节流连续高频事件(滚动条拖拽)必须开 trailing**:`useThrottleFn(fn, ms, true)`——默认 false 会丢弃节流窗口内的全部事件(白屏事故的根因)。
- **rAF/循环 + 外部输入**:输入必须在循环体内每帧重读,闭包捕获只是快照(自动滚动速度漂移事故的根因)。
## 已知边界(不要当 bug 修)
- build 产物 >500kB(TDesign 全量引入),按需引入留作后续优化,见 README「已知边界」。
- 生产部署无 Vite 代理,网关路径需反向代理;`GATEWAY` 只从环境变量读取,代码不写死默认值。
- 选区随滚动重建会丢失 DOM 层可见选区(model 高亮保留)——虚拟滚动固有行为,非 bug。
+24
View File
@@ -0,0 +1,24 @@
# bench 的 Caddy 配置:静态托管 + 网关反向代理
#
# :80 表示监听 80 端口、禁用自动 HTTPS(容器场景由外层决定 TLS;
# 若将来直接暴露公网域名,把 :80 换成域名,Caddy 会自动签发证书)
#
# GATEWAY 由 docker run -e / compose environment 注入(Caddyfile 支持 {$GATEWAY} 环境变量占位)
# 未注入时 Caddy 启动会因空地址报错——与 vite dev 行为一致:不写死默认值
:80 {
# 静态站点:根目录 + SPA fallback(不存在的路径回退 index.html)
root * /usr/share/caddy
encode gzip
# 接口代理:与 vite.config.ts 的 dev 代理语义一致,转发到网关
@api path /api/* /v1/*
handle @api {
reverse_proxy {$GATEWAY}
}
handle {
try_files {path} /index.html
file_server
}
}
+6
View File
@@ -0,0 +1,6 @@
# bench 运行镜像:仅把本机构建好的 dist 打进 Caddy(构建在开发机完成,见 README 部署章节)
# 服务器上 docker build 只做 COPY,秒级完成,不需要任何构建工具链
FROM caddy:2-alpine
COPY Caddyfile /etc/caddy/Caddyfile
COPY dist /usr/share/caddy
+28 -2
View File
@@ -35,7 +35,7 @@ bun run build # type-check + vite build(dist/)
## 使用
1. 左侧选择接口(5 个,平铺不分组)→ 右侧开 tab
1. 左侧选择接口(9 个,平铺不分组)→ 右侧开 tab
2. tab 内展开「注意事项」看该接口的实证调研坑文档
3. 填 sk-key(每 tab 独立、仅存内存、刷新即重置,不落盘;切换 tab 输入保留)
4. 点「发送请求」→ 响应区显示元信息(状态/耗时/字符数)+ 虚拟滚动高亮 JSON
@@ -50,10 +50,36 @@ bun run build # type-check + vite build(dist/)
## 已知边界
- 生产部署(`bun run build`)无 Vite 代理,需为网关路径配置反向代理
- `useClipboard` 在非 https 环境(如 http://localhost)自动降级 `execCommand`
- `/v1/models/:model` 单模型详情接口未纳入(数据源是硬编码静态表,与 `/v1/models` 不一致,无调试价值)
## 部署(开发机构建 + 服务器轻构建运行)
构建在开发机完成(四绿验证后的 dist 才是部署物),服务器只做镜像 COPY + 起容器,
不需要任何构建工具链。
```bash
# —— 开发机 ——
bun run build # 得到 dist/(此时已过 test/type-check/lint)
# SFTP 上传四个部署物到服务器同一目录:
# dist/ Caddyfile compose.yaml Dockerfile
# (.env 不上传,GATEWAY 在服务器上以 .env 或环境变量提供)
# —— 服务器(目录内需有 .env,内容为 GATEWAY=https://…)——
docker compose up -d --build # 首次:拉 caddy 镜像 + COPY 产物起容器,秒级
# 浏览器打开 http://<服务器IP>:8080
# —— 常规更新(开发机)——
bun run build # 重新构建
# SFTP 覆盖 dist/
docker compose up -d --build # 服务器上重新 COPY + 替换容器
```
- Caddy 承担与 dev 代理相同的职责:静态文件 + `/api`、`/v1` 转发到 `$GATEWAY` + SPA fallback
- 换网关:改服务器上的 `.env` 后 `docker compose up -d --force-recreate`(无需重新 build)
- 暴露公网时把 `Caddyfile` 的 `:80` 改为域名,Caddy 自动签发 HTTPS 证书
- `compose.yaml`(Compose 规范标准文件名)里 `GATEWAY` 为必填(未设置时启动即报错,不会带错误配置运行)
## 文档
- 交互与技术决策:`docs/bench交互设计文档.md`
+21
View File
@@ -0,0 +1,21 @@
# bench 部署编排:服务器上 docker compose up -d 即起
# 前置:开发机已 bun run build 得到 dist/,并把 dist/ Caddyfile/ compose.yaml/ Dockerfile/
# 四项经 SFTP 上传到服务器同一目录(见 README 部署章节)
#
# 常规更新流程(开发机):
# 1. bun run build
# 2. SFTP 覆盖上传 dist/ 目录
# 3. 服务器上 docker compose up -d --build # 重新 COPY 产物进镜像并滚动替换容器
services:
bench:
build:
context: .
image: new-api-key-bench
container_name: bench
ports:
- "8080:80"
environment:
# 必填:NewAPI 网关地址(Caddyfile 的 {$GATEWAY} 占位由此注入)
GATEWAY: ${GATEWAY:?请在 .env 或环境中设置 GATEWAY,例如 GATEWAY=https://your-gateway.example.com}
restart: unless-stopped
@@ -0,0 +1,121 @@
# 给 JSON 预览器加上代码折叠:一次"投影层"的扩展实践
> 这篇博客记录 bench 项目 JSON 预览组件(`json-text-view.vue`)新增代码块折叠的完整过程:为什么折叠在虚拟滚动上不是"再渲染一个树"的问题,VS Code 和 CodeMirror 6 的折叠机制长什么样,以及把折叠嫁接到"等高行虚拟滚动 + model 层选区"架构上的具体改法。是前一篇《给两万行 JSON 做一个不卡、可选中、还能拖拽选行的预览器》的续篇,目标读者不变——想理解"编辑器类组件"设计思路的工程师(也就是我自己的学习笔记)。
## 一、问题:两万行的 JSON,少一半是"看不见的括号"
前一篇的预览器解决了"卡"和"选不中",但 `/api/log/token` 两万行的响应里,真正有信息量的键值对可能只占一半——剩下的一半是结构性的括号行和被嵌套展开的深层字段。读数据时人脑做的事情和折叠功能完全一致:**盯着顶层 key,跳过暂时不关心的子树**。
所以目标是 VS Code 式的代码块折叠:点击折叠箭头,一个对象/数组收成一行 `{…} N 行`;再点开,原样展开。听起来像"给列表加个树形控件",但对虚拟滚动组件来说,这个需求击中了架构的一个隐藏假设——
**现有架构里,"可视坐标 = model 坐标"是一个 1:1 的隐含等式。** 行号 gutter 显示的数字、指针几何换算出的行号、选区存储的行号、滚动高度公式里的行数,全部是同一个数。折叠一旦介入,这个等式就断了:model 里第 100 行,可能在屏幕上第 80 行的位置(前面 20 行被折叠藏掉了)。折叠功能的全部工作,就是把这个隐含等式改造成显式映射,并且不能改坏任何一处旧换算。
## 二、先看标准答案:VS Code 与 CodeMirror 6 怎么做
动手前先考证了两家主流编辑器的源码级实现,结论是它们共享同一个模式,只是实现层不同。
**VS Code / Monaco 的折叠是"隐藏行模型"。** 折叠范围就是 `(startLine, endLine)` 行号对;`HiddenRangeModel` 从折叠状态派生出隐藏区间,view model 把隐藏行**从视图流里移除**——不是 CSS 藏起来,是真的不渲染。语义上有一个关键细节:折叠头的 start 行永远可见,隐藏的是 `start+1 .. end`。折叠后 gutter 仍然显示 model 行号(跳号显示),折叠箭头画在首行行号槽,首行行尾追加 `…`。折叠视口上方的块时,`StableEditorScrollState` 会先记下"视口顶部是哪一行(model 行号) + 视口内像素偏移",折叠重排后反推新的 scrollTop——所以你折叠眼前的块,内容纹丝不动。
**CodeMirror 6 的折叠是"替换装饰"。** 折叠集合是 editor state 的一部分(`StateField<DecorationSet>`),`foldEffect/unfoldEffect` 事务驱动;被折叠的区间用 `Decoration.replace` 收成一个占位 widget(默认 `…`,可自定义)。它和 CM6 自带的 viewport 虚拟渲染天然兼容:替换后可视行数变少,viewport 按新高度算就是了。还有个有趣的细节:**光标移进折叠区时,该折叠自动清除**——编辑器必须保证光标永远落在可见文档上。
**两者的公共抽象,正好就是我们要的:** 折叠范围(model 坐标)+ 折叠位集合 + 可见性投影(隐藏行不进视图流)。状态永远存 model 坐标,渲染永远是投影——和前一篇选区设计的原则完全同构。
JSON 查看器生态(Chrome DevTools、JSON Hero 之类)的交互惯例也顺带考证了:折叠箭头在键名处、折叠后行尾显示摘要、默认全展开、没有快捷键依赖。这些直接决定了我们的交互清单。
## 三、折叠范围怎么算:括号栈,别用缩进
VS Code 对通用文本用"缩进推导"算折叠范围(逐行比较缩进层级),但对 JSON 有专门的实现:官方 `vscode-json-languageservice` 的 `jsonFolding.ts` 用的是**词法扫描 + 括号配对栈**。
我们选括号栈,而且比它还简单。依据是 pretty-print JSON 的两条铁律:
1. **每行至多一个结构括号**(开括号在行尾,或整行就是闭括号)
2. **字符串值不会跨行**(`JSON.stringify` 不产生多行字符串,字符串里的 `{` 被引号包裹)
于是逐行扫描只需判断两端:
```
行尾(trimEnd 后)是 { 或 [ → 开块,压栈 {startLine, kind}
行首(首个非空白)是 } 或 ] → 弹栈,产出 fold{ startLine, endLine: closeLine, depth, hiddenLineCount }
```
单遍 O(n),两万行不到 1ms。三个细节值得记:
**end 是闭括号行本身,不是"闭括号前一行"。** LSP 的 `FoldingRange` 文档写的是"end line 是闭括号前一行",但那是 **0-based 记法**——0-based 的"闭括号前一行"换到 1-based 恰好就是闭括号行。我第一版按字面理解实现了 1-based 的"前一行",结果折叠后每个对象都留下一个孤儿 `}` 行,被自己写的测试当场抓住。混用 0/1-based 行号体系是编辑器领域的经典坑,值得写进注释。
**单行块不产出折叠。** `{"a": 1}` 占一行,折叠它没有任何意义;只有 `closeLine > startLine + 1` 才产出。
**非法结构不抛错。** 闭括号比开括号多时弹空栈,跳过该行继续扫。调用方(视图层)会用 `props.json === null` 把非 JSON 响应挡在扫描之外,这里是第二道保险。
## 四、嫁接到虚拟滚动:八个接入点
`visibleModel: number[]` 是新增的核心数据结构——**可见性投影**:未被折叠隐藏的 model 行号按序排列。无折叠时它就是 `[1..N]` 的恒等投影;折叠 20 行后,它比 lineList 短 20。有了它,原架构里所有"行数"的消费者逐个换算:
| 接入点 | 原实现 | 折叠后 |
|---|---|---|
| 可视窗口计算 | 输入 model 总行数 | 输入 `visibleModel.length`(输出变成可视索引区间) |
| 窗口行渲染 | `lineList[index]`,行号 `index+1` | `visibleModel[index]` 反查 model 行号,再取原文 |
| 撑高 spacer | model 行数 × 20px | **可视行数 × 20px**(滚动条随之缩短,这个细节很重要) |
| gutter 宽度 | model 总行数 | **不变**——跳号显示,最大行号还是 model 的 |
| 指针 → 行号 | 几何换算直出 | 先可视索引,再 `visibleModel` 反查(隐藏行不在投影里,天然不会被点到) |
| 边缘自动滚动 | 同上 | 同上 |
| v-for 的 key | model 行号 | **不变**(Vue 复用节点稳定) |
| 选区高亮判断 | model 行号 | **不变**(隐藏行不渲染,高亮自动正确) |
这张表里最微妙的是 **gutter 宽度不动**:折叠后可见的最大行号没变(还是 model 最后一行),宽度按它算,折叠展开时行号列不会抖动。
而窗口计算、spacer 高度换用可视行数,意味着**滚动条的长短随折叠实时变化**——折叠一半内容,滚动条变长一倍。这正是 Chrome DevTools / VS Code 的手感。
## 五、三个真正的难点
### 5.1 折叠视口上方的块,屏幕为什么会跳
折叠让"锚点行之前"的可视行数变少,如果 scrollTop 不动,锚点行会向上瞬移。VS Code 的解法(StableEditorScrollState)在等高行体系下可以做得非常轻:
```
折叠前:anchor = 视口顶部那行的 model 行号,delta = scrollTop − 可视索引 × 20
折叠中:改折叠位 → 重建 visibleModel
折叠后:scrollTop = 锚点的新可视索引 × 20 + delta
```
锚点**必须是 model 行号**——折叠改变的是 model 行的可视索引,不是行本身。另有一个边角:锚点行恰好落在被折叠的区间里时,先把它钳制到折叠头行(它必然可见),补偿基准不失效。jsdom 测这条链路还顺带踩了两个环境坑:fake timers 下 `scrollTop` 是只读的(用 getter/setter 模拟),赋值不触发 scroll 事件(真实浏览器会,测试里手动补派发)。
### 5.2 选区划过隐藏区,存哪、停在谁身上
选区真相在 model 层(前一篇的设计),这一原则不变;变化的是**写入时的钳制**:拖拽把选区划进隐藏区,端点停在折叠头行上(Monaco `adjustSelections` 的语义:收缩,而不是自动展开折叠——那是编辑器的需求,查看器收缩更符合阅读直觉)。
顺带修掉一个存量隐患:原 `syncDomSelection` 里 model 行号和可视索引是混用的(无折叠时恰好等价,所以没暴露)。折叠上线后这个隐患变成必然错位,一并改为"钳制 → 反查可视索引 → 与窗口求交集"。
### 5.3 非 JSON 响应:折叠特性整体关门
`props.json === null` 时预览器降级为纯文本展示。raw 文本没有可靠的括号结构(HTML 里的 `{` 会骗过扫描器),所以折叠三件套(扫描、chevron、摘要)全部以 `hasFolds` 门控。这也是测试抓出来的:第一版 chevron 的占位 span 在纯文本行上也渲染了,行高虽不受影响,但"非 JSON 页面出现幽灵元素"不可接受。
## 六、交互层:抄谁,不抄谁
按生态惯例取舍后的清单:
- **抄 VS Code**:chevron 在 gutter 侧(hover 显示、已折叠常显)、折叠后行尾摘要、跳号显示 model 行号
- **抄 JSON 工具系**:摘要带数量——但我们显示的是"隐藏行数"(`{…} 8 行`)而不是 keys 数。理由:查看器的对象可能巨大,"8 行"直接预告了展开后的篇幅,对"要不要展开"这个决策更实用
- **不抄**:快捷键(查看器没有键盘编辑流)、hover 展开预览(无生态先例)、折叠态持久化(工具"刷新即重置"的哲学)、默认折叠(生态一致地默认全展开)
- **自创一处**:"折叠全部"只收 depth=1 的顶层块——日志 JSON 最常见的诉求是"把每条日志收成一行",嵌套折叠位保留不动,展开顶层即可恢复原状
## 七、复盘:投影层的可扩展性,是被验证出来的
这次扩展让前一篇结尾的原则经受了一次实战检验:
**"真相只放一份,放在离 DOM 最远的那层"——但真相不止一个坐标系。**
model 坐标系(行号、内容、选区)和 view 坐标系(可视索引、scrollTop、spacer 高度)在无折叠时是同一个,折叠功能把它们拆开并用 `visibleModel` 显式连接。整个扩展过程没有动 model 层一行代码,所有改动集中在"投影的生成规则"和"两个坐标系的换算点"上——八个接入点里,四个是"把 model 行数换成可视行数"的一行改动,两个是反查方向的颠倒,真正的新逻辑只有折叠事务和钳制。
测试方面有个值得一提的节奏:第一版实现带着"end = 闭括号前一行"的语义偏差上线,**单测当场红了两条**;视图层第一版带着 SFC 里的非法 `continue` 编译失败,**vitest 的编译环节先于 type-check 报错**。纯函数先行、TDD 锁死语义,这套流程在这类"几何换算密集"的组件上回报极高——坐标系换算的 bug 靠肉眼 review 几乎不可能发现,但一个精确的期望值数组就能锁死它。
如果要继续往编辑器方向走,折叠是继选区之后第二块拼图。下一步的自然延伸:折叠态进 store 的序列化(Monaco/CM6 都支持持久化)、按 depth 分层折叠(实现已留好 `depth` 字段)、以及把"行号选区"升级为"(行,列) 坐标"——那时 model 层才真正需要为列而设计。
---
## 附:代码与测试索引
- `src/lib/json-text-processor.ts` — 纯函数层新增:`scanJsonFolds`(括号栈扫描)、`buildVisibleProjection`(可见性投影)、`findVisibleIndex`(行号反查)、`clampLineToVisible`(选区钳制)
- `src/components/json-text-view.vue` — 视图层:投影接线(八接入点)、折叠事务(scrollTop 补偿)、chevron 与行尾摘要
- `src/components/endpoint-panel.vue` — meta 行「折叠全部 / 展开全部」按钮
- `docs/JSON预览器代码折叠调研.md` — 机制考证全文(VS Code/CM6 源码结论、交互惯例清单)
- 测试:纯函数 20 用例 + 视图层 6 用例(折叠渲染、chevron 交互、选区隔离、补偿无跳变、状态重置、非 JSON 降级)
+128
View File
@@ -0,0 +1,128 @@
# JSON 预览器代码折叠调研
> 如何在 bench 的虚拟滚动 JSON 预览器里实现 VS Code 式的代码块折叠——主流实现机制考证 + 对现有架构的嫁接方案。
>
> 调研日期:2026-09-09 · 方法:VS Code / CodeMirror 6 源码级考证 + JSON 查看器生态惯例 + 现有代码精读
---
## 1. 背景与结论
`/api/log/token` 的响应 pretty-print 后 2~4MB、约两万行,折叠对象/数组能显著降低阅读成本。现有预览器(`json-text-view.vue` + `json-text-processor.ts`)架构:
- 纯函数 processor 切行 + 正则浅切词高亮(仅可视窗口行执行)
- 等高行(20px)虚拟滚动:spacer 撑高 + translateY 窗口偏移 + overscan + trailing 节流
- model 层行选区状态机(锚点行号,DOM Selection 只是同步目标)
**结论:完全可行,且现有架构条件优越。** 主流方案的本质是同一个模式——**折叠范围(model 行号对)+ 折叠位集合 + 可见性投影(隐藏行从视图流移除)**。现有代码"选区存 model 行号、渲染走投影"已经是 model/view 分离,加折叠只是把"目前可视坐标 = model 坐标的 1:1 映射"变成"折叠时跳过隐藏行的映射"。计算方式用**括号栈行级扫描**(VS Code 官方 JSON 语言服务同款),2 万行 O(n) 扫描不到 1ms。
## 2. 主流实现机制
### 2.1 VS Code / Monaco(源码考证)
| 关注点 | 机制 | 出处 |
|--------|------|------|
| 数据结构 | `FoldingRange` = `(startLine, endLine)` 行号对;`FoldingRegions` 平行数组(`Uint32Array` ×2)+ BitField 折叠位 | `foldingRanges.ts` |
| 核心语义 | `hidesLine(line) = start < line ≤ end`——**折叠头行(start)永远可见**,隐藏 `start+1 .. end`(闭括号行也在隐藏区) | 同上 |
| 视图投影 | `HiddenRangeModel` 派生隐藏区间,view model 把隐藏行**从视图流移除**(非 CSS 遮盖);视图行号重排,gutter 显示 **model 行号**(跳号显示) | `hiddenRangeModel.ts` |
| 视觉呈现 | chevron 画在首行 gutter;首行行尾追加 `…` 摘要(`afterContentClassName: 'inline-folded'`) | `foldingDecorations.ts` |
| gutter 交互 | 点击 chevron 图标本身才折叠/展开;控件默认 `mouseover`(悬停显示,已折叠区常显) | `folding.ts` |
| 滚动稳定 | `StableEditorScrollState`:折叠前记录视口顶部锚点(**model 行号** + 视口内像素偏移),折叠后反推新 scrollTop——折叠视口上方的块时视口不跳变 | `stableEditorScroll.ts` |
### 2.2 CodeMirror 6(源码 + 官方文档)
- 折叠态是 editor state 的一部分:`foldState = StateField<DecorationSet>`,`foldEffect/unfoldEffect`(doc offset 区间)事务驱动
- 折叠 = `Decoration.replace({widget})` 把区间收成一个占位 widget(默认 `…`,可自定义「N lines」);**点击占位符展开**
- 与自身 viewport 虚拟渲染天然兼容:替换后可视行数减少,viewport 按行块计算自然适配
- **选区保护**:`foldState.update` 末尾检查选区头——落进折叠区时该折叠自动清除(光标移入即展开)
### 2.3 JSON 查看器生态(Chrome DevTools / Firefox JSONView / JSON Hero)
- 折叠锚点都在**键名行**,摘要写在折叠行行尾(`▸ {…}` / `N keys` / `N items`)
- 默认**全展开**;树形工具无行号槽、无快捷键;编辑器系才有快捷键(`Ctrl+K Ctrl+[` 系)
- 无 hover 展开预览的先例(摘要数字已够)
## 3. 折叠范围计算:括号栈(选型定论)
| 路线 | 原理 | JSON pretty-print 适用性 |
|------|------|--------------------------|
| 缩进推导(VS Code 通用 fallback) | 逐行缩进层级栈式配对 | 需处理空行归属、offSide 语义,JSON 上是绕路 |
| **括号栈行级扫描**(VS Code JSON 语言服务同款) | 行尾 `{`/`[` 开块压栈;行首 `}`/`]` 收栈 | **零误判且更便宜**(官方 `jsonFolding.ts` 同款) |
选括号栈。pretty-print 后两条铁律:**每行至多一个括号、缩进即结构**(`JSON.stringify` 产物中字符串值不会跨行,行尾判定安全)。规则:
- 开块行:行尾为 `{` 或 `[` → 记录 `start`,压栈
- 闭块行:行首 token 为 `}` 或 `]` → 弹栈,产出 `{start, end: 闭括号行 − 1}`
- **`end = 闭括号前一行`**(与 VS Code LSP `FoldingRange`、jsonFolding 惯例一致)
- **单行块不产出折叠**(`closeLine > start + 1` 才产出,同 jsonFolding 的 `line > startLine + 1`)
- 顺带记录 `depth`(顶层块 = 1)与 `kind`(object/array),供摘要与「折叠全部」用
## 4. 嫁接到现有架构:改动点映射
现有代码的隐藏前提是**可视坐标 = model 坐标**。加折叠 = 显式建立映射。逐点:
| 现有代码 | 现状 | 折叠后 |
|----------|------|--------|
| `lineList` | model 全量行数组 | 不变(model 层真相) |
| `computeVisibleRowWindow(lineList.length, …)` | 输入 model 行数 | 输入 `visibleModel.length`;输出为**可视索引**窗口 |
| `visibleLineList` 行号 `index + 1` | model 行号直取 | `visibleModel[visIdx]` 反查 model 行号;gutter 显示 model 行号(跳号,VS Code 惯例) |
| `totalHeightPx` | `lineList.length × 20` | `visibleModel.length × 20` |
| `gutterWidth` | model 总行数 | **仍用 model 总行数**(跳号显示最大行号不变) |
| `resolveLineFromPointer` | 几何换算直出 model 行号 | 先出可视索引,反查 model 行号(选区保持 model 坐标;几何反查天然不会命中隐藏行) |
| 边缘自动滚动 `lineAtEdge` | model 行号 | 同上反查 |
| v-for `:key` | model 行号 | 不变(Vue 复用稳定) |
| `isLineSelected` | model 行号判断 | 不变(隐藏行不渲染,高亮自动正确) |
**新增 model 层状态**:
1. `folds: JsonFoldRange[]`——扫描产物,按 start 升序天然嵌套
2. `collapsedSet: Set<startLine>`——已折叠集合(`isCollapsed` O(1))
3. `visibleModel: number[]`——投影数组:未被隐藏的 model 行号按序排列(2 万行 ≈ 80KB;全量重建 <1ms,先全量,不做局部增量)
## 5. 三个关键难点与解法
### 5.1 scrollTop 跳变补偿
VS Code `StableEditorScrollState` 的等高行等价实现:
```
折叠事务前:anchorModel = visibleModel[floor(scrollTop / 20)]
deltaPx = scrollTop − visIdx × 20 (scrollTop = 0 时跳过)
折叠事务中:改 collapsedSet → 全量重建 visibleModel
折叠事务后:newScrollTop = findVisibleIndex(anchorModel) × 20 + deltaPx
```
锚点必须是 **model 行号**——折叠只改变 model 行的可视索引,不改变行本身。
### 5.2 选区与隐藏行冲突
采用 **Monaco 语义(收缩)** 而非 CM6 语义(自动展开)——查看器无编辑,收缩更符合阅读直觉:
- 选区端点落入隐藏区(`start < line ≤ end`)→ 钳制到折叠头行 `start`
- 复制语义不受损:选区提取按 model 行号在 `lineList` 纯数据操作,折叠区内内容依然完整可复制
- `syncDomSelection` 需改为:选区端点先钳制到可见,再转可视索引,与可视窗口求交集后索引 DOM(原实现 model/可视坐标混用,折叠后会错位)
### 5.3 非 JSON 响应降级与状态重置
- `props.json === null`(非 JSON 降级为纯文本)→ **不扫描、无 chevron、无摘要**:raw 文本无可靠括号结构(HTML 里的 `{` 会误判),与现有降级链路一致
- **切换响应(`props.json` 变化)时必须重置 `collapsedSet`**——新数据的 folds 是新扫描结果,旧行号语义已失效
## 6. 交互清单(按生态惯例取舍)
| 项 | 决策 | 依据 |
|----|------|------|
| gutter chevron(hover 显 / 已折叠常显) | ✅ | VS Code `mouseover` 控件惯例 |
| 行尾摘要 `{…} N 行`,点击展开 | ✅ | 编辑器系 `…` + JSON 工具系行数计数的并存;行数可预判展开长度,适合日志大 JSON |
| 全部折叠(收 depth=1 顶层)/ 全部展开,放响应 meta 行 | ✅ | 2 万行场景最高频操作 |
| 默认全展开 | ✅ | 生态一致,不自作主张 |
| 快捷键 `Ctrl+K Ctrl+[` 系 | ❌ 暂缓 | 查看器无键盘编辑流 |
| hover 展开预览 | ❌ | 生态无先例 |
| 分层折叠(折叠到 depth N) | 🕐 后议 | 可用「折叠到第 N 层」一键替代 |
| 折叠态持久化 | ❌ | bench「无持久化、刷新即重置」哲学 |
## 7. 实施拆分
1. **纯函数先行**(`json-text-processor.ts` + 单测):`scanJsonFolds` / `buildVisibleProjection` / `findVisibleIndex` / `clampLineToVisible`
2. **视图接线**(`json-text-view.vue`):投影层改造上表 8 接入点 + chevron + 行尾摘要 + scrollTop 补偿事务 + `defineExpose`(collapseAll/expandAll/hasFolds)
3. **meta 行按钮**(`endpoint-panel.vue`):折叠全部 / 展开全部
4. **回归**:全量测试 + 新增折叠用例(重点:折叠后滚动、嵌套折叠不重复计数、补偿无跳变)
+52 -2
View File
@@ -37,6 +37,14 @@ Authorization: Bearer sk-xxxxxxxxxxxx
|------|-----|------|
| `QuotaPerUnit` | 500000 | 实测验证:接口① `total_used` ÷ 500000 = 接口④ `total_usage` ÷ 100 |
**此值是 NewAPI 的默认值(源码 `common/constants.go:22`:`var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens`),并非硬编码定律**:
- 它是 `var` 且挂入 OptionMap(`model/option.go:166`),站长可在后台修改(`model/option.go:605` 会用数据库值覆盖)
- 接口①④交叉实测吻合的原因:billing 接口内部就是拿 quota `÷ QuotaPerUnit` 换算(`controller/billing.go:50`),同一常量自证,**吻合验证不了管理员是否改过此值**
- **正确做法:换算比率不硬编码,从 `/api/status` 的 `quota_per_unit` 字段读取**(见接口⓪),启动时拉取并定期校验,站点改配置后工具自动跟随
> `500000` 的设计来由:NewAPI(继承自 One API)以 OpenAI 早期基准定价 **$0.002 / 1K tokens** 为锚点,内部用整数 quota 计费避免浮点误差,1 quota = $0.000002,反推 1 美元 = 500,000 quota。
### 关键开关:DisplayTokenStatEnabled
| 接口 | 受开关影响? |
@@ -54,6 +62,7 @@ Authorization: Bearer sk-xxxxxxxxxxxx
| 序号 | 方法 | 路径 | 鉴权 | 用途 |
|------|------|------|------|------|
| ⓪ | GET | `/api/status` | **无(完全公开)** | 站点配置与计费参数(含 `quota_per_unit`),工具校准换算比率的依据 |
| ① | GET | `/api/usage/token` | TokenAuthReadOnly | 查 token 的累计已用/剩余/总额(原始 quota) |
| ② | GET | `/api/log/token` | TokenAuthReadOnly | 查该 key 最近 1000 条调用日志 |
| ③ | GET | `/v1/dashboard/billing/subscription` | TokenAuth | 查总额度上限(OpenAI 兼容,美元) |
@@ -73,6 +82,45 @@ Authorization: Bearer sk-xxxxxxxxxxxx
## 2. 用量/额度查询接口详解
### ⓪ GET `/api/status` - 站点配置与计费参数(完全公开)
**鉴权**:无。挂载于 `router/api-router.go:26`,在鉴权中间件组之外,任何人(不持有任何 key)可直接访问。本清单中唯一无需凭证的接口。
**请求参数**:无
**请求示例**:
```
GET /api/status
```
**返回结构**(源码:`controller/misc.go:44` -> `GetStatus`):外层为 `{ success, message, data }`,`data` 含 60+ 字段(版本、登录方式、前端模块开关、公告等)。**与本项目相关的计费字段**:
| 字段 | 类型 | 说明 | 实测值(2026-09-09) |
|------|------|------|------|
| `quota_per_unit` | float | **QuotaPerUnit 当前生效值**。quota ÷ 该值 = 美元。管理员可改,工具不得硬编码 | `500000` |
| `quota_display_type` | string | 站点额度展示模式:`USD` / `CNY` / `TOKENS`。影响 billing 接口(③④)的语义 | `"USD"` |
| `usd_exchange_rate` | float | USD→CNY 展示汇率(`quota_display_type=CNY` 时 billing 接口用它换算) | `6.8` |
| `display_in_currency` | bool | 是否以货币展示额度(旧字段,兼容保留) | `true` |
| `custom_currency_symbol` | string | 自定义货币符号(`¤` = 未自定义) | `"¤"` |
| `custom_currency_exchange_rate` | float | 自定义货币汇率 | `1` |
| `price` | float | 充值价格(人民币/单位),站点充值汇率相关 | `6.8` |
| `version` | string | NewAPI 版本号 | `"v1.0.0-rc.35"` |
**对本项目的意义**:
1. **校准换算比率**:`quota_per_unit` 是工具换算美元的唯一权威来源——管理员改配置后,工具启动时拉取 + 定期校验即可自动跟随,不会算错
2. **感知展示模式切换**:若管理员把 `quota_display_type` 切到 `CNY` 或 `TOKENS`,billing 接口(③④)返回的数字语义会变(CNY 是先 ÷QuotaPerUnit 再 ×`usd_exchange_rate`;TOKENS 直接返回 token 数),工具可提前探测到,避免拿旧逻辑解析新语义
3. **零成本**:完全公开、无鉴权、无调用门槛,可放心定期轮询
**注意事项**:
- 返回字段很多且含大量前端 UI 配置(`HeaderNavModules`、`SidebarModulesAdmin` 等),解析时只取计费相关字段,其余忽略
- 字段集合随版本演进(如开启公告开关会追加 `announcements`),解析需容忍未知字段
- 该接口不泄露敏感信息(secret 类 OptionMap 键已被 NewAPI 过滤),这也是它敢完全公开的原因
---
### ① GET `/api/usage/token` - token 级用量汇总
**鉴权**:`TokenAuthReadOnly()`(宽松版,key 过期/耗尽也能查)
@@ -364,7 +412,7 @@ total_usage = 134683.8914 美分 ÷ 100 = 1346.84 美元
### 核心换算公式
```
原始 quota ÷ 500000 = 美元
原始 quota ÷ quota_per_unit(/api/status 实时读取,当前站点值 500000)= 美元
```
### 实测数据验证
@@ -423,7 +471,9 @@ total_usage = 134683.8914 美分 ÷ 100 = 1346.84 美元
| billing 查询 | `controller/billing.go:75` | `GetUsage` |
| 显示开关 | `common/constants.go` | `DisplayTokenStatEnabled` |
| 日志上限 | `common/constants.go` | `MaxRecentItems = 1000` |
| 配额单位 | `common/constants.go` | `QuotaPerUnit`(默认 500000) |
| 配额单位 | `common/constants.go:22` | `QuotaPerUnit`(var,默认 500000,`// $0.002 / 1K tokens`,可被 OptionMap 覆盖) |
| 配额单位(覆盖入口) | `model/option.go:605` | `case "QuotaPerUnit"` 运行时改写 |
| 站点配置暴露 | `controller/misc.go:44` + `router/api-router.go:26` | `GetStatus`(`/api/status`,无鉴权,含 `quota_per_unit`) |
| token 更新 | `model/token.go` | `Update()`(Select 不含 used_quota) |
| 额度增减 | `model/token.go` | `increaseTokenQuota` / `decreaseTokenQuota` |
+13 -9
View File
@@ -8,20 +8,24 @@
## 1. 项目定位
替代 Postman 的专用调试台(bench):左侧选接口 → 右侧 tab 内看文档、填 key、发请求、看原始响应。对接 5 个接口,服务 karpool 拼车项目的接口调试与换算验证。
替代 Postman 的专用调试台(bench):左侧选接口 → 右侧 tab 内看文档、填 key、发请求、看原始响应。对接 9 个接口,服务 karpool 拼车项目的接口调试与换算验证。
**接口清单**(数据源:`docs/NewAPI-sk-key接口文档.md`,按此顺序展示,不分组):
| 序 | 路径 | 鉴权 | 响应特征 |
|---|------|------|---------|
| 1 | `GET /v1/dashboard/billing/subscription` | TokenAuth(严格) | 小 JSON,美元额度(无限额时 1 亿占位) |
| 2 | `GET /v1/dashboard/billing/usage` | TokenAuth(严格) | 小 JSON,累计美分 |
| 3 | `GET /v1/models` | TokenAuth(严格) | 中等 JSON,模型数组 |
| 4 | `GET /api/usage/token` | TokenAuthReadOnly(宽松) | 小 JSON,累计 quota |
| 5 | `GET /api/log/token` | TokenAuthReadOnly(宽松) | **大响应**:最近 1000 条日志,pretty-print 2~4MB |
| 0 | `GET /api/user/groups` | 无(完全公开) | 用户分组与倍率列表;未启用分组时为空对象 |
| 1 | `GET /api/perf-metrics/summary` | 公开门控(默认无需凭证) | 全站模型服务质量:延迟/成功率/TPS/小时级序列;`hours` 参数 |
| 2 | `GET /api/rankings` | 公开门控(默认无需凭证) | 全站模型用量排行:名次/份额/环比增长;`period` 参数 |
| 3 | `GET /api/status` | 无(完全公开) | 站点配置与计费参数:`quota_per_unit`(换算比率权威来源)、`quota_display_type`、汇率等 |
| 4 | `GET /v1/dashboard/billing/subscription` | TokenAuth(严格) | 小 JSON,美元额度(无限额时 1 亿占位) |
| 5 | `GET /v1/dashboard/billing/usage` | TokenAuth(严格) | 小 JSON,累计美分 |
| 6 | `GET /v1/models` | TokenAuth(严格) | 中等 JSON,模型数组 |
| 7 | `GET /api/usage/token` | TokenAuthReadOnly(宽松) | 小 JSON,累计 quota |
| 8 | `GET /api/log/token` | TokenAuthReadOnly(宽松) | **大响应**:最近 1000 条日志,pretty-print 2~4MB |
> 4、5 宽松鉴权(key 过期/耗尽仍可查);1~3 严格鉴权(key 无效直接被拒)。tab 内标出该差异,避免误判「接口坏了」。
> 4 与 2 构成换算交叉验证对(total_used ÷ 500000 ≈ total_usage ÷ 100),Tabs 并行对账是核心工作流。
> 0~3 完全公开或默认公开(面板隐藏 sk-key 输入行);7、8 宽松鉴权(key 过期/耗尽仍可查);4~6 严格鉴权(key 无效直接被拒)。tab 内标出该差异,避免误判「接口坏了」。
> 7 与 5 构成换算交叉验证对(total_used ÷ 500000 ≈ total_usage ÷ 100),Tabs 并行对账是核心工作流。3 是换算比率的权威来源(quota_per_unit 可被管理员修改,500000 只是默认值)。1、2 是「全站视角」数据——单个 sk-key 日志看不到的服务质量与用量排行,服务模型健康度判断。
---
@@ -52,7 +56,7 @@
无分组无编号,按清单顺序)
```
- **左 sider**(~300px):接口列表,**不分组、不编号**,按 §1 清单顺序平铺 5 个路径;选中态高亮
- **左 sider**(~300px):接口列表,**不分组、不编号**,按 §1 清单顺序平铺 9 个路径;选中态高亮
- **右主区**:`t-tabs` 顶部,激活 tab 内容 = 请求行(method + 路径 + 鉴权徽标)→ 调试行(sk-key 输入 + 发送按钮)→ 响应区(元信息 + 虚拟滚动 JSON)
- 暗色默认(TDesign dark theme + CSS 变量)
+13 -5
View File
@@ -3,17 +3,21 @@ import { describe, expect, it } from 'vitest';
import { endpointList, findEndpointByPath } from './endpoints';
describe('endpointList 数据完整性', () => {
it('恰好 5 个接口(设计文档 §1 定案的对接清单)', () => {
expect(endpointList).toHaveLength(5);
it('恰好 9 个接口(设计文档 §1 定案的对接清单)', () => {
expect(endpointList).toHaveLength(9);
});
it('路径唯一(path 是 tab / queryKey 的唯一标识)', () => {
const pathList = endpointList.map((endpoint) => endpoint.path);
expect(new Set(pathList).size).toBe(5);
expect(new Set(pathList).size).toBe(9);
});
it('顺序与用户指定清单一致', () => {
expect(endpointList.map((endpoint) => endpoint.path)).toEqual([
'/api/user/groups',
'/api/perf-metrics/summary',
'/api/rankings',
'/api/status',
'/v1/dashboard/billing/subscription',
'/v1/dashboard/billing/usage',
'/v1/models',
@@ -25,13 +29,17 @@ describe('endpointList 数据完整性', () => {
it('全部为 GET 且带鉴权元数据', () => {
for (const endpoint of endpointList) {
expect(endpoint.method).toBe('GET');
expect(['strict', 'lenient']).toContain(endpoint.authMode);
expect(['strict', 'lenient', 'none']).toContain(endpoint.authMode);
expect(endpoint.authMiddlewareName).toBeTruthy();
}
});
it('严格/宽松鉴权分布正确(前 3 严格,后 2 宽松)', () => {
it('鉴权模式分布正确(公开 4 + 严格 3 + 宽松 2)', () => {
expect(endpointList.map((endpoint) => endpoint.authMode)).toEqual([
'none',
'none',
'none',
'none',
'strict',
'strict',
'strict',
+120 -12
View File
@@ -1,17 +1,20 @@
/**
* 5 个对接接口的静态元数据
* 对接接口的静态元数据
* 数据源:docs/NewAPI-sk-key接口文档.md(源码实证调研)
* 顺序即 sider / tab 展示顺序(设计文档 §1 定案,不分组不编号)
*
* 鉴权三态:none = 完全公开(无需任何凭证)→ strict = TokenAuth → lenient = TokenAuthReadOnly
* 公开接口组(/api/user/groups、/api/perf-metrics/summary、/api/rankings、/api/status)排在清单最前
*/
export type AuthMode = 'strict' | 'lenient';
export type AuthMode = 'strict' | 'lenient' | 'none';
export interface EndpointDefinition {
/** 接口路径(唯一标识,同时用作 tab 标签与 queryKey 组成部分) */
readonly path: string;
/** HTTP 方法(当前全部为 GET,保留扩展位) */
readonly method: 'GET';
/** 鉴权模式:strict = TokenAuth(key 无效/过期/耗尽直接被拒);lenient = TokenAuthReadOnly(宽松,废 key 也能查) */
/** 鉴权模式:strict = TokenAuth(key 无效/过期/耗尽直接被拒);lenient = TokenAuthReadOnly(宽松,废 key 也能查);none = 完全公开(无需任何凭证) */
readonly authMode: AuthMode;
/** 鉴权中间件名(展示用) */
readonly authMiddlewareName: string;
@@ -24,6 +27,111 @@ export interface EndpointDefinition {
}
export const endpointList: readonly EndpointDefinition[] = [
{
path: '/api/user/groups',
method: 'GET',
authMode: 'none',
authMiddlewareName: '无(公开接口)',
responseSummary: '用户分组与倍率列表(未启用分组时为空对象)',
notes: [
'完全公开:无任何鉴权中间件(本工具对接清单中三个免凭证接口之一)',
'返回站点配置的用户分组(default / vip 等)及其倍率;站点未启用分组时 data 为空对象',
'用途:解释「同一模型为什么计费不同」——分组倍率会影响同一模型的实际消耗',
'与 /api/pricing 的 group_ratio 字段互补:本接口是分组清单,pricing 是模型定价',
],
sampleResponse: { success: true, message: '', data: {} },
},
{
path: '/api/perf-metrics/summary',
method: 'GET',
authMode: 'none',
authMiddlewareName: 'HeaderNavModulePublicOrUserAuth(默认公开)',
responseSummary: '全站模型服务质量汇总(延迟/成功率/TPS)',
notes: [
'公开接口:管理员未在 HeaderNav 中开启 pricing 模块的 requireAuth 时无需任何凭证',
'query 参数 hours:统计窗口小时数(默认 24)',
'返回每个模型的 avg_latency_ms(平均延迟)、success_rate(成功率 %)、avg_tps(每秒 token 数)、recent_success_series(按小时采样的成功率时间序列)',
'对拼车工具的意义:单个 sk-key 日志看不到全局服务质量,本接口补上「模型当前健康度」视角——伙伴反馈「慢/报错多」时用它定位是模型问题还是自身问题',
'明细版 /api/perf-metrics?model=xxx 需 query 参数(model 必填,另支持 group/hours),面板暂不支持 query 输入,可用上方注意事项了解参数后经代理手动拼接',
],
sampleResponse: {
success: true,
data: {
models: [
{
model_name: 'gpt-5.6-sol',
avg_latency_ms: 16823,
success_rate: 94.73,
avg_tps: 25.81,
recent_success_series: [
{ ts: 1788872400, success_rate: 100 },
{ ts: 1788876000, success_rate: 99.16 },
],
},
],
},
},
},
{
path: '/api/rankings',
method: 'GET',
authMode: 'none',
authMiddlewareName: 'HeaderNavModulePublicOrUserAuth(默认公开)',
responseSummary: '全站模型用量排行(名次/份额/增长)',
notes: [
'公开接口:同 perf-metrics,受 HeaderNav 模块开关控制,默认无需凭证',
'query 参数 period:统计周期(默认 week)',
'返回每个模型的 rank(名次)、previous_rank(上期名次)、total_tokens(总用量)、share(份额)、growth_pct(环比增长 %)',
'对拼车工具的意义:看全站都在用什么模型——车长选「主推模型」(便宜且稳定)的数据依据;配合 perf-metrics 的成功率可识别衰退模型',
],
sampleResponse: {
success: true,
data: {
models: [
{
rank: 1,
previous_rank: 2,
model_name: 'gpt-5.6-sol',
vendor: 'OpenAI',
vendor_icon: 'OpenAI',
category: 'all',
total_tokens: 1583845480,
share: 0.6549,
growth_pct: 60.4253,
},
],
},
},
},
{
path: '/api/status',
method: 'GET',
authMode: 'none',
authMiddlewareName: '无(公开接口)',
responseSummary: '站点配置与计费参数(含 quota_per_unit)',
notes: [
'完全公开:无任何鉴权,不持有 sk-key 也能访问(对接清单公开接口组之一)',
'quota_per_unit 是 QuotaPerUnit 当前生效值(默认 500000,NewAPI 以 $0.002 / 1K tokens 的基准定价为锚点设计)——quota ÷ 该值 = 美元,换算比率的唯一权威来源,管理员可改,不要硬编码',
'想用 billing 接口(subscription / usage)反推换算比率不可靠:它们内部就是拿 quota ÷ QuotaPerUnit 换算的,站点改了配置它们会跟着变,永远「吻合」——比率唯一权威只有本接口的 quota_per_unit',
'quota_display_type 影响 /v1/dashboard/billing/subscription 与 /v1/dashboard/billing/usage 的语义:USD 直接是美元;CNY 先 ÷QuotaPerUnit 再 ×usd_exchange_rate;TOKENS 是原始 token 数',
'返回 60+ 字段(版本、登录方式、前端模块开关、公告等),解析时只取计费相关字段,其余忽略;字段集合随版本演进,需容忍未知字段',
],
sampleResponse: {
success: true,
message: '',
data: {
quota_per_unit: 500000,
quota_display_type: 'USD',
usd_exchange_rate: 6.8,
display_in_currency: true,
custom_currency_symbol: '¤',
custom_currency_exchange_rate: 1,
price: 6.8,
system_name: 'AI Gateway',
version: 'v1.0.0-rc.35',
},
},
},
{
path: '/v1/dashboard/billing/subscription',
method: 'GET',
@@ -34,7 +142,7 @@ export const endpointList: readonly EndpointDefinition[] = [
'受 DisplayTokenStatEnabled 开关影响:开启返回 token 级额度,关闭回退到账号级额度(会暴露整个账号余额)',
'无限额度 key 返回 100000000(一亿)占位值,无参考意义',
'受 quota_display_type 影响:USD 模式才是美元;CNY 模式会做货币转换;Tokens 模式是原始 token 数',
'hard_limit_usd(本接口)− total_usage ÷ 100(usage 接口)= 剩余额度(仅有限额 key 有意义)',
'hard_limit_usd(本接口)− total_usage ÷ 100(/v1/dashboard/billing/usage)= 剩余额度(仅有限额 key 有意义)',
],
sampleResponse: {
object: 'billing_subscription',
@@ -54,8 +162,8 @@ export const endpointList: readonly EndpointDefinition[] = [
notes: [
'total_usage 单位是美分,换算美元 ÷ 100',
'累计值(key 创建至今),非当月用量',
'与 /api/usage/token 的 total_used ÷ 500000 完全吻合(QuotaPerUnit = 500000 的双重验证)',
'受 DisplayTokenStatEnabled 与 quota_display_type 影响(同 subscription 接口)',
'可与 /api/usage/token 的 total_used ÷ quota_per_unit 交叉对账(两接口应一致;若不一致,说明站点改过 quota_per_unit,见 /api/status)',
'受 DisplayTokenStatEnabled 与 quota_display_type 影响(同 /v1/dashboard/billing/subscription)',
],
sampleResponse: { object: 'list', total_usage: 134683.8914 },
},
@@ -66,7 +174,7 @@ export const endpointList: readonly EndpointDefinition[] = [
authMiddlewareName: 'TokenAuth',
responseSummary: '可用模型列表(OpenAI 格式)',
notes: [
'数据源是数据库 abilities 表,反映渠道实际配置的模型',
'数据源是数据库中渠道实际配置的模型表(NewAPI 内部存储),反映渠道真实配置',
'与 /v1/models/:model 单查接口数据源不同(后者查代码内硬编码静态表)',
'渠道新配的模型可能出现「列表里有、单查 model_not_found」,不影响实际调用',
],
@@ -83,9 +191,9 @@ export const endpointList: readonly EndpointDefinition[] = [
responseSummary: 'token 级用量汇总(原始 quota)',
notes: [
'total_used 是累计值(key 创建至今),非当月用量',
'额度为原始 quota 整数,换算美元 ÷ 500000(QuotaPerUnit)',
'额度为原始 quota 整数,换算美元 ÷ quota_per_unit(见 /api/status,默认 500000)',
'unlimited_quota = true 时 total_granted / total_available 为占位值,无实际意义',
'禁用再解禁不会重置 total_used(源码验证:Update() 的 Select 列表不含 used_quota)',
'禁用再解禁不会重置 total_used(NewAPI 源码验证:token 更新逻辑不覆盖该字段)',
'不受 DisplayTokenStatEnabled 开关影响,恒返回 token 级数据',
],
sampleResponse: {
@@ -111,11 +219,11 @@ export const endpointList: readonly EndpointDefinition[] = [
authMiddlewareName: 'TokenAuthReadOnly',
responseSummary: '最近 1000 条调用日志',
notes: [
'硬上限 1000 条(common.MaxRecentItems = 1000)',
'硬上限 1000 条(NewAPI 源码硬编码)',
'不支持任何请求参数:分页 / limit / 时间过滤全被忽略',
'固定按 id desc 排序,即最新 1000 条',
'固定最新在前(服务端按内部序号倒序);响应里的 id 是展示序号,跨请求无意义,勿用于去重或游标',
'月调用量超 1000 次时,此接口无法覆盖完整当月',
'大响应:pretty-print 后 2~4MB,约两万行——虚拟滚动的核心场景',
'大响应:pretty-print 后 2~4MB,约两万行——页面已做虚拟滚动优化,可直接流畅浏览',
],
sampleResponse: {
success: true,
+23 -3
View File
@@ -1,14 +1,29 @@
<script setup lang="ts">
/**
* 接口信息行:method 徽标 + 路径 + 鉴权徽标(宽松/严格)
* 接口信息行:method 徽标 + 路径 + 鉴权徽标(公开/宽松/严格)
*/
import type { EndpointDefinition } from '@/api/endpoints';
const props = defineProps<{ endpoint: EndpointDefinition }>();
const authBadgeText = props.endpoint.authMode === 'lenient' ? '宽松' : '严格';
const authBadgeTitle = `${props.endpoint.authMiddlewareName}(${
const authBadgeTextMap: Record<string, string> = {
lenient: '宽松',
strict: '严格',
none: '公开',
};
const authBadgeTitleMap: Record<string, string> = {
lenient: 'TokenAuthReadOnly(key 过期/禁用/额度耗尽仍可查询)',
strict: 'TokenAuth(key 无效/过期/额度耗尽会被拒绝)',
none: '无鉴权中间件(不持有任何 key 也能访问)',
};
const authBadgeText = authBadgeTextMap[props.endpoint.authMode] ?? '未知';
const authBadgeTitle =
props.endpoint.authMode === 'none'
? authBadgeTitleMap.none
: `${props.endpoint.authMiddlewareName}(${
props.endpoint.authMode === 'lenient'
? 'key 过期/禁用/额度耗尽仍可查询'
: 'key 无效/过期/额度耗尽会被拒绝'
@@ -64,6 +79,11 @@ const authBadgeTitle = `${props.endpoint.authMiddlewareName}(${
border: 1px solid var(--td-success-color);
}
.auth-badge.none {
color: var(--td-text-color-secondary);
border: 1px solid var(--td-component-border);
}
.response-summary {
font-size: 12px;
color: var(--td-text-color-placeholder);
+48 -7
View File
@@ -9,7 +9,7 @@
*/
import { useClipboard } from '@vueuse/core';
import { computed } from 'vue';
import { computed, ref, watch } from 'vue';
import { findEndpointByPath } from '@/api/endpoints';
import { useManualEndpointQuery } from '@/api/endpoint-queries';
@@ -44,15 +44,42 @@ const {
sendRequest,
} = useManualEndpointQuery(endpointPathRef, skKeyInput);
/** 无鉴权接口(如 /api/status)不需要 sk-key:隐藏输入行、按钮不因空 key 禁用 */
const requiresKey = computed(() => endpoint.value.authMode !== 'none');
const keyInputStatus = computed(() =>
hasSent.value && !skKeyInput.value.trim() ? 'error' : 'default',
hasSent.value && requiresKey.value && !skKeyInput.value.trim() ? 'error' : 'default',
);
const sendButtonDisabled = computed(() => requiresKey.value && !skKeyInput.value.trim());
function handleSend(): void {
if (!skKeyInput.value.trim()) return; // 空 key 不发(输入框已标红提示)
if (sendButtonDisabled.value) return; // 空 key 不发(输入框已标红提示);无鉴权接口直接发
sendRequest();
}
/* ---------- 折叠全部 / 展开全部(JsonTextView defineExpose 通道) ---------- */
const jsonTextViewRef = ref<InstanceType<typeof JsonTextView> | null>(null);
const foldable = ref(false);
// hasFolds 是 JsonTextView 内部的 computed,经 watch 桥接为面板侧响应式标志
watch(
() => jsonTextViewRef.value?.hasFolds,
(value) => {
foldable.value = value === true;
},
{ immediate: true },
);
function collapseAllFolds(): void {
jsonTextViewRef.value?.collapseAll();
}
function expandAllFolds(): void {
jsonTextViewRef.value?.expandAll();
}
/* ---------- 响应区操作:复制 / 下载 ---------- */
const {
@@ -100,10 +127,11 @@ function downloadResponseAsFile(): void {
</t-collapse>
</section>
<!-- 调试行:sk-key + 发送 -->
<!-- 调试行:sk-key + 发送(无鉴权接口隐藏 key 输入) -->
<section class="panel-section">
<div class="request-row">
<t-input
v-if="requiresKey"
v-model="skKeyInput"
class="sk-key-input"
type="password"
@@ -113,10 +141,11 @@ function downloadResponseAsFile(): void {
:tips="keyInputStatus === 'error' ? '请填写 sk-key' : ''"
@enter="handleSend"
/>
<span v-else class="no-key-hint">公开接口,无需 sk-key</span>
<t-button
theme="primary"
:loading="isFetching"
:disabled="!skKeyInput.trim()"
:disabled="sendButtonDisabled"
@click="handleSend"
>
发送请求
@@ -143,6 +172,12 @@ function downloadResponseAsFile(): void {
{{ responseData.networkError }}
</span>
<span class="meta-spacer"></span>
<t-button v-if="foldable" size="small" variant="outline" @click="collapseAllFolds">
折叠全部
</t-button>
<t-button v-if="foldable" size="small" variant="outline" @click="expandAllFolds">
展开全部
</t-button>
<t-button
v-if="clipboardSupported"
size="small"
@@ -153,10 +188,10 @@ function downloadResponseAsFile(): void {
</t-button>
<t-button size="small" variant="outline" @click="downloadResponseAsFile">下载</t-button>
</div>
<JsonTextView :json="responseData.json" :raw-text="responseData.text" />
<JsonTextView ref="jsonTextViewRef" :json="responseData.json" :raw-text="responseData.text" />
</section>
<section v-else-if="!hasSent" class="panel-section response-placeholder">
填写 sk-key 后点击「发送请求」
{{ requiresKey ? '填写 sk-key 后点击「发送请求」' : '点击「发送请求」查看站点配置' }}
</section>
</div>
</template>
@@ -186,6 +221,12 @@ function downloadResponseAsFile(): void {
max-width: 480px;
}
.no-key-hint {
align-self: center;
font-size: 12.5px;
color: var(--td-text-color-placeholder);
}
.response-section {
flex: 1;
min-height: 220px; /* 响应区保底高度:窄窗口下不被上部固定分区压缩到不可用 */
+155
View File
@@ -165,3 +165,158 @@ describe('json-text-view 虚拟滚动', () => {
wrapper.unmount();
});
});
/* ---------- 代码块折叠(实施计划 §6 视图层测试) ---------- */
describe('json-text-view 代码块折叠', () => {
/** 9 行单条目 JSON(含嵌套对象):
* 1 { / 2 "data": [ / 3 { / 4 "id": 1 / 5 "model_name": "m" / 6 "quota": 2500 / 7 } / 8 ] / 9 }
* folds: start=3/end=7(depth=3)、start=2/end=8(depth=2)、start=1/end=9(depth=1)
*/
const singleEntryJson = { data: [{ id: 1, model_name: 'm', quota: 2500 }] };
it('全部折叠:spacer 高度缩短为折叠头行数 × 20px,gutter 跳号显示 model 行号,折叠头行带摘要', async () => {
const wrapper = mount(JsonTextView, {
props: { json: singleEntryJson, rawText: '' },
attachTo: document.body,
});
expect(wrapper.find('.scroll-spacer').attributes('style')).toContain('height: 180px'); // 9 行
await (wrapper.vm as unknown as { collapseAll: () => void }).collapseAll();
await wrapper.vm.$nextTick();
// depth=1 顶层块折叠后:可视行只剩折叠头(行 1),其余全部隐藏
const lines = wrapper.findAll('.line');
expect(lines).toHaveLength(1);
expect(wrapper.find('.scroll-spacer').attributes('style')).toContain('height: 20px');
expect(lines[0]!.find('.gutter').text()).toBe('1'); // model 行号
// 折叠头行尾摘要:对象块 → {…} + 隐藏行数 8
expect(lines[0]!.find('.fold-summary').text()).toContain('{…}');
expect(lines[0]!.find('.fold-summary').text()).toContain('8 行');
// chevron 已折叠态常显
expect(lines[0]!.find('.fold-chevron').classes()).toContain('fold-chevron-collapsed');
wrapper.unmount();
});
it('点击 chevron 折叠中间块:隐藏区间行从 DOM 消失、折叠头保留、再点击展开恢复', async () => {
const wrapper = mount(JsonTextView, {
props: { json: singleEntryJson, rawText: '' },
attachTo: document.body,
});
const findLineByGutter = (no: string) =>
wrapper.findAll('.line').find((line) => line.find('.gutter').text() === no);
// 行 2 是数组折叠头("data": [),点击其 chevron 折叠 2..8
const line2 = findLineByGutter('2')!;
expect(line2.find('.fold-chevron').exists()).toBe(true);
await line2.find('.fold-chevron').trigger('click');
await wrapper.vm.$nextTick();
const gutters = wrapper.findAll('.line').map((line) => line.find('.gutter').text());
expect(gutters).toEqual(['1', '2', '9']); // (2, 8] 全部隐藏(含行 8 闭括号)
expect(findLineByGutter('2')!.find('.fold-summary').text()).toContain('[…]');
// 再点击 chevron 展开:恢复全量 9 行
await findLineByGutter('2')!.find('.fold-chevron').trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.findAll('.line')).toHaveLength(9);
expect(wrapper.find('.scroll-spacer').attributes('style')).toContain('height: 180px');
wrapper.unmount();
});
it('chevron / 摘要点击不触发 gutter 选区(mousedown.stop 隔离)', async () => {
const wrapper = mount(JsonTextView, {
props: { json: singleEntryJson, rawText: '' },
attachTo: document.body,
});
const line2 = wrapper.findAll('.line').find((line) => line.find('.gutter').text() === '2')!;
await line2.find('.fold-chevron').trigger('click');
await document.body.dispatchEvent(new MouseEvent('mouseup'));
await wrapper.vm.$nextTick();
// 无任何行进入选中态
expect(wrapper.findAll('.line-selected')).toHaveLength(0);
expect(window.getSelection()!.toString()).toBe('');
wrapper.unmount();
});
it('滚动位置折叠补偿:折叠视口上方的块后,视口顶部锚点行的内容不变(无跳变)', async () => {
vi.useFakeTimers();
try {
// 60 条目 ≈ 308 行(4 行外壳 + 60×5 行/条目 + 1),滚动目标有充足纵深
const text = makeJsonText(60);
const wrapper = mount(JsonTextView, {
props: { json: JSON.parse(text), rawText: text },
attachTo: document.body,
});
const container = wrapper.find('.json-text-view');
// 滚到 2000px(可视行 100 起)——深处条目中部
// fake timers 下 jsdom 不派发布局滚动,scrollTop 需用可写 getter/setter 模拟真实行为
let currentScrollTop = 0;
Object.defineProperty(container.element, 'scrollTop', {
configurable: true,
get: () => currentScrollTop,
set: (value: number) => {
currentScrollTop = value;
},
});
container.element.scrollTop = 2000;
await container.trigger('scroll');
vi.advanceTimersByTime(34); // 节流补执行
await wrapper.vm.$nextTick();
const firstGutterBefore = wrapper.findAll('.line')[0]!.find('.gutter').text();
expect(Number(firstGutterBefore)).toBeGreaterThan(95); // 前提:锚点在深处
// 折叠一个视口上方的顶层块:第二条目 { 在行 8(depth=1),8 ≤ 100 在视口上方
await (wrapper.vm as unknown as { toggleFold: (line: number) => Promise<void> }).toggleFold(
8,
);
// 补偿把 scrollTop 写为 1920;真实浏览器会因 scrollTop 变化派发 scroll 事件,
// 这里手动补派发 + 推进节流 trailing,让 scrollTopPx 跟随到补偿后的位置
await container.trigger('scroll');
vi.advanceTimersByTime(34);
await wrapper.vm.$nextTick();
// 补偿断言:视口顶部仍停在锚点行(model 100,可视索引 96)——内容不跳变
const firstGutterAfter = wrapper.findAll('.line')[0]!.find('.gutter').text();
expect(firstGutterAfter).toBe(firstGutterBefore);
expect(container.element.scrollTop).toBe(1920); // 96 × 20px(隐藏 4 行后的锚点位置)
wrapper.unmount();
} finally {
vi.useRealTimers();
}
});
it('props.json 变化:折叠态清空,恢复全量渲染', async () => {
const wrapper = mount(JsonTextView, {
props: { json: singleEntryJson, rawText: '' },
attachTo: document.body,
});
await (wrapper.vm as unknown as { collapseAll: () => void }).collapseAll();
await wrapper.vm.$nextTick();
expect(wrapper.findAll('.line')).toHaveLength(1); // 折叠生效
await wrapper.setProps({ json: { alpha: 1 }, rawText: '' });
await wrapper.vm.$nextTick();
// 新数据(3 行)不应继承旧折叠位:全量渲染
const lines = wrapper.findAll('.line');
expect(lines).toHaveLength(3);
expect(wrapper.find('.scroll-spacer').attributes('style')).toContain('height: 60px');
expect(lines.map((line) => line.find('.gutter').text())).toEqual(['1', '2', '3']);
wrapper.unmount();
});
it('非 JSON 响应:无 chevron、无摘要(折叠特性整体关闭)', () => {
const wrapper = mount(JsonTextView, {
props: { json: null, rawText: '<html>502</html>' },
attachTo: document.body,
});
expect(wrapper.findAll('.fold-chevron')).toHaveLength(0);
expect(wrapper.findAll('.fold-summary')).toHaveLength(0);
wrapper.unmount();
});
});
+197 -33
View File
@@ -8,19 +8,26 @@
* 实现要点:
* - DOM 恒定:只渲染可视窗口 ± overscan 的行(json-text-processor.computeVisibleRowWindow)
* - 懒高亮:仅对窗口内行做 token 切词(结构化 span),转义与着色在模板层,无 v-html
* - 滚动条:撑高度用总行数 × 行高的占位 div,窗口偏移用 translateY
* - 滚动条:撑高度用「可视行数 × 行高」的占位 div,窗口偏移用 translateY
* - 代码块折叠:括号栈扫描 fold + collapsedSet 投影剔除隐藏行;折叠事务带 scrollTop 补偿
* (锚点 = 视口顶部 model 行号,折叠视口上方块时视口不跳变);选区端点写前钳制
*/
import { useElementSize, useThrottleFn } from '@vueuse/core';
import { computed, ref } from 'vue';
import { computed, nextTick, ref, watch } from 'vue';
import {
buildJsonLineSegments,
buildVisibleProjection,
clampLineToVisible,
computeGutterWidth,
computeVisibleRowWindow,
findVisibleIndex,
formatJsonOrRawText,
JSON_VIEW_ROW_HEIGHT_PX,
scanJsonFolds,
splitTextIntoLines,
type JsonFoldRange,
} from '@/lib/json-text-processor';
const props = defineProps<{
@@ -35,6 +42,92 @@ const props = defineProps<{
const formattedText = computed(() => formatJsonOrRawText(props.json, props.rawText));
const lineList = computed(() => splitTextIntoLines(formattedText.value));
/* ---------- 代码块折叠(model 层状态,调研见 docs/JSON预览器代码折叠调研.md) ----------
*
* 三层模型:folds(扫描产物)+ collapsedSet(折叠位)+ visibleModel(可见性投影)。
* 隐藏语义同 VS Code hidesLine:start < line ≤ end(折叠头行可见、闭括号随块隐藏)。
* 非 JSON 响应(props.json === null)不扫描——raw 文本无可靠括号结构。
*/
const foldList = computed(() => (props.json === null ? [] : scanJsonFolds(lineList.value)));
const collapsedStartLines = ref<ReadonlySet<number>>(new Set());
/** 折叠头行 → fold 查找表(chevron / 行尾摘要渲染用) */
const foldByStartLine = computed(
() => new Map(foldList.value.map((fold) => [fold.startLine, fold])),
);
/** 可见性投影:可视槽位 → model 行号(1-based)。无折叠时为恒等投影。 */
const visibleModel = computed(() =>
buildVisibleProjection(lineList.value.length, foldList.value, collapsedStartLines.value),
);
const hasFolds = computed(() => foldList.value.length > 0);
/**
* 折叠事务:改折叠位 + scrollTop 补偿(VS Code StableEditorScrollState 等价实现)。
* 锚点是 model 行号——折叠只改变 model 行的可视索引,不改变行本身;
* 锚点行被本次折叠藏住时,先钳制到折叠头行,保证补偿基准仍可见。
*/
async function toggleFold(startLine: number): Promise<void> {
const container = scrollContainerRef.value;
const nextCollapsed = new Set(collapsedStartLines.value);
if (nextCollapsed.has(startLine)) {
nextCollapsed.delete(startLine);
} else {
nextCollapsed.add(startLine);
}
// 折叠前捕获锚点:视口顶部可视行(model 行号)+ 视口内像素偏移
let anchorModelLine: number | null = null;
let deltaPx = 0;
if (container && container.scrollTop > 0) {
const topVisIdx = Math.floor(container.scrollTop / JSON_VIEW_ROW_HEIGHT_PX);
const rawLine = visibleModel.value[topVisIdx];
if (rawLine !== undefined) {
anchorModelLine = clampLineToVisible(rawLine, foldList.value, nextCollapsed);
deltaPx = container.scrollTop - topVisIdx * JSON_VIEW_ROW_HEIGHT_PX;
}
}
collapsedStartLines.value = nextCollapsed;
// DOM 更新后按新投影反查锚点可视索引,恢复滚动位置
await nextTick();
if (container && anchorModelLine !== null) {
const newVisIdx = findVisibleIndex(visibleModel.value, anchorModelLine);
if (newVisIdx >= 0) {
container.scrollTop = newVisIdx * JSON_VIEW_ROW_HEIGHT_PX + deltaPx;
}
}
}
/** 选区端点写入前的钳制包装:划进隐藏区的行停在折叠头行(Monaco adjustSelections 语义) */
function clampSelectionLine(modelLine: number): number {
return clampLineToVisible(modelLine, foldList.value, collapsedStartLines.value);
}
/** 全部折叠:收起所有顶层块(depth = 1)——嵌套块随父块隐藏,符合直觉 */
function collapseAll(): void {
collapsedStartLines.value = new Set(
foldList.value.filter((fold) => fold.depth === 1).map((fold) => fold.startLine),
);
}
/** 全部展开 */
function expandAll(): void {
collapsedStartLines.value = new Set();
}
/** 切换响应(props.json 变化)时重置折叠态:新数据的 folds 语义已变,旧折叠位失效 */
watch(
() => props.json,
() => {
collapsedStartLines.value = new Set();
},
);
defineExpose({ collapseAll, expandAll, hasFolds, toggleFold });
/* ---------- 容器尺寸与滚动位置(VueUse) ---------- */
const scrollContainerRef = ref<HTMLElement | null>(null);
@@ -55,13 +148,17 @@ const handleScroll = useThrottleFn(
/* ---------- 可视窗口(纯函数,阶段二已单测锁死) ---------- */
const visibleWindow = computed(() =>
computeVisibleRowWindow(lineList.value.length, scrollTopPx.value, containerHeightPx.value),
computeVisibleRowWindow(visibleModel.value.length, scrollTopPx.value, containerHeightPx.value),
);
/** 窗口内行的渲染模型(行号 + 完整片段序列:token 与 plain 交替,括号不丢) */
/** 窗口内行的渲染模型(行号 = model 行号,折叠后 gutter 跳号显示;完整片段序列:token 与 plain 交替,括号不丢) */
interface RenderableLine {
lineNumber: number;
segments: ReturnType<typeof buildJsonLineSegments>;
/** 该行是否为某个 fold 的折叠头(渲染 chevron 与行尾摘要) */
foldAtLine: JsonFoldRange | null;
/** 折叠头行当前是否处于折叠态(决定 chevron 方向与摘要显隐) */
isFoldCollapsed: boolean;
}
const visibleLineList = computed<RenderableLine[]>(() => {
@@ -71,14 +168,21 @@ const visibleLineList = computed<RenderableLine[]>(() => {
index <= visibleWindow.value.endIndex;
index += 1
) {
const lineText = lineList.value[index];
if (lineText === undefined) continue;
lines.push({ lineNumber: index + 1, segments: buildJsonLineSegments(lineText) });
const modelLine = visibleModel.value[index];
const lineText = modelLine === undefined ? undefined : lineList.value[modelLine - 1];
if (modelLine === undefined || lineText === undefined) continue;
const foldAtLine = foldByStartLine.value.get(modelLine) ?? null;
lines.push({
lineNumber: modelLine,
segments: buildJsonLineSegments(lineText),
foldAtLine,
isFoldCollapsed: foldAtLine !== null && collapsedStartLines.value.has(foldAtLine.startLine),
});
}
return lines;
});
const totalHeightPx = computed(() => lineList.value.length * JSON_VIEW_ROW_HEIGHT_PX);
const totalHeightPx = computed(() => visibleModel.value.length * JSON_VIEW_ROW_HEIGHT_PX);
const gutterWidth = computed(() => computeGutterWidth(lineList.value.length));
/* ---------- 行选区(model 层锚点,编辑器 model/view 分离的微缩版) ----------
@@ -113,24 +217,25 @@ function isLineSelected(lineNumber: number): boolean {
}
/** 同步真实 DOM Selection:让 Ctrl+C 直接可用。
* anchor/active 行可能不在窗口内(DOM 不存在),用窗口内实际渲染的边缘行近似。
* 选区端点是 model 行号:先钳制到可见(划进隐藏区停在折叠头行),再转可视索引,
* 与可视窗口求交集后索引窗口内实际渲染的边缘行(anchor/active 行可能不在窗口内)。
*/
function syncDomSelection(): void {
const range = selectedLineRange.value;
const container = scrollContainerRef.value;
if (!range || !container) return;
const [start, end] = range;
const clampedStart = clampSelectionLine(start);
const clampedEnd = clampSelectionLine(end);
const { startIndex, endIndex } = visibleWindow.value;
// 选区与可视窗口的交集边缘(1-based 行号 → 渲染索引)
const viewStartLine = startIndex + 1;
const viewEndLine = endIndex + 1;
const selStartLine = Math.max(start, viewStartLine);
const selEndLine = Math.min(end, viewEndLine);
if (selStartLine > selEndLine) return;
// 可视索引(0-based)与可视窗口的交集(窗口起止也是可视索引)
const selStartVisIdx = Math.max(findVisibleIndex(visibleModel.value, clampedStart), startIndex);
const selEndVisIdx = Math.min(findVisibleIndex(visibleModel.value, clampedEnd), endIndex);
if (selStartVisIdx > selEndVisIdx) return;
const lineElements = container.querySelectorAll<HTMLElement>('.line');
const startEl = lineElements[selStartLine - viewStartLine];
const endEl = lineElements[selEndLine - viewStartLine];
const startEl = lineElements[selStartVisIdx - startIndex];
const endEl = lineElements[selEndVisIdx - startIndex];
if (!startEl || !endEl) return;
const domSelection = window.getSelection();
@@ -148,8 +253,8 @@ function handleGutterMouseDown(lineNumber: number, event: MouseEvent): void {
// 只响应主键,避免与右键菜单/中键冲突
if (event.button !== 0) return;
event.preventDefault(); // 防止浏览器默认文本拖选与我们的状态机冲突
selectionAnchorLine.value = lineNumber;
selectionActiveLine.value = lineNumber;
selectionAnchorLine.value = clampSelectionLine(lineNumber);
selectionActiveLine.value = clampSelectionLine(lineNumber);
syncDomSelection();
const finish = (): void => {
@@ -162,7 +267,7 @@ function handleGutterMouseDown(lineNumber: number, event: MouseEvent): void {
const onMove = (moveEvent: MouseEvent): void => {
const lineAtPointer = resolveLineFromPointer(moveEvent.clientY);
if (lineAtPointer === null) return;
selectionActiveLine.value = lineAtPointer;
selectionActiveLine.value = clampSelectionLine(lineAtPointer);
syncDomSelection();
updateEdgeAutoScroll(moveEvent.clientY);
};
@@ -170,15 +275,18 @@ function handleGutterMouseDown(lineNumber: number, event: MouseEvent): void {
document.addEventListener('mouseup', finish, { once: true });
}
/** 指针 y 坐标 → 行号(1-based);基于容器几何而非 event.target(拖拽中 target 可能是任意节点) */
/** 指针 y 坐标 → model 行号(1-based);基于容器几何而非 event.target(拖拽中 target 可能是任意节点)。
* 几何换算先得可视索引,再经投影反查 model 行号——隐藏行不在投影里,天然不会被命中。
*/
function resolveLineFromPointer(clientY: number): number | null {
const container = scrollContainerRef.value;
if (!container) return null;
const rect = container.getBoundingClientRect();
const offsetY = clientY - rect.top + container.scrollTop;
const lineIndex = Math.floor(offsetY / JSON_VIEW_ROW_HEIGHT_PX);
if (lineIndex < 0 || lineIndex >= lineList.value.length) return null;
return lineIndex + 1;
const visIdx = Math.floor(offsetY / JSON_VIEW_ROW_HEIGHT_PX);
if (visIdx < 0 || visIdx >= visibleModel.value.length) return null;
const modelLine = visibleModel.value[visIdx];
return modelLine === undefined ? null : modelLine;
}
/* ---------- 拖到容器边缘的自动滚动(编辑器标配) ---------- */
@@ -236,17 +344,16 @@ function updateEdgeAutoScroll(pointerClientY: number): void {
}
container.scrollTop += speed;
// 自动滚动后按新的 scrollTop 推进选区(与滚轮/滚动条共用 scrollTopPx 同源链路)
const lineAtEdge =
speed < 0
? Math.max(1, Math.floor(container.scrollTop / JSON_VIEW_ROW_HEIGHT_PX) + 1)
: Math.min(
lineList.value.length,
Math.floor(
(container.scrollTop + container.clientHeight - 1) / JSON_VIEW_ROW_HEIGHT_PX,
) + 1,
const topVisIdx = Math.max(0, Math.floor(container.scrollTop / JSON_VIEW_ROW_HEIGHT_PX));
const bottomVisIdx = Math.min(
visibleModel.value.length - 1,
Math.floor((container.scrollTop + container.clientHeight - 1) / JSON_VIEW_ROW_HEIGHT_PX),
);
selectionActiveLine.value = lineAtEdge;
const lineAtEdge = speed < 0 ? visibleModel.value[topVisIdx] : visibleModel.value[bottomVisIdx];
if (lineAtEdge !== undefined) {
selectionActiveLine.value = clampSelectionLine(lineAtEdge);
syncDomSelection();
}
edgeScrollRafId = requestAnimationFrame(step);
};
edgeScrollRafId = requestAnimationFrame(step);
@@ -273,6 +380,16 @@ function updateEdgeAutoScroll(pointerClientY: number): void {
@mousedown="handleGutterMouseDown(line.lineNumber, $event)"
>{{ line.lineNumber }}</span
>
<span
v-if="hasFolds && line.foldAtLine !== null"
class="fold-chevron"
:class="{ 'fold-chevron-collapsed': line.isFoldCollapsed }"
:title="line.isFoldCollapsed ? '展开' : '折叠'"
@mousedown.stop
@click.stop="toggleFold(line.foldAtLine.startLine)"
>{{ line.isFoldCollapsed ? '›' : '⌄' }}</span
>
<span v-else-if="hasFolds" class="fold-chevron fold-chevron-placeholder"></span>
<span class="line-content">
<template v-for="(segment, segmentIndex) in line.segments" :key="segmentIndex">
<span v-if="segment.kind !== 'plain'" class="token" :class="segment.kind">{{
@@ -280,6 +397,15 @@ function updateEdgeAutoScroll(pointerClientY: number): void {
}}</span>
<template v-else>{{ segment.text }}</template>
</template>
<span
v-if="line.foldAtLine !== null && line.isFoldCollapsed"
class="fold-summary"
:title="`点击展开(${line.foldAtLine.hiddenLineCount} 行)`"
@mousedown.stop
@click.stop="toggleFold(line.foldAtLine.startLine)"
>{{ line.foldAtLine.kind === 'object' ? '{…}' : '[…]' }}
{{ line.foldAtLine.hiddenLineCount }} 行</span
>
</span>
</div>
</div>
@@ -311,6 +437,44 @@ function updateEdgeAutoScroll(pointerClientY: number): void {
user-select: none;
}
/* 折叠 chevron 列:占位透明保持行号与内容的间距恒定;可折叠行 hover 显示、已折叠常显 */
.fold-chevron {
display: inline-block;
width: 1.5ch;
text-align: center;
user-select: none;
cursor: pointer;
color: var(--td-text-color-placeholder);
visibility: hidden;
}
.fold-chevron-placeholder {
cursor: default;
}
.line:hover > .fold-chevron {
visibility: visible;
}
.fold-chevron-collapsed {
visibility: visible;
color: var(--td-brand-color);
font-weight: 700;
}
/* 折叠头行行尾摘要:hover 提示可点击展开 */
.fold-summary {
margin-left: 8px;
font-size: 11.5px;
color: var(--td-text-color-placeholder);
cursor: pointer;
user-select: none;
}
.fold-summary:hover {
color: var(--td-brand-color);
}
/* model 层选区高亮:重渲染不丢(DOM ::selection 会随虚拟滚动窗口变化失效) */
.line.line-selected {
background: var(--td-brand-color-light-focus, rgba(0, 82, 217, 0.18));
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* 左侧接口导航:5 个路径平铺(不分组不编号,按 endpoints.ts 定案顺序)
* 左侧接口导航:路径平铺(不分组不编号,按 endpoints.ts 定案顺序)
* 点击 → openOrActivateTab(已开则激活,未开则新开)
*/
+162
View File
@@ -2,11 +2,15 @@ import { describe, expect, it } from 'vitest';
import {
buildJsonLineSegments,
buildVisibleProjection,
clampLineToVisible,
computeGutterWidth,
computeVisibleRowWindow,
findVisibleIndex,
formatJsonOrRawText,
JSON_VIEW_OVERSCAN_ROWS,
JSON_VIEW_ROW_HEIGHT_PX,
scanJsonFolds,
splitTextIntoLines,
} from './json-text-processor';
@@ -127,3 +131,161 @@ describe('computeGutterWidth', () => {
expect(computeGutterWidth(0)).toBe(1);
});
});
/* ---------- 代码块折叠(实施计划 §3 用例清单) ---------- */
describe('scanJsonFolds 扫描规则', () => {
it('顶层多行对象:1 个 fold,end = 闭括号行,depth = 1', () => {
const lines = ['{', ' "a": 1', ' "b": 2', '}'];
expect(scanJsonFolds(lines)).toEqual([
{ startLine: 1, endLine: 4, depth: 1, kind: 'object', hiddenLineCount: 3 },
]);
});
it('嵌套对象:内外两个 fold,内层 depth = 3,各自 endLine 正确', () => {
const lines = [
'{', // 1
' "outer": {', // 2
' "inner": {', // 3
' "x": 1', // 4
' }', // 5
' }', // 6
'}', // 7
];
expect(scanJsonFolds(lines)).toEqual([
{ startLine: 3, endLine: 5, depth: 3, kind: 'object', hiddenLineCount: 2 },
{ startLine: 2, endLine: 6, depth: 2, kind: 'object', hiddenLineCount: 4 },
{ startLine: 1, endLine: 7, depth: 1, kind: 'object', hiddenLineCount: 6 },
]);
});
it('顶层数组:kind = array', () => {
const lines = ['[', ' 1', ' 2', ']'];
expect(scanJsonFolds(lines)).toEqual([
{ startLine: 1, endLine: 4, depth: 1, kind: 'array', hiddenLineCount: 3 },
]);
});
it('单行对象/数组不产出折叠', () => {
expect(scanJsonFolds(['{"a": 1}'])).toEqual([]);
expect(scanJsonFolds(['[1, 2]'])).toEqual([]);
expect(scanJsonFolds(['{', ' "a": {"x": 1}', '}'])).toEqual([
{ startLine: 1, endLine: 3, depth: 1, kind: 'object', hiddenLineCount: 2 },
]);
});
it('非法结构(无开括号的闭括号)健壮跳过,不抛错', () => {
expect(scanJsonFolds(['}'])).toEqual([]);
expect(scanJsonFolds([' ]'])).toEqual([]);
expect(() => scanJsonFolds(['}', ']', ' }'])).not.toThrow();
});
it('行尾含空白/换行的开括号行正常识别(trimEnd 生效)', () => {
const lines = ['{ ', ' "a": 1', '}', '\n'];
expect(scanJsonFolds(lines)).toEqual([
{ startLine: 1, endLine: 3, depth: 1, kind: 'object', hiddenLineCount: 2 },
]);
});
it('非 JSON 文本不抛错(调用方须先确认是 JSON,此处只保证健壮性)', () => {
expect(() => scanJsonFolds(['function f() {', ' return 1;', '}'])).not.toThrow();
expect(scanJsonFolds([])).toEqual([]);
});
});
describe('buildVisibleProjection 隐藏语义', () => {
const folds = scanJsonFolds(['{', ' "a": 1', ' "b": 2', '}']);
const fold = folds[0]!;
it('空折叠集:恒等投影', () => {
expect(buildVisibleProjection(4, folds, new Set())).toEqual([1, 2, 3, 4]);
});
it('折叠中间块:start 可见、(start, end] 全部消失(含闭括号行)、end+1 起恢复', () => {
const middleFolds = scanJsonFolds(['{', ' "x": {', ' "y": 1', ' }', '}']);
// middleFolds: start=2/end=4(depth=2)与 start=1/end=5(depth=1)
const projection = buildVisibleProjection(5, middleFolds, new Set([2]));
expect(projection).toEqual([1, 2, 5]);
});
it('投影长度 = 总行数 − 隐藏行数', () => {
const projection = buildVisibleProjection(4, [fold], new Set([fold.startLine]));
expect(projection).toHaveLength(4 - fold.hiddenLineCount);
});
it('嵌套两块同时折叠:区间合并隐藏、无重复行号', () => {
const nested = scanJsonFolds([
'{', // 1
' "a": {', // 2
' "b": 1', // 3
' }', // 4
'}', // 5
]);
// nested: start=2/end=4(depth=2)与 start=1/end=5(depth=1)
// 同时折叠外层(start=1)与内层(start=2):外层区间已覆盖全部,投影只剩折叠头
const projection = buildVisibleProjection(5, nested, new Set([1, 2]));
expect(projection).toEqual([1]);
});
it('折叠位含不存在的 startLine:忽略不生效', () => {
expect(buildVisibleProjection(4, folds, new Set([999]))).toEqual([1, 2, 3, 4]);
});
it('零总行数返回空投影', () => {
expect(buildVisibleProjection(0, folds, new Set([1]))).toEqual([]);
});
});
describe('findVisibleIndex 反查', () => {
const folds = scanJsonFolds(['{', ' "a": 1', ' "b": 2', '}']);
// 折叠顶层对象(start=1/end=4,闭括号行随块隐藏):投影只剩折叠头
const projection = buildVisibleProjection(4, folds, new Set([1]));
it('可见行命中正确可视索引', () => {
expect(findVisibleIndex(projection, 1)).toBe(0);
const halfFolds = scanJsonFolds(['{', ' "a": 1', ' "b": 2', ' "c": 3', '}']);
const halfProjection = buildVisibleProjection(5, halfFolds, new Set([1]));
expect(halfProjection).toEqual([1]); // 折叠整个对象后仍只有折叠头可见
expect(findVisibleIndex(halfProjection, 1)).toBe(0);
});
it('隐藏行(含闭括号行)返回 −1', () => {
expect(findVisibleIndex(projection, 2)).toBe(-1);
expect(findVisibleIndex(projection, 3)).toBe(-1);
expect(findVisibleIndex(projection, 4)).toBe(-1);
});
});
describe('clampLineToVisible 选区钳制', () => {
const folds = scanJsonFolds([
'{', // 1
' "outer": {', // 2
' "x": 1', // 3
' }', // 4
'}', // 5
]);
// folds: start=2/end=4(depth=2)与 start=1/end=5(depth=1)
it('区间内隐藏行(含闭括号行)收缩到折叠头行 start', () => {
const collapsed = new Set([1]);
expect(clampLineToVisible(2, folds, collapsed)).toBe(1);
expect(clampLineToVisible(4, folds, collapsed)).toBe(1);
expect(clampLineToVisible(5, folds, collapsed)).toBe(1); // 行 5 是闭括号,在隐藏区
});
it('start 本身、折叠头之后的可见行不收缩', () => {
const collapsed = new Set([2]); // 只折叠内层(2..4)
expect(clampLineToVisible(2, folds, collapsed)).toBe(2);
expect(clampLineToVisible(5, folds, collapsed)).toBe(5); // 外层闭括号在折叠区外
});
it('未折叠的 fold 不参与钳制', () => {
expect(clampLineToVisible(3, folds, new Set([2]))).toBe(2);
expect(clampLineToVisible(3, folds, new Set())).toBe(3);
});
it('嵌套区间同时折叠:命中最外层折叠头', () => {
const collapsed = new Set([1, 2]);
expect(clampLineToVisible(3, folds, collapsed)).toBe(1);
});
});
+128
View File
@@ -112,3 +112,131 @@ export function buildJsonLineSegments(line: string): JsonLineSegment[] {
export function computeGutterWidth(totalRowCount: number): number {
return String(Math.max(totalRowCount, 1)).length;
}
/* ---------- 代码块折叠(调研与实施计划见 docs/JSON预览器代码折叠调研.md / 实施计划.md) ----------
*
* 三层模型(对齐 VS Code/Monaco 与 CodeMirror 6 的共同结构):
* 1. folds:括号栈扫描产出的折叠范围(model 行号对,end = 闭括号前一行)
* 2. collapsedSet:已折叠集合(fold 的 start 行号)
* 3. visibleModel:可见性投影——未被隐藏的 model 行号按序排列;
* 隐藏语义同 VS Code hidesLine:start < line ≤ end(折叠头行永远可见)
*/
/** 单个折叠范围(1-based model 行号;start = 开括号行,end = 闭括号行) */
export interface JsonFoldRange {
readonly startLine: number;
/** 闭括号行(注意:不是闭括号前一行——LSP 的「闭括号前一行」是 0-based 记法,
* 转 1-based 后即闭括号行本身;VS Code 折叠对象时闭括号行随块隐藏,行尾摘要附在折叠头行) */
readonly endLine: number;
/** 嵌套深度:顶层块 = 1(供「折叠全部到第 N 层」扩展位与调试) */
readonly depth: number;
/** 块类型(行尾是 { 还是 [) */
readonly kind: 'object' | 'array';
/** 该折叠隐藏的行数(endLine − startLine,不含折叠头行) */
readonly hiddenLineCount: number;
}
/** 从行数组扫描 JSON 折叠范围(括号栈行级扫描,VS Code jsonFolding 同款规则)。
* 仅当行尾为 { / [(后随空白)时开块、行首(首个非空白)为 } / ] 时收块——
* pretty-print 的 JSON 保证每行至多一个括号,字符串值不会以裸括号结尾,零误判。
* 单行块(闭括号紧随开括号行)不产出折叠。非 JSON 文本可能产出错误范围,调用方须先确认是 JSON。
*/
export function scanJsonFolds(lineList: readonly string[]): JsonFoldRange[] {
const folds: JsonFoldRange[] = [];
// 栈元素:开括号行(1-based)与块类型
const openStack: Array<{ startLine: number; kind: 'object' | 'array' }> = [];
for (let index = 0; index < lineList.length; index += 1) {
const line = lineList[index]!;
const lineNo = index + 1;
const trimmedEnd = line.trimEnd();
if (trimmedEnd.endsWith('{') || trimmedEnd.endsWith('[')) {
openStack.push({
startLine: lineNo,
kind: trimmedEnd.endsWith('{') ? 'object' : 'array',
});
continue;
}
const firstChar = trimmedEnd.trimStart().charAt(0);
if (firstChar === '}' || firstChar === ']') {
const opened = openStack.pop();
if (!opened) continue; // 非法结构(不完整 JSON 等):跳过,保持扫描健壮
// end = 闭括号行(闭括号随块隐藏);单行块不产出(closeLine > start + 1 才有隐藏行)
const closeLine = lineNo;
if (closeLine > opened.startLine + 1) {
folds.push({
startLine: opened.startLine,
endLine: closeLine,
depth: openStack.length + 1,
kind: opened.kind,
hiddenLineCount: closeLine - opened.startLine,
});
}
}
}
return folds;
}
/**
* 可见性投影:从全量 model 行号中剔除被折叠区间隐藏的行(start < line ≤ end)。
* 折叠区间按 startLine 升序判定;嵌套折叠通过区间覆盖判定天然正确——
* 一个行号落在多个已折叠区间内只输出一次。
*/
export function buildVisibleProjection(
totalLineCount: number,
folds: readonly JsonFoldRange[],
collapsedStartLines: ReadonlySet<number>,
): number[] {
if (totalLineCount <= 0) return [];
const identityProjection = (): number[] =>
Array.from({ length: totalLineCount }, (_, index) => index + 1);
if (collapsedStartLines.size === 0) return identityProjection();
const collapsedRanges = folds
.filter((fold) => collapsedStartLines.has(fold.startLine))
.sort((a, b) => a.startLine - b.startLine);
if (collapsedRanges.length === 0) return identityProjection();
const visible: number[] = [];
for (let line = 1; line <= totalLineCount; line += 1) {
let isHidden = false;
for (const range of collapsedRanges) {
if (range.startLine >= line) break; // 区间按 start 升序,后面的不会覆盖当前行
if (line <= range.endLine) {
isHidden = true;
break;
}
}
if (!isHidden) visible.push(line);
}
return visible;
}
/** model 行号 → 可视索引(0-based,投影数组中的位置);行被隐藏或未命中时返回 −1 */
export function findVisibleIndex(visibleModel: readonly number[], modelLine: number): number {
return visibleModel.indexOf(modelLine);
}
/** 选区端点钳制:model 行落在某已折叠区间的隐藏部分(start < line ≤ end)时收缩到折叠头行。
* Monaco HiddenRangeModel.adjustSelections 同款语义——选区端点只收缩、不展开折叠。
*/
export function clampLineToVisible(
modelLine: number,
folds: readonly JsonFoldRange[],
collapsedStartLines: ReadonlySet<number>,
): number {
let clamped = modelLine;
for (const fold of folds) {
if (!collapsedStartLines.has(fold.startLine)) continue;
if (fold.startLine < clamped && clamped <= fold.endLine) {
clamped = fold.startLine;
}
}
return clamped;
}