feat(data): 接口静态数据、网关 fetch wrapper、虚拟滚动文本加工(纯 TS + 单测)

json-text-processor 直接以 segments 方案实现完整行重建:token 与 plain 交替,join 等于原文,括号/缩进/逗号逐字不丢;高亮输出结构化 span 无 v-html;窗口计算含 scrollTop 超界钳制。
This commit is contained in:
2026-09-08 00:49:26 +08:00
parent c3d6f379f9
commit 78f2e7136e
7 changed files with 549 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest';
import {
buildJsonLineSegments,
computeGutterWidth,
computeVisibleRowWindow,
formatJsonOrRawText,
JSON_VIEW_OVERSCAN_ROWS,
JSON_VIEW_ROW_HEIGHT_PX,
splitTextIntoLines,
} from './json-text-processor';
describe('splitTextIntoLines', () => {
it('多行文本正常切分', () => {
expect(splitTextIntoLines('{\n "a": 1\n}')).toEqual(['{', ' "a": 1', '}']);
});
it('空文本返回空数组(虚拟滚动零行安全)', () => {
expect(splitTextIntoLines('')).toEqual([]);
});
it('结尾换行不产生幽灵空行', () => {
expect(splitTextIntoLines('a\nb\n')).toEqual(['a', 'b']);
});
it('单行无换行原样返回', () => {
expect(splitTextIntoLines('hello')).toEqual(['hello']);
});
});
describe('formatJsonOrRawText', () => {
it('JSON 值 pretty-print 两空格缩进', () => {
expect(formatJsonOrRawText({ a: 1 }, 'ignored')).toBe('{\n "a": 1\n}');
});
it('json 为 null(非 JSON 响应)时降级为原文', () => {
expect(formatJsonOrRawText(null, '<html>502</html>')).toBe('<html>502</html>');
});
});
describe('computeVisibleRowWindow', () => {
const ROW = JSON_VIEW_ROW_HEIGHT_PX; // 20px
it('常量导出符合预期(与组件 CSS 对齐)', () => {
expect(ROW).toBe(20);
expect(JSON_VIEW_OVERSCAN_ROWS).toBe(5);
});
it('常规窗口:1000 行、滚到顶部、视口 400px', () => {
const win = computeVisibleRowWindow(1000, 0, 400);
expect(win.startIndex).toBe(0);
// 首行 0 - overscan 5 → 钳到 0;末行 0 + 20 行可见 + 5 缓冲 - 1
expect(win.endIndex).toBe(24);
expect(win.offsetYPx).toBe(0);
});
it('中部滚动:scrollTop 10000(第 500 行起)', () => {
const win = computeVisibleRowWindow(1000, 10000, 400);
expect(win.startIndex).toBe(495);
// 可见 20 行 + 上下缓冲各 5:500-5 到 500+20+5-1 → 495..524
expect(win.endIndex).toBe(524);
expect(win.offsetYPx).toBe(495 * ROW);
});
it('底部滚动:scrollTop 超界被钳制在最后一屏,末行 = totalRowCount - 1', () => {
const win = computeVisibleRowWindow(100, 99999, 400);
expect(win.endIndex).toBe(99);
// 100 行 2000px - 视口 400px = 最大 scrollTop 1600px → 第 80 行起
expect(win.startIndex).toBe(75);
});
it('零行(空响应)返回空窗口', () => {
expect(computeVisibleRowWindow(0, 0, 400)).toEqual({ startIndex: 0, endIndex: -1, offsetYPx: 0 });
});
it('非正视口高度安全返回空窗口', () => {
expect(computeVisibleRowWindow(100, 0, 0).endIndex).toBe(-1);
});
it('行数少于一屏时全量可见(endIndex = totalRowCount - 1)', () => {
const win = computeVisibleRowWindow(10, 0, 400);
expect(win.startIndex).toBe(0);
expect(win.endIndex).toBe(9);
});
});
describe('buildJsonLineSegments(完整行重建——括号不丢)', () => {
it('典型行:plain 前缀 + key + plain + number + plain 尾巴,join 等于原文', () => {
const line = ' "total_used": 673419457,';
const segments = buildJsonLineSegments(line);
expect(segments.map((segment) => segment.text).join('')).toBe(line);
expect(segments).toEqual([
{ text: ' ', kind: 'plain' },
{ text: '"total_used":', kind: 'key' },
{ text: ' ', kind: 'plain' },
{ text: '673419457', kind: 'number' },
{ text: ',', kind: 'plain' },
]);
});
it('纯结构行(括号/逗号/空白)整行 plain', () => {
expect(buildJsonLineSegments(' },')).toEqual([{ text: ' },', kind: 'plain' }]);
expect(buildJsonLineSegments('[')).toEqual([{ text: '[', kind: 'plain' }]);
});
it('开头的 { 也能渲染(上一版丢括号 bug 的回归测试)', () => {
expect(buildJsonLineSegments('{')).toEqual([{ text: '{', kind: 'plain' }]);
});
it('token 起始的行无 plain 前缀', () => {
const segments = buildJsonLineSegments('"code": true');
expect(segments[0]?.kind).toBe('key');
expect(segments.map((segment) => segment.text).join('')).toBe('"code": true');
});
});
describe('computeGutterWidth', () => {
it('按最大位数计算(两万行 → 5 位)', () => {
expect(computeGutterWidth(20000)).toBe(5);
});
it('零行时至少 1 位(占位安全)', () => {
expect(computeGutterWidth(0)).toBe(1);
});
});
+114
View File
@@ -0,0 +1,114 @@
/**
* JSON 文本的虚拟滚动数据加工(纯函数,vitest 覆盖)
*
* 性能背景(旧版 api-playground 卡死的四大主因之一就是全量高亮 + v-html):
* - 1000 条日志 pretty-print 后 2~4MB、约两万行
* - 本模块只做两件事:把文本切成行;按滚动位置算出「该渲染哪些行」
* - 语法高亮只对可视区窗口内的行执行(json-text-view.vue 消费)
*/
/** 虚拟滚动常量(与 json-text-view.vue 的 CSS 对齐:12.5px 等宽字体 / 1.6 行高) */
export const JSON_VIEW_ROW_HEIGHT_PX = 20;
/** 可视区上下各多渲染的缓冲行数(滚动时避免白屏) */
export const JSON_VIEW_OVERSCAN_ROWS = 5;
export interface VisibleRowWindow {
/** 窗口内首行索引(含) */
readonly startIndex: number;
/** 窗口内末行索引(含);无行时 endIndex < startIndex */
readonly endIndex: number;
/** 窗口首行相对容器的像素偏移(撑起滚动高度的占位顶部) */
readonly offsetYPx: number;
}
/** 把响应文本切成行(空文本 → 空数组;结尾换行不产生幽灵空行) */
export function splitTextIntoLines(text: string): string[] {
if (text === '') return [];
const lines = text.split('\n');
return text.endsWith('\n') ? lines.slice(0, -1) : lines;
}
/** JSON 值 pretty-print;非 JSON 输入(含 null / undefined / 解析失败)返回原文(UI 降级为纯文本展示) */
export function formatJsonOrRawText(json: unknown, rawText: string): string {
if (json === null || json === undefined) return rawText;
try {
return JSON.stringify(json, null, 2);
} catch {
return rawText;
}
}
/** 按容器高度与滚动位置计算可视行窗口(越界钳制、空行集安全) */
export function computeVisibleRowWindow(
totalRowCount: number,
scrollTopPx: number,
viewportHeightPx: number,
rowHeightPx: number = JSON_VIEW_ROW_HEIGHT_PX,
overscanRows: number = JSON_VIEW_OVERSCAN_ROWS,
): VisibleRowWindow {
if (totalRowCount <= 0 || viewportHeightPx <= 0 || rowHeightPx <= 0) {
return { startIndex: 0, endIndex: -1, offsetYPx: 0 };
}
// scrollTop 超过总高度(如内容 shrink 后的惯性滚动)先钳到最后一屏
const maxScrollTopPx = Math.max(0, totalRowCount * rowHeightPx - viewportHeightPx);
const clampedScrollTopPx = Math.min(Math.max(scrollTopPx, 0), maxScrollTopPx);
const firstVisibleRow = Math.floor(clampedScrollTopPx / rowHeightPx);
const visibleRowCount = Math.ceil(viewportHeightPx / rowHeightPx);
const startIndex = Math.max(0, firstVisibleRow - overscanRows);
const endIndex = Math.min(
totalRowCount - 1,
firstVisibleRow + visibleRowCount + overscanRows - 1,
);
return { startIndex, endIndex, offsetYPx: startIndex * rowHeightPx };
}
/* ---------- 行级语法高亮(仅对可视区行执行,单行成本微秒级) ---------- */
const JSON_TOKEN_PATTERN =
/("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(?:\s*:)?|\btrue\b|\bfalse\b|\bnull\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g;
export type JsonTokenKind = 'key' | 'string' | 'number' | 'boolean' | 'null';
function classifyJsonToken(matchedText: string): JsonTokenKind {
if (matchedText.startsWith('"')) return matchedText.endsWith(':') ? 'key' : 'string';
if (matchedText === 'true' || matchedText === 'false') return 'boolean';
if (matchedText === 'null') return 'null';
return 'number';
}
/** 行渲染片段:plain = 非 token 部分(括号/逗号/冒号/空白),无着色 */
export interface JsonLineSegment {
readonly text: string;
readonly kind: JsonTokenKind | 'plain';
}
/**
* 完整行重建:token 与非 token 片段交替,segments.map(s => s.text).join('') === 原文,
* 渲染层据此保证括号/缩进/逗号与原文逐字一致
*/
export function buildJsonLineSegments(line: string): JsonLineSegment[] {
const segments: JsonLineSegment[] = [];
let cursor = 0;
for (const match of line.matchAll(JSON_TOKEN_PATTERN)) {
const matchIndex = match.index ?? 0;
if (matchIndex > cursor) {
segments.push({ text: line.slice(cursor, matchIndex), kind: 'plain' });
}
segments.push({ text: match[0], kind: classifyJsonToken(match[0]) });
cursor = matchIndex + match[0].length;
}
if (cursor < line.length) {
segments.push({ text: line.slice(cursor), kind: 'plain' });
}
return segments;
}
/** 行号显示宽度(如 20000 行 → 5 位,供行号列对齐) */
export function computeGutterWidth(totalRowCount: number): number {
return String(Math.max(totalRowCount, 1)).length;
}
+8
View File
@@ -0,0 +1,8 @@
import { describe, expect, it } from 'vitest';
// 阶段一冒烟测试:验证 vitest 管线(数据层单测在阶段二补齐)
describe('vitest pipeline', () => {
it('runs', () => {
expect(1 + 1).toBe(2);
});
});