Archived
feat(data): 接口静态数据、网关 fetch wrapper、虚拟滚动文本加工(纯 TS + 单测)
json-text-processor 直接以 segments 方案实现完整行重建:token 与 plain 交替,join 等于原文,括号/缩进/逗号逐字不丢;高亮输出结构化 span 无 v-html;窗口计算含 scrollTop 超界钳制。
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { endpointList, findEndpointByPath } from './endpoints';
|
||||
|
||||
describe('endpointList 数据完整性', () => {
|
||||
it('恰好 5 个接口(设计文档 §1 定案的对接清单)', () => {
|
||||
expect(endpointList).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('路径唯一(path 是 tab / queryKey 的唯一标识)', () => {
|
||||
const pathList = endpointList.map((endpoint) => endpoint.path);
|
||||
expect(new Set(pathList).size).toBe(5);
|
||||
});
|
||||
|
||||
it('顺序与用户指定清单一致', () => {
|
||||
expect(endpointList.map((endpoint) => endpoint.path)).toEqual([
|
||||
'/v1/dashboard/billing/subscription',
|
||||
'/v1/dashboard/billing/usage',
|
||||
'/v1/models',
|
||||
'/api/usage/token',
|
||||
'/api/log/token',
|
||||
]);
|
||||
});
|
||||
|
||||
it('全部为 GET 且带鉴权元数据', () => {
|
||||
for (const endpoint of endpointList) {
|
||||
expect(endpoint.method).toBe('GET');
|
||||
expect(['strict', 'lenient']).toContain(endpoint.authMode);
|
||||
expect(endpoint.authMiddlewareName).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('严格/宽松鉴权分布正确(前 3 严格,后 2 宽松)', () => {
|
||||
expect(endpointList.map((endpoint) => endpoint.authMode)).toEqual([
|
||||
'strict',
|
||||
'strict',
|
||||
'strict',
|
||||
'lenient',
|
||||
'lenient',
|
||||
]);
|
||||
});
|
||||
|
||||
it('每个接口都有注意事项与示例响应(tab 内文档的数据源)', () => {
|
||||
for (const endpoint of endpointList) {
|
||||
expect(endpoint.notes.length).toBeGreaterThan(0);
|
||||
expect(endpoint.sampleResponse).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findEndpointByPath', () => {
|
||||
it('命中已知路径', () => {
|
||||
expect(findEndpointByPath('/v1/models')?.responseSummary).toContain('模型列表');
|
||||
});
|
||||
|
||||
it('未知路径返回 undefined', () => {
|
||||
expect(findEndpointByPath('/v1/nonexistent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 5 个对接接口的静态元数据
|
||||
* 数据源:docs/NewAPI-sk-key接口文档.md(源码实证调研)
|
||||
* 顺序即 sider / tab 展示顺序(设计文档 §1 定案,不分组不编号)
|
||||
*/
|
||||
|
||||
export type AuthMode = 'strict' | 'lenient';
|
||||
|
||||
export interface EndpointDefinition {
|
||||
/** 接口路径(唯一标识,同时用作 tab 标签与 queryKey 组成部分) */
|
||||
readonly path: string;
|
||||
/** HTTP 方法(当前全部为 GET,保留扩展位) */
|
||||
readonly method: 'GET';
|
||||
/** 鉴权模式:strict = TokenAuth(key 无效/过期/耗尽直接被拒);lenient = TokenAuthReadOnly(宽松,废 key 也能查) */
|
||||
readonly authMode: AuthMode;
|
||||
/** 鉴权中间件名(展示用) */
|
||||
readonly authMiddlewareName: string;
|
||||
/** 响应特征一句话(展示用) */
|
||||
readonly responseSummary: string;
|
||||
/** 注意事项(tab 内折叠文档,文案来自接口文档的实证调研) */
|
||||
readonly notes: readonly string[];
|
||||
/** 示例响应(展示用,结构对齐真实接口) */
|
||||
readonly sampleResponse: unknown;
|
||||
}
|
||||
|
||||
export const endpointList: readonly EndpointDefinition[] = [
|
||||
{
|
||||
path: '/v1/dashboard/billing/subscription',
|
||||
method: 'GET',
|
||||
authMode: 'strict',
|
||||
authMiddlewareName: 'TokenAuth',
|
||||
responseSummary: '总额度上限(美元)',
|
||||
notes: [
|
||||
'受 DisplayTokenStatEnabled 开关影响:开启返回 token 级额度,关闭回退到账号级额度(会暴露整个账号余额)',
|
||||
'无限额度 key 返回 100000000(一亿)占位值,无参考意义',
|
||||
'受 quota_display_type 影响:USD 模式才是美元;CNY 模式会做货币转换;Tokens 模式是原始 token 数',
|
||||
'hard_limit_usd(本接口)− total_usage ÷ 100(usage 接口)= 剩余额度(仅有限额 key 有意义)',
|
||||
],
|
||||
sampleResponse: {
|
||||
object: 'billing_subscription',
|
||||
has_payment_method: true,
|
||||
soft_limit_usd: 100000000,
|
||||
hard_limit_usd: 100000000,
|
||||
system_hard_limit_usd: 100000000,
|
||||
access_until: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/v1/dashboard/billing/usage',
|
||||
method: 'GET',
|
||||
authMode: 'strict',
|
||||
authMiddlewareName: 'TokenAuth',
|
||||
responseSummary: '累计已用(美分)',
|
||||
notes: [
|
||||
'total_usage 单位是美分,换算美元 ÷ 100',
|
||||
'累计值(key 创建至今),非当月用量',
|
||||
'与 /api/usage/token 的 total_used ÷ 500000 完全吻合(QuotaPerUnit = 500000 的双重验证)',
|
||||
'受 DisplayTokenStatEnabled 与 quota_display_type 影响(同 subscription 接口)',
|
||||
],
|
||||
sampleResponse: { object: 'list', total_usage: 134683.8914 },
|
||||
},
|
||||
{
|
||||
path: '/v1/models',
|
||||
method: 'GET',
|
||||
authMode: 'strict',
|
||||
authMiddlewareName: 'TokenAuth',
|
||||
responseSummary: '可用模型列表(OpenAI 格式)',
|
||||
notes: [
|
||||
'数据源是数据库 abilities 表,反映渠道实际配置的模型',
|
||||
'与 /v1/models/:model 单查接口数据源不同(后者查代码内硬编码静态表)',
|
||||
'渠道新配的模型可能出现「列表里有、单查 model_not_found」,不影响实际调用',
|
||||
],
|
||||
sampleResponse: {
|
||||
object: 'list',
|
||||
data: [{ id: 'gpt-4o', object: 'model', created: 1686935000, owned_by: 'openai' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/api/usage/token',
|
||||
method: 'GET',
|
||||
authMode: 'lenient',
|
||||
authMiddlewareName: 'TokenAuthReadOnly',
|
||||
responseSummary: 'token 级用量汇总(原始 quota)',
|
||||
notes: [
|
||||
'total_used 是累计值(key 创建至今),非当月用量',
|
||||
'额度为原始 quota 整数,换算美元 ÷ 500000(QuotaPerUnit)',
|
||||
'unlimited_quota = true 时 total_granted / total_available 为占位值,无实际意义',
|
||||
'禁用再解禁不会重置 total_used(源码验证:Update() 的 Select 列表不含 used_quota)',
|
||||
'不受 DisplayTokenStatEnabled 开关影响,恒返回 token 级数据',
|
||||
],
|
||||
sampleResponse: {
|
||||
code: true,
|
||||
message: 'ok',
|
||||
data: {
|
||||
object: 'token_usage',
|
||||
name: '令牌名称',
|
||||
total_granted: 6012,
|
||||
total_used: 673419457,
|
||||
total_available: -673413445,
|
||||
unlimited_quota: true,
|
||||
model_limits: {},
|
||||
model_limits_enabled: false,
|
||||
expires_at: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/api/log/token',
|
||||
method: 'GET',
|
||||
authMode: 'lenient',
|
||||
authMiddlewareName: 'TokenAuthReadOnly',
|
||||
responseSummary: '最近 1000 条调用日志',
|
||||
notes: [
|
||||
'硬上限 1000 条(common.MaxRecentItems = 1000)',
|
||||
'不支持任何请求参数:分页 / limit / 时间过滤全被忽略',
|
||||
'固定按 id desc 排序,即最新 1000 条',
|
||||
'月调用量超 1000 次时,此接口无法覆盖完整当月',
|
||||
'大响应:pretty-print 后 2~4MB,约两万行——虚拟滚动的核心场景',
|
||||
],
|
||||
sampleResponse: {
|
||||
success: true,
|
||||
message: '',
|
||||
data: [
|
||||
{
|
||||
id: 123456,
|
||||
user_id: 12,
|
||||
created_at: 1751328000,
|
||||
type: 2,
|
||||
content: '模型倍率 1.0,分组倍率 1.0',
|
||||
username: '用户名',
|
||||
token_name: '令牌名',
|
||||
model_name: 'gpt-4o',
|
||||
quota: 2500,
|
||||
prompt_tokens: 1500,
|
||||
completion_tokens: 500,
|
||||
use_time: 2,
|
||||
is_stream: true,
|
||||
channel: 0,
|
||||
channel_name: '',
|
||||
token_id: 100,
|
||||
group: 'default',
|
||||
ip: '',
|
||||
request_id: 'req_xxx',
|
||||
upstream_request_id: '',
|
||||
other: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** 按路径查接口定义 */
|
||||
export function findEndpointByPath(path: string): EndpointDefinition | undefined {
|
||||
return endpointList.find((endpoint) => endpoint.path === path);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildAuthorizationHeader } from './gateway-client';
|
||||
|
||||
describe('buildAuthorizationHeader', () => {
|
||||
it('有效 key 拼出 Bearer 头', () => {
|
||||
expect(buildAuthorizationHeader('sk-abc123')).toEqual({ Authorization: 'Bearer sk-abc123' });
|
||||
});
|
||||
|
||||
it('key 两端空白被裁剪', () => {
|
||||
expect(buildAuthorizationHeader(' sk-abc123 ')).toEqual({
|
||||
Authorization: 'Bearer sk-abc123',
|
||||
});
|
||||
});
|
||||
|
||||
it('空 key 不带鉴权头(交给网关拒绝,调试工具要看到真实的 401)', () => {
|
||||
expect(buildAuthorizationHeader('')).toEqual({});
|
||||
});
|
||||
|
||||
it('纯空白 key 同样视为空', () => {
|
||||
expect(buildAuthorizationHeader(' ')).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* NewAPI 网关的薄 fetch wrapper
|
||||
* 职责:拼 Authorization 头、计时、量字节数、分离 JSON / 纯文本
|
||||
* 刻意不做的事(设计文档 §4.3):无拦截器、无重试——调试工具必须看到真实的失败
|
||||
*/
|
||||
|
||||
export interface GatewayResponse {
|
||||
/** HTTP 状态码;网络层失败(fetch reject)时为 null */
|
||||
readonly status: number | null;
|
||||
/** 请求耗时(毫秒,performance.now 量测) */
|
||||
readonly durationMs: number;
|
||||
/** 响应体字节数(按字符数近似,仅展示用) */
|
||||
readonly byteLength: number;
|
||||
/** 响应体原文 */
|
||||
readonly text: string;
|
||||
/** JSON.parse 成功的结果;解析失败为 null(非 JSON 响应原样展示) */
|
||||
readonly json: unknown;
|
||||
/** 网络层错误信息(fetch reject 时);成功为 null */
|
||||
readonly networkError: string | null;
|
||||
}
|
||||
|
||||
export function buildAuthorizationHeader(skKey: string): Record<string, string> {
|
||||
const trimmedKey = skKey.trim();
|
||||
if (!trimmedKey) return {};
|
||||
return { Authorization: `Bearer ${trimmedKey}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求网关接口(相对路径,经 Vite dev server 代理转发到网关,见 vite.config.ts)
|
||||
*/
|
||||
export async function requestGatewayEndpoint(path: string, skKey: string): Promise<GatewayResponse> {
|
||||
const requestStartedAt = performance.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(path, { headers: buildAuthorizationHeader(skKey) });
|
||||
const text = await response.text();
|
||||
const durationMs = Math.round(performance.now() - requestStartedAt);
|
||||
|
||||
let json: unknown = null;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
// 非 JSON 响应(如网关 502 的 HTML 错误页):保持 json = null,UI 走纯文本展示
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
durationMs,
|
||||
byteLength: text.length,
|
||||
text,
|
||||
json,
|
||||
networkError: null,
|
||||
};
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - requestStartedAt);
|
||||
return {
|
||||
status: null,
|
||||
durationMs,
|
||||
byteLength: 0,
|
||||
text: '',
|
||||
json: null,
|
||||
networkError: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// 阶段一冒烟测试:验证 vitest 管线(数据层单测在阶段二补齐)
|
||||
describe('vitest pipeline', () => {
|
||||
it('runs', () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user