Archived
Compare commits
5
Commits
19b69e2d5a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5f7d93d67 | ||
|
|
a77cd71888 | ||
|
|
b3ad8a5a8e | ||
|
|
8d6a842055 | ||
|
|
86d2ac304b |
@@ -35,7 +35,7 @@ bun run build # type-check + vite build(dist/)
|
|||||||
|
|
||||||
## 使用
|
## 使用
|
||||||
|
|
||||||
1. 左侧选择接口(5 个,平铺不分组)→ 右侧开 tab
|
1. 左侧选择接口(9 个,平铺不分组)→ 右侧开 tab
|
||||||
2. tab 内展开「注意事项」看该接口的实证调研坑文档
|
2. tab 内展开「注意事项」看该接口的实证调研坑文档
|
||||||
3. 填 sk-key(每 tab 独立、仅存内存、刷新即重置,不落盘;切换 tab 输入保留)
|
3. 填 sk-key(每 tab 独立、仅存内存、刷新即重置,不落盘;切换 tab 输入保留)
|
||||||
4. 点「发送请求」→ 响应区显示元信息(状态/耗时/字符数)+ 虚拟滚动高亮 JSON
|
4. 点「发送请求」→ 响应区显示元信息(状态/耗时/字符数)+ 虚拟滚动高亮 JSON
|
||||||
|
|||||||
@@ -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 降级)
|
||||||
@@ -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. **回归**:全量测试 + 新增折叠用例(重点:折叠后滚动、嵌套折叠不重复计数、补偿无跳变)
|
||||||
@@ -37,6 +37,14 @@ Authorization: Bearer sk-xxxxxxxxxxxx
|
|||||||
|------|-----|------|
|
|------|-----|------|
|
||||||
| `QuotaPerUnit` | 500000 | 实测验证:接口① `total_used` ÷ 500000 = 接口④ `total_usage` ÷ 100 |
|
| `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
|
### 关键开关:DisplayTokenStatEnabled
|
||||||
|
|
||||||
| 接口 | 受开关影响? |
|
| 接口 | 受开关影响? |
|
||||||
@@ -54,6 +62,7 @@ Authorization: Bearer sk-xxxxxxxxxxxx
|
|||||||
|
|
||||||
| 序号 | 方法 | 路径 | 鉴权 | 用途 |
|
| 序号 | 方法 | 路径 | 鉴权 | 用途 |
|
||||||
|------|------|------|------|------|
|
|------|------|------|------|------|
|
||||||
|
| ⓪ | GET | `/api/status` | **无(完全公开)** | 站点配置与计费参数(含 `quota_per_unit`),工具校准换算比率的依据 |
|
||||||
| ① | GET | `/api/usage/token` | TokenAuthReadOnly | 查 token 的累计已用/剩余/总额(原始 quota) |
|
| ① | GET | `/api/usage/token` | TokenAuthReadOnly | 查 token 的累计已用/剩余/总额(原始 quota) |
|
||||||
| ② | GET | `/api/log/token` | TokenAuthReadOnly | 查该 key 最近 1000 条调用日志 |
|
| ② | GET | `/api/log/token` | TokenAuthReadOnly | 查该 key 最近 1000 条调用日志 |
|
||||||
| ③ | GET | `/v1/dashboard/billing/subscription` | TokenAuth | 查总额度上限(OpenAI 兼容,美元) |
|
| ③ | GET | `/v1/dashboard/billing/subscription` | TokenAuth | 查总额度上限(OpenAI 兼容,美元) |
|
||||||
@@ -73,6 +82,45 @@ Authorization: Bearer sk-xxxxxxxxxxxx
|
|||||||
|
|
||||||
## 2. 用量/额度查询接口详解
|
## 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 级用量汇总
|
### ① GET `/api/usage/token` - token 级用量汇总
|
||||||
|
|
||||||
**鉴权**:`TokenAuthReadOnly()`(宽松版,key 过期/耗尽也能查)
|
**鉴权**:`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` |
|
| billing 查询 | `controller/billing.go:75` | `GetUsage` |
|
||||||
| 显示开关 | `common/constants.go` | `DisplayTokenStatEnabled` |
|
| 显示开关 | `common/constants.go` | `DisplayTokenStatEnabled` |
|
||||||
| 日志上限 | `common/constants.go` | `MaxRecentItems = 1000` |
|
| 日志上限 | `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) |
|
| token 更新 | `model/token.go` | `Update()`(Select 不含 used_quota) |
|
||||||
| 额度增减 | `model/token.go` | `increaseTokenQuota` / `decreaseTokenQuota` |
|
| 额度增减 | `model/token.go` | `increaseTokenQuota` / `decreaseTokenQuota` |
|
||||||
|
|
||||||
|
|||||||
+13
-9
@@ -8,20 +8,24 @@
|
|||||||
|
|
||||||
## 1. 项目定位
|
## 1. 项目定位
|
||||||
|
|
||||||
替代 Postman 的专用调试台(bench):左侧选接口 → 右侧 tab 内看文档、填 key、发请求、看原始响应。对接 5 个接口,服务 karpool 拼车项目的接口调试与换算验证。
|
替代 Postman 的专用调试台(bench):左侧选接口 → 右侧 tab 内看文档、填 key、发请求、看原始响应。对接 9 个接口,服务 karpool 拼车项目的接口调试与换算验证。
|
||||||
|
|
||||||
**接口清单**(数据源:`docs/NewAPI-sk-key接口文档.md`,按此顺序展示,不分组):
|
**接口清单**(数据源:`docs/NewAPI-sk-key接口文档.md`,按此顺序展示,不分组):
|
||||||
|
|
||||||
| 序 | 路径 | 鉴权 | 响应特征 |
|
| 序 | 路径 | 鉴权 | 响应特征 |
|
||||||
|---|------|------|---------|
|
|---|------|------|---------|
|
||||||
| 1 | `GET /v1/dashboard/billing/subscription` | TokenAuth(严格) | 小 JSON,美元额度(无限额时 1 亿占位) |
|
| 0 | `GET /api/user/groups` | 无(完全公开) | 用户分组与倍率列表;未启用分组时为空对象 |
|
||||||
| 2 | `GET /v1/dashboard/billing/usage` | TokenAuth(严格) | 小 JSON,累计美分 |
|
| 1 | `GET /api/perf-metrics/summary` | 公开门控(默认无需凭证) | 全站模型服务质量:延迟/成功率/TPS/小时级序列;`hours` 参数 |
|
||||||
| 3 | `GET /v1/models` | TokenAuth(严格) | 中等 JSON,模型数组 |
|
| 2 | `GET /api/rankings` | 公开门控(默认无需凭证) | 全站模型用量排行:名次/份额/环比增长;`period` 参数 |
|
||||||
| 4 | `GET /api/usage/token` | TokenAuthReadOnly(宽松) | 小 JSON,累计 quota |
|
| 3 | `GET /api/status` | 无(完全公开) | 站点配置与计费参数:`quota_per_unit`(换算比率权威来源)、`quota_display_type`、汇率等 |
|
||||||
| 5 | `GET /api/log/token` | TokenAuthReadOnly(宽松) | **大响应**:最近 1000 条日志,pretty-print 2~4MB |
|
| 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 内标出该差异,避免误判「接口坏了」。
|
> 0~3 完全公开或默认公开(面板隐藏 sk-key 输入行);7、8 宽松鉴权(key 过期/耗尽仍可查);4~6 严格鉴权(key 无效直接被拒)。tab 内标出该差异,避免误判「接口坏了」。
|
||||||
> 4 与 2 构成换算交叉验证对(total_used ÷ 500000 ≈ total_usage ÷ 100),Tabs 并行对账是核心工作流。
|
> 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)
|
- **右主区**:`t-tabs` 顶部,激活 tab 内容 = 请求行(method + 路径 + 鉴权徽标)→ 调试行(sk-key 输入 + 发送按钮)→ 响应区(元信息 + 虚拟滚动 JSON)
|
||||||
- 暗色默认(TDesign dark theme + CSS 变量)
|
- 暗色默认(TDesign dark theme + CSS 变量)
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,21 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { endpointList, findEndpointByPath } from './endpoints';
|
import { endpointList, findEndpointByPath } from './endpoints';
|
||||||
|
|
||||||
describe('endpointList 数据完整性', () => {
|
describe('endpointList 数据完整性', () => {
|
||||||
it('恰好 5 个接口(设计文档 §1 定案的对接清单)', () => {
|
it('恰好 9 个接口(设计文档 §1 定案的对接清单)', () => {
|
||||||
expect(endpointList).toHaveLength(5);
|
expect(endpointList).toHaveLength(9);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('路径唯一(path 是 tab / queryKey 的唯一标识)', () => {
|
it('路径唯一(path 是 tab / queryKey 的唯一标识)', () => {
|
||||||
const pathList = endpointList.map((endpoint) => endpoint.path);
|
const pathList = endpointList.map((endpoint) => endpoint.path);
|
||||||
expect(new Set(pathList).size).toBe(5);
|
expect(new Set(pathList).size).toBe(9);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('顺序与用户指定清单一致', () => {
|
it('顺序与用户指定清单一致', () => {
|
||||||
expect(endpointList.map((endpoint) => endpoint.path)).toEqual([
|
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/subscription',
|
||||||
'/v1/dashboard/billing/usage',
|
'/v1/dashboard/billing/usage',
|
||||||
'/v1/models',
|
'/v1/models',
|
||||||
@@ -25,13 +29,17 @@ describe('endpointList 数据完整性', () => {
|
|||||||
it('全部为 GET 且带鉴权元数据', () => {
|
it('全部为 GET 且带鉴权元数据', () => {
|
||||||
for (const endpoint of endpointList) {
|
for (const endpoint of endpointList) {
|
||||||
expect(endpoint.method).toBe('GET');
|
expect(endpoint.method).toBe('GET');
|
||||||
expect(['strict', 'lenient']).toContain(endpoint.authMode);
|
expect(['strict', 'lenient', 'none']).toContain(endpoint.authMode);
|
||||||
expect(endpoint.authMiddlewareName).toBeTruthy();
|
expect(endpoint.authMiddlewareName).toBeTruthy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('严格/宽松鉴权分布正确(前 3 严格,后 2 宽松)', () => {
|
it('鉴权模式分布正确(公开 4 + 严格 3 + 宽松 2)', () => {
|
||||||
expect(endpointList.map((endpoint) => endpoint.authMode)).toEqual([
|
expect(endpointList.map((endpoint) => endpoint.authMode)).toEqual([
|
||||||
|
'none',
|
||||||
|
'none',
|
||||||
|
'none',
|
||||||
|
'none',
|
||||||
'strict',
|
'strict',
|
||||||
'strict',
|
'strict',
|
||||||
'strict',
|
'strict',
|
||||||
|
|||||||
+120
-12
@@ -1,17 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* 5 个对接接口的静态元数据
|
* 对接接口的静态元数据
|
||||||
* 数据源:docs/NewAPI-sk-key接口文档.md(源码实证调研)
|
* 数据源:docs/NewAPI-sk-key接口文档.md(源码实证调研)
|
||||||
* 顺序即 sider / tab 展示顺序(设计文档 §1 定案,不分组不编号)
|
* 顺序即 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 {
|
export interface EndpointDefinition {
|
||||||
/** 接口路径(唯一标识,同时用作 tab 标签与 queryKey 组成部分) */
|
/** 接口路径(唯一标识,同时用作 tab 标签与 queryKey 组成部分) */
|
||||||
readonly path: string;
|
readonly path: string;
|
||||||
/** HTTP 方法(当前全部为 GET,保留扩展位) */
|
/** HTTP 方法(当前全部为 GET,保留扩展位) */
|
||||||
readonly method: 'GET';
|
readonly method: 'GET';
|
||||||
/** 鉴权模式:strict = TokenAuth(key 无效/过期/耗尽直接被拒);lenient = TokenAuthReadOnly(宽松,废 key 也能查) */
|
/** 鉴权模式:strict = TokenAuth(key 无效/过期/耗尽直接被拒);lenient = TokenAuthReadOnly(宽松,废 key 也能查);none = 完全公开(无需任何凭证) */
|
||||||
readonly authMode: AuthMode;
|
readonly authMode: AuthMode;
|
||||||
/** 鉴权中间件名(展示用) */
|
/** 鉴权中间件名(展示用) */
|
||||||
readonly authMiddlewareName: string;
|
readonly authMiddlewareName: string;
|
||||||
@@ -24,6 +27,111 @@ export interface EndpointDefinition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const endpointList: readonly 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',
|
path: '/v1/dashboard/billing/subscription',
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -34,7 +142,7 @@ export const endpointList: readonly EndpointDefinition[] = [
|
|||||||
'受 DisplayTokenStatEnabled 开关影响:开启返回 token 级额度,关闭回退到账号级额度(会暴露整个账号余额)',
|
'受 DisplayTokenStatEnabled 开关影响:开启返回 token 级额度,关闭回退到账号级额度(会暴露整个账号余额)',
|
||||||
'无限额度 key 返回 100000000(一亿)占位值,无参考意义',
|
'无限额度 key 返回 100000000(一亿)占位值,无参考意义',
|
||||||
'受 quota_display_type 影响:USD 模式才是美元;CNY 模式会做货币转换;Tokens 模式是原始 token 数',
|
'受 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: {
|
sampleResponse: {
|
||||||
object: 'billing_subscription',
|
object: 'billing_subscription',
|
||||||
@@ -54,8 +162,8 @@ export const endpointList: readonly EndpointDefinition[] = [
|
|||||||
notes: [
|
notes: [
|
||||||
'total_usage 单位是美分,换算美元 ÷ 100',
|
'total_usage 单位是美分,换算美元 ÷ 100',
|
||||||
'累计值(key 创建至今),非当月用量',
|
'累计值(key 创建至今),非当月用量',
|
||||||
'与 /api/usage/token 的 total_used ÷ 500000 完全吻合(QuotaPerUnit = 500000 的双重验证)',
|
'可与 /api/usage/token 的 total_used ÷ quota_per_unit 交叉对账(两接口应一致;若不一致,说明站点改过 quota_per_unit,见 /api/status)',
|
||||||
'受 DisplayTokenStatEnabled 与 quota_display_type 影响(同 subscription 接口)',
|
'受 DisplayTokenStatEnabled 与 quota_display_type 影响(同 /v1/dashboard/billing/subscription)',
|
||||||
],
|
],
|
||||||
sampleResponse: { object: 'list', total_usage: 134683.8914 },
|
sampleResponse: { object: 'list', total_usage: 134683.8914 },
|
||||||
},
|
},
|
||||||
@@ -66,7 +174,7 @@ export const endpointList: readonly EndpointDefinition[] = [
|
|||||||
authMiddlewareName: 'TokenAuth',
|
authMiddlewareName: 'TokenAuth',
|
||||||
responseSummary: '可用模型列表(OpenAI 格式)',
|
responseSummary: '可用模型列表(OpenAI 格式)',
|
||||||
notes: [
|
notes: [
|
||||||
'数据源是数据库 abilities 表,反映渠道实际配置的模型',
|
'数据源是数据库中渠道实际配置的模型表(NewAPI 内部存储),反映渠道真实配置',
|
||||||
'与 /v1/models/:model 单查接口数据源不同(后者查代码内硬编码静态表)',
|
'与 /v1/models/:model 单查接口数据源不同(后者查代码内硬编码静态表)',
|
||||||
'渠道新配的模型可能出现「列表里有、单查 model_not_found」,不影响实际调用',
|
'渠道新配的模型可能出现「列表里有、单查 model_not_found」,不影响实际调用',
|
||||||
],
|
],
|
||||||
@@ -83,9 +191,9 @@ export const endpointList: readonly EndpointDefinition[] = [
|
|||||||
responseSummary: 'token 级用量汇总(原始 quota)',
|
responseSummary: 'token 级用量汇总(原始 quota)',
|
||||||
notes: [
|
notes: [
|
||||||
'total_used 是累计值(key 创建至今),非当月用量',
|
'total_used 是累计值(key 创建至今),非当月用量',
|
||||||
'额度为原始 quota 整数,换算美元 ÷ 500000(QuotaPerUnit)',
|
'额度为原始 quota 整数,换算美元 ÷ quota_per_unit(见 /api/status,默认 500000)',
|
||||||
'unlimited_quota = true 时 total_granted / total_available 为占位值,无实际意义',
|
'unlimited_quota = true 时 total_granted / total_available 为占位值,无实际意义',
|
||||||
'禁用再解禁不会重置 total_used(源码验证:Update() 的 Select 列表不含 used_quota)',
|
'禁用再解禁不会重置 total_used(NewAPI 源码验证:token 更新逻辑不覆盖该字段)',
|
||||||
'不受 DisplayTokenStatEnabled 开关影响,恒返回 token 级数据',
|
'不受 DisplayTokenStatEnabled 开关影响,恒返回 token 级数据',
|
||||||
],
|
],
|
||||||
sampleResponse: {
|
sampleResponse: {
|
||||||
@@ -111,11 +219,11 @@ export const endpointList: readonly EndpointDefinition[] = [
|
|||||||
authMiddlewareName: 'TokenAuthReadOnly',
|
authMiddlewareName: 'TokenAuthReadOnly',
|
||||||
responseSummary: '最近 1000 条调用日志',
|
responseSummary: '最近 1000 条调用日志',
|
||||||
notes: [
|
notes: [
|
||||||
'硬上限 1000 条(common.MaxRecentItems = 1000)',
|
'硬上限 1000 条(NewAPI 源码硬编码)',
|
||||||
'不支持任何请求参数:分页 / limit / 时间过滤全被忽略',
|
'不支持任何请求参数:分页 / limit / 时间过滤全被忽略',
|
||||||
'固定按 id desc 排序,即最新 1000 条',
|
'固定最新在前(服务端按内部序号倒序);响应里的 id 是展示序号,跨请求无意义,勿用于去重或游标',
|
||||||
'月调用量超 1000 次时,此接口无法覆盖完整当月',
|
'月调用量超 1000 次时,此接口无法覆盖完整当月',
|
||||||
'大响应:pretty-print 后 2~4MB,约两万行——虚拟滚动的核心场景',
|
'大响应:pretty-print 后 2~4MB,约两万行——页面已做虚拟滚动优化,可直接流畅浏览',
|
||||||
],
|
],
|
||||||
sampleResponse: {
|
sampleResponse: {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,18 +1,33 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 接口信息行:method 徽标 + 路径 + 鉴权徽标(宽松/严格)
|
* 接口信息行:method 徽标 + 路径 + 鉴权徽标(公开/宽松/严格)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { EndpointDefinition } from '@/api/endpoints';
|
import type { EndpointDefinition } from '@/api/endpoints';
|
||||||
|
|
||||||
const props = defineProps<{ endpoint: EndpointDefinition }>();
|
const props = defineProps<{ endpoint: EndpointDefinition }>();
|
||||||
|
|
||||||
const authBadgeText = props.endpoint.authMode === 'lenient' ? '宽松' : '严格';
|
const authBadgeTextMap: Record<string, string> = {
|
||||||
const authBadgeTitle = `${props.endpoint.authMiddlewareName}(${
|
lenient: '宽松',
|
||||||
props.endpoint.authMode === 'lenient'
|
strict: '严格',
|
||||||
? 'key 过期/禁用/额度耗尽仍可查询'
|
none: '公开',
|
||||||
: 'key 无效/过期/额度耗尽会被拒绝'
|
};
|
||||||
})`;
|
|
||||||
|
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 无效/过期/额度耗尽会被拒绝'
|
||||||
|
})`;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -64,6 +79,11 @@ const authBadgeTitle = `${props.endpoint.authMiddlewareName}(${
|
|||||||
border: 1px solid var(--td-success-color);
|
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 {
|
.response-summary {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--td-text-color-placeholder);
|
color: var(--td-text-color-placeholder);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useClipboard } from '@vueuse/core';
|
import { useClipboard } from '@vueuse/core';
|
||||||
import { computed } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { findEndpointByPath } from '@/api/endpoints';
|
import { findEndpointByPath } from '@/api/endpoints';
|
||||||
import { useManualEndpointQuery } from '@/api/endpoint-queries';
|
import { useManualEndpointQuery } from '@/api/endpoint-queries';
|
||||||
@@ -44,15 +44,42 @@ const {
|
|||||||
sendRequest,
|
sendRequest,
|
||||||
} = useManualEndpointQuery(endpointPathRef, skKeyInput);
|
} = useManualEndpointQuery(endpointPathRef, skKeyInput);
|
||||||
|
|
||||||
|
/** 无鉴权接口(如 /api/status)不需要 sk-key:隐藏输入行、按钮不因空 key 禁用 */
|
||||||
|
const requiresKey = computed(() => endpoint.value.authMode !== 'none');
|
||||||
|
|
||||||
const keyInputStatus = computed(() =>
|
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 {
|
function handleSend(): void {
|
||||||
if (!skKeyInput.value.trim()) return; // 空 key 不发(输入框已标红提示)
|
if (sendButtonDisabled.value) return; // 空 key 不发(输入框已标红提示);无鉴权接口直接发
|
||||||
sendRequest();
|
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 {
|
const {
|
||||||
@@ -100,10 +127,11 @@ function downloadResponseAsFile(): void {
|
|||||||
</t-collapse>
|
</t-collapse>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 调试行:sk-key + 发送 -->
|
<!-- 调试行:sk-key + 发送(无鉴权接口隐藏 key 输入) -->
|
||||||
<section class="panel-section">
|
<section class="panel-section">
|
||||||
<div class="request-row">
|
<div class="request-row">
|
||||||
<t-input
|
<t-input
|
||||||
|
v-if="requiresKey"
|
||||||
v-model="skKeyInput"
|
v-model="skKeyInput"
|
||||||
class="sk-key-input"
|
class="sk-key-input"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -113,10 +141,11 @@ function downloadResponseAsFile(): void {
|
|||||||
:tips="keyInputStatus === 'error' ? '请填写 sk-key' : ''"
|
:tips="keyInputStatus === 'error' ? '请填写 sk-key' : ''"
|
||||||
@enter="handleSend"
|
@enter="handleSend"
|
||||||
/>
|
/>
|
||||||
|
<span v-else class="no-key-hint">公开接口,无需 sk-key</span>
|
||||||
<t-button
|
<t-button
|
||||||
theme="primary"
|
theme="primary"
|
||||||
:loading="isFetching"
|
:loading="isFetching"
|
||||||
:disabled="!skKeyInput.trim()"
|
:disabled="sendButtonDisabled"
|
||||||
@click="handleSend"
|
@click="handleSend"
|
||||||
>
|
>
|
||||||
发送请求
|
发送请求
|
||||||
@@ -143,6 +172,12 @@ function downloadResponseAsFile(): void {
|
|||||||
{{ responseData.networkError }}
|
{{ responseData.networkError }}
|
||||||
</span>
|
</span>
|
||||||
<span class="meta-spacer"></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
|
<t-button
|
||||||
v-if="clipboardSupported"
|
v-if="clipboardSupported"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -153,10 +188,10 @@ function downloadResponseAsFile(): void {
|
|||||||
</t-button>
|
</t-button>
|
||||||
<t-button size="small" variant="outline" @click="downloadResponseAsFile">下载</t-button>
|
<t-button size="small" variant="outline" @click="downloadResponseAsFile">下载</t-button>
|
||||||
</div>
|
</div>
|
||||||
<JsonTextView :json="responseData.json" :raw-text="responseData.text" />
|
<JsonTextView ref="jsonTextViewRef" :json="responseData.json" :raw-text="responseData.text" />
|
||||||
</section>
|
</section>
|
||||||
<section v-else-if="!hasSent" class="panel-section response-placeholder">
|
<section v-else-if="!hasSent" class="panel-section response-placeholder">
|
||||||
填写 sk-key 后点击「发送请求」
|
{{ requiresKey ? '填写 sk-key 后点击「发送请求」' : '点击「发送请求」查看站点配置' }}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -186,6 +221,12 @@ function downloadResponseAsFile(): void {
|
|||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.no-key-hint {
|
||||||
|
align-self: center;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
.response-section {
|
.response-section {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 220px; /* 响应区保底高度:窄窗口下不被上部固定分区压缩到不可用 */
|
min-height: 220px; /* 响应区保底高度:窄窗口下不被上部固定分区压缩到不可用 */
|
||||||
|
|||||||
@@ -165,3 +165,158 @@ describe('json-text-view 虚拟滚动', () => {
|
|||||||
wrapper.unmount();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,19 +8,26 @@
|
|||||||
* 实现要点:
|
* 实现要点:
|
||||||
* - DOM 恒定:只渲染可视窗口 ± overscan 的行(json-text-processor.computeVisibleRowWindow)
|
* - DOM 恒定:只渲染可视窗口 ± overscan 的行(json-text-processor.computeVisibleRowWindow)
|
||||||
* - 懒高亮:仅对窗口内行做 token 切词(结构化 span),转义与着色在模板层,无 v-html
|
* - 懒高亮:仅对窗口内行做 token 切词(结构化 span),转义与着色在模板层,无 v-html
|
||||||
* - 滚动条:撑高度用总行数 × 行高的占位 div,窗口偏移用 translateY
|
* - 滚动条:撑高度用「可视行数 × 行高」的占位 div,窗口偏移用 translateY
|
||||||
|
* - 代码块折叠:括号栈扫描 fold + collapsedSet 投影剔除隐藏行;折叠事务带 scrollTop 补偿
|
||||||
|
* (锚点 = 视口顶部 model 行号,折叠视口上方块时视口不跳变);选区端点写前钳制
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useElementSize, useThrottleFn } from '@vueuse/core';
|
import { useElementSize, useThrottleFn } from '@vueuse/core';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildJsonLineSegments,
|
buildJsonLineSegments,
|
||||||
|
buildVisibleProjection,
|
||||||
|
clampLineToVisible,
|
||||||
computeGutterWidth,
|
computeGutterWidth,
|
||||||
computeVisibleRowWindow,
|
computeVisibleRowWindow,
|
||||||
|
findVisibleIndex,
|
||||||
formatJsonOrRawText,
|
formatJsonOrRawText,
|
||||||
JSON_VIEW_ROW_HEIGHT_PX,
|
JSON_VIEW_ROW_HEIGHT_PX,
|
||||||
|
scanJsonFolds,
|
||||||
splitTextIntoLines,
|
splitTextIntoLines,
|
||||||
|
type JsonFoldRange,
|
||||||
} from '@/lib/json-text-processor';
|
} from '@/lib/json-text-processor';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -35,6 +42,92 @@ const props = defineProps<{
|
|||||||
const formattedText = computed(() => formatJsonOrRawText(props.json, props.rawText));
|
const formattedText = computed(() => formatJsonOrRawText(props.json, props.rawText));
|
||||||
const lineList = computed(() => splitTextIntoLines(formattedText.value));
|
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) ---------- */
|
/* ---------- 容器尺寸与滚动位置(VueUse) ---------- */
|
||||||
|
|
||||||
const scrollContainerRef = ref<HTMLElement | null>(null);
|
const scrollContainerRef = ref<HTMLElement | null>(null);
|
||||||
@@ -55,13 +148,17 @@ const handleScroll = useThrottleFn(
|
|||||||
/* ---------- 可视窗口(纯函数,阶段二已单测锁死) ---------- */
|
/* ---------- 可视窗口(纯函数,阶段二已单测锁死) ---------- */
|
||||||
|
|
||||||
const visibleWindow = computed(() =>
|
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 {
|
interface RenderableLine {
|
||||||
lineNumber: number;
|
lineNumber: number;
|
||||||
segments: ReturnType<typeof buildJsonLineSegments>;
|
segments: ReturnType<typeof buildJsonLineSegments>;
|
||||||
|
/** 该行是否为某个 fold 的折叠头(渲染 chevron 与行尾摘要) */
|
||||||
|
foldAtLine: JsonFoldRange | null;
|
||||||
|
/** 折叠头行当前是否处于折叠态(决定 chevron 方向与摘要显隐) */
|
||||||
|
isFoldCollapsed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleLineList = computed<RenderableLine[]>(() => {
|
const visibleLineList = computed<RenderableLine[]>(() => {
|
||||||
@@ -71,14 +168,21 @@ const visibleLineList = computed<RenderableLine[]>(() => {
|
|||||||
index <= visibleWindow.value.endIndex;
|
index <= visibleWindow.value.endIndex;
|
||||||
index += 1
|
index += 1
|
||||||
) {
|
) {
|
||||||
const lineText = lineList.value[index];
|
const modelLine = visibleModel.value[index];
|
||||||
if (lineText === undefined) continue;
|
const lineText = modelLine === undefined ? undefined : lineList.value[modelLine - 1];
|
||||||
lines.push({ lineNumber: index + 1, segments: buildJsonLineSegments(lineText) });
|
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;
|
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));
|
const gutterWidth = computed(() => computeGutterWidth(lineList.value.length));
|
||||||
|
|
||||||
/* ---------- 行选区(model 层锚点,编辑器 model/view 分离的微缩版) ----------
|
/* ---------- 行选区(model 层锚点,编辑器 model/view 分离的微缩版) ----------
|
||||||
@@ -113,24 +217,25 @@ function isLineSelected(lineNumber: number): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 同步真实 DOM Selection:让 Ctrl+C 直接可用。
|
/** 同步真实 DOM Selection:让 Ctrl+C 直接可用。
|
||||||
* anchor/active 行可能不在窗口内(DOM 不存在),用窗口内实际渲染的边缘行近似。
|
* 选区端点是 model 行号:先钳制到可见(划进隐藏区停在折叠头行),再转可视索引,
|
||||||
|
* 与可视窗口求交集后索引窗口内实际渲染的边缘行(anchor/active 行可能不在窗口内)。
|
||||||
*/
|
*/
|
||||||
function syncDomSelection(): void {
|
function syncDomSelection(): void {
|
||||||
const range = selectedLineRange.value;
|
const range = selectedLineRange.value;
|
||||||
const container = scrollContainerRef.value;
|
const container = scrollContainerRef.value;
|
||||||
if (!range || !container) return;
|
if (!range || !container) return;
|
||||||
const [start, end] = range;
|
const [start, end] = range;
|
||||||
|
const clampedStart = clampSelectionLine(start);
|
||||||
|
const clampedEnd = clampSelectionLine(end);
|
||||||
const { startIndex, endIndex } = visibleWindow.value;
|
const { startIndex, endIndex } = visibleWindow.value;
|
||||||
// 选区与可视窗口的交集边缘(1-based 行号 → 渲染索引)
|
// 可视索引(0-based)与可视窗口的交集(窗口起止也是可视索引)
|
||||||
const viewStartLine = startIndex + 1;
|
const selStartVisIdx = Math.max(findVisibleIndex(visibleModel.value, clampedStart), startIndex);
|
||||||
const viewEndLine = endIndex + 1;
|
const selEndVisIdx = Math.min(findVisibleIndex(visibleModel.value, clampedEnd), endIndex);
|
||||||
const selStartLine = Math.max(start, viewStartLine);
|
if (selStartVisIdx > selEndVisIdx) return;
|
||||||
const selEndLine = Math.min(end, viewEndLine);
|
|
||||||
if (selStartLine > selEndLine) return;
|
|
||||||
|
|
||||||
const lineElements = container.querySelectorAll<HTMLElement>('.line');
|
const lineElements = container.querySelectorAll<HTMLElement>('.line');
|
||||||
const startEl = lineElements[selStartLine - viewStartLine];
|
const startEl = lineElements[selStartVisIdx - startIndex];
|
||||||
const endEl = lineElements[selEndLine - viewStartLine];
|
const endEl = lineElements[selEndVisIdx - startIndex];
|
||||||
if (!startEl || !endEl) return;
|
if (!startEl || !endEl) return;
|
||||||
|
|
||||||
const domSelection = window.getSelection();
|
const domSelection = window.getSelection();
|
||||||
@@ -148,8 +253,8 @@ function handleGutterMouseDown(lineNumber: number, event: MouseEvent): void {
|
|||||||
// 只响应主键,避免与右键菜单/中键冲突
|
// 只响应主键,避免与右键菜单/中键冲突
|
||||||
if (event.button !== 0) return;
|
if (event.button !== 0) return;
|
||||||
event.preventDefault(); // 防止浏览器默认文本拖选与我们的状态机冲突
|
event.preventDefault(); // 防止浏览器默认文本拖选与我们的状态机冲突
|
||||||
selectionAnchorLine.value = lineNumber;
|
selectionAnchorLine.value = clampSelectionLine(lineNumber);
|
||||||
selectionActiveLine.value = lineNumber;
|
selectionActiveLine.value = clampSelectionLine(lineNumber);
|
||||||
syncDomSelection();
|
syncDomSelection();
|
||||||
|
|
||||||
const finish = (): void => {
|
const finish = (): void => {
|
||||||
@@ -162,7 +267,7 @@ function handleGutterMouseDown(lineNumber: number, event: MouseEvent): void {
|
|||||||
const onMove = (moveEvent: MouseEvent): void => {
|
const onMove = (moveEvent: MouseEvent): void => {
|
||||||
const lineAtPointer = resolveLineFromPointer(moveEvent.clientY);
|
const lineAtPointer = resolveLineFromPointer(moveEvent.clientY);
|
||||||
if (lineAtPointer === null) return;
|
if (lineAtPointer === null) return;
|
||||||
selectionActiveLine.value = lineAtPointer;
|
selectionActiveLine.value = clampSelectionLine(lineAtPointer);
|
||||||
syncDomSelection();
|
syncDomSelection();
|
||||||
updateEdgeAutoScroll(moveEvent.clientY);
|
updateEdgeAutoScroll(moveEvent.clientY);
|
||||||
};
|
};
|
||||||
@@ -170,15 +275,18 @@ function handleGutterMouseDown(lineNumber: number, event: MouseEvent): void {
|
|||||||
document.addEventListener('mouseup', finish, { once: true });
|
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 {
|
function resolveLineFromPointer(clientY: number): number | null {
|
||||||
const container = scrollContainerRef.value;
|
const container = scrollContainerRef.value;
|
||||||
if (!container) return null;
|
if (!container) return null;
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
const offsetY = clientY - rect.top + container.scrollTop;
|
const offsetY = clientY - rect.top + container.scrollTop;
|
||||||
const lineIndex = Math.floor(offsetY / JSON_VIEW_ROW_HEIGHT_PX);
|
const visIdx = Math.floor(offsetY / JSON_VIEW_ROW_HEIGHT_PX);
|
||||||
if (lineIndex < 0 || lineIndex >= lineList.value.length) return null;
|
if (visIdx < 0 || visIdx >= visibleModel.value.length) return null;
|
||||||
return lineIndex + 1;
|
const modelLine = visibleModel.value[visIdx];
|
||||||
|
return modelLine === undefined ? null : modelLine;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 拖到容器边缘的自动滚动(编辑器标配) ---------- */
|
/* ---------- 拖到容器边缘的自动滚动(编辑器标配) ---------- */
|
||||||
@@ -236,17 +344,16 @@ function updateEdgeAutoScroll(pointerClientY: number): void {
|
|||||||
}
|
}
|
||||||
container.scrollTop += speed;
|
container.scrollTop += speed;
|
||||||
// 自动滚动后按新的 scrollTop 推进选区(与滚轮/滚动条共用 scrollTopPx 同源链路)
|
// 自动滚动后按新的 scrollTop 推进选区(与滚轮/滚动条共用 scrollTopPx 同源链路)
|
||||||
const lineAtEdge =
|
const topVisIdx = Math.max(0, Math.floor(container.scrollTop / JSON_VIEW_ROW_HEIGHT_PX));
|
||||||
speed < 0
|
const bottomVisIdx = Math.min(
|
||||||
? Math.max(1, Math.floor(container.scrollTop / JSON_VIEW_ROW_HEIGHT_PX) + 1)
|
visibleModel.value.length - 1,
|
||||||
: Math.min(
|
Math.floor((container.scrollTop + container.clientHeight - 1) / JSON_VIEW_ROW_HEIGHT_PX),
|
||||||
lineList.value.length,
|
);
|
||||||
Math.floor(
|
const lineAtEdge = speed < 0 ? visibleModel.value[topVisIdx] : visibleModel.value[bottomVisIdx];
|
||||||
(container.scrollTop + container.clientHeight - 1) / JSON_VIEW_ROW_HEIGHT_PX,
|
if (lineAtEdge !== undefined) {
|
||||||
) + 1,
|
selectionActiveLine.value = clampSelectionLine(lineAtEdge);
|
||||||
);
|
syncDomSelection();
|
||||||
selectionActiveLine.value = lineAtEdge;
|
}
|
||||||
syncDomSelection();
|
|
||||||
edgeScrollRafId = requestAnimationFrame(step);
|
edgeScrollRafId = requestAnimationFrame(step);
|
||||||
};
|
};
|
||||||
edgeScrollRafId = requestAnimationFrame(step);
|
edgeScrollRafId = requestAnimationFrame(step);
|
||||||
@@ -273,6 +380,16 @@ function updateEdgeAutoScroll(pointerClientY: number): void {
|
|||||||
@mousedown="handleGutterMouseDown(line.lineNumber, $event)"
|
@mousedown="handleGutterMouseDown(line.lineNumber, $event)"
|
||||||
>{{ line.lineNumber }}</span
|
>{{ 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">
|
<span class="line-content">
|
||||||
<template v-for="(segment, segmentIndex) in line.segments" :key="segmentIndex">
|
<template v-for="(segment, segmentIndex) in line.segments" :key="segmentIndex">
|
||||||
<span v-if="segment.kind !== 'plain'" class="token" :class="segment.kind">{{
|
<span v-if="segment.kind !== 'plain'" class="token" :class="segment.kind">{{
|
||||||
@@ -280,6 +397,15 @@ function updateEdgeAutoScroll(pointerClientY: number): void {
|
|||||||
}}</span>
|
}}</span>
|
||||||
<template v-else>{{ segment.text }}</template>
|
<template v-else>{{ segment.text }}</template>
|
||||||
</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,6 +437,44 @@ function updateEdgeAutoScroll(pointerClientY: number): void {
|
|||||||
user-select: none;
|
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 会随虚拟滚动窗口变化失效) */
|
/* model 层选区高亮:重渲染不丢(DOM ::selection 会随虚拟滚动窗口变化失效) */
|
||||||
.line.line-selected {
|
.line.line-selected {
|
||||||
background: var(--td-brand-color-light-focus, rgba(0, 82, 217, 0.18));
|
background: var(--td-brand-color-light-focus, rgba(0, 82, 217, 0.18));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 左侧接口导航:5 个路径平铺(不分组不编号,按 endpoints.ts 定案顺序)
|
* 左侧接口导航:路径平铺(不分组不编号,按 endpoints.ts 定案顺序)
|
||||||
* 点击 → openOrActivateTab(已开则激活,未开则新开)
|
* 点击 → openOrActivateTab(已开则激活,未开则新开)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildJsonLineSegments,
|
buildJsonLineSegments,
|
||||||
|
buildVisibleProjection,
|
||||||
|
clampLineToVisible,
|
||||||
computeGutterWidth,
|
computeGutterWidth,
|
||||||
computeVisibleRowWindow,
|
computeVisibleRowWindow,
|
||||||
|
findVisibleIndex,
|
||||||
formatJsonOrRawText,
|
formatJsonOrRawText,
|
||||||
JSON_VIEW_OVERSCAN_ROWS,
|
JSON_VIEW_OVERSCAN_ROWS,
|
||||||
JSON_VIEW_ROW_HEIGHT_PX,
|
JSON_VIEW_ROW_HEIGHT_PX,
|
||||||
|
scanJsonFolds,
|
||||||
splitTextIntoLines,
|
splitTextIntoLines,
|
||||||
} from './json-text-processor';
|
} from './json-text-processor';
|
||||||
|
|
||||||
@@ -127,3 +131,161 @@ describe('computeGutterWidth', () => {
|
|||||||
expect(computeGutterWidth(0)).toBe(1);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -112,3 +112,131 @@ export function buildJsonLineSegments(line: string): JsonLineSegment[] {
|
|||||||
export function computeGutterWidth(totalRowCount: number): number {
|
export function computeGutterWidth(totalRowCount: number): number {
|
||||||
return String(Math.max(totalRowCount, 1)).length;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user