From 78f2e7136eaf0006fce4ab8ceb7cae4cd97087c5 Mon Sep 17 00:00:00 2001 From: skycurtain Date: Tue, 8 Sep 2026 00:49:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(data):=20=E6=8E=A5=E5=8F=A3=E9=9D=99?= =?UTF-8?q?=E6=80=81=E6=95=B0=E6=8D=AE=E3=80=81=E7=BD=91=E5=85=B3=20fetch?= =?UTF-8?q?=20wrapper=E3=80=81=E8=99=9A=E6=8B=9F=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E5=8A=A0=E5=B7=A5=EF=BC=88=E7=BA=AF=20TS=20+?= =?UTF-8?q?=20=E5=8D=95=E6=B5=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit json-text-processor 直接以 segments 方案实现完整行重建:token 与 plain 交替,join 等于原文,括号/缩进/逗号逐字不丢;高亮输出结构化 span 无 v-html;窗口计算含 scrollTop 超界钳制。 --- src/api/endpoints.spec.ts | 59 +++++++++++ src/api/endpoints.ts | 155 ++++++++++++++++++++++++++++ src/api/gateway-client.spec.ts | 23 +++++ src/api/gateway-client.ts | 65 ++++++++++++ src/lib/json-text-processor.spec.ts | 125 ++++++++++++++++++++++ src/lib/json-text-processor.ts | 114 ++++++++++++++++++++ src/lib/smoke.spec.ts | 8 ++ 7 files changed, 549 insertions(+) create mode 100644 src/api/endpoints.spec.ts create mode 100644 src/api/endpoints.ts create mode 100644 src/api/gateway-client.spec.ts create mode 100644 src/api/gateway-client.ts create mode 100644 src/lib/json-text-processor.spec.ts create mode 100644 src/lib/json-text-processor.ts create mode 100644 src/lib/smoke.spec.ts diff --git a/src/api/endpoints.spec.ts b/src/api/endpoints.spec.ts new file mode 100644 index 0000000..2623365 --- /dev/null +++ b/src/api/endpoints.spec.ts @@ -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(); + }); +}); diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts new file mode 100644 index 0000000..db80c2c --- /dev/null +++ b/src/api/endpoints.ts @@ -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); +} diff --git a/src/api/gateway-client.spec.ts b/src/api/gateway-client.spec.ts new file mode 100644 index 0000000..b8f7bc6 --- /dev/null +++ b/src/api/gateway-client.spec.ts @@ -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({}); + }); +}); diff --git a/src/api/gateway-client.ts b/src/api/gateway-client.ts new file mode 100644 index 0000000..d50bc1e --- /dev/null +++ b/src/api/gateway-client.ts @@ -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 { + 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 { + 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), + }; + } +} diff --git a/src/lib/json-text-processor.spec.ts b/src/lib/json-text-processor.spec.ts new file mode 100644 index 0000000..eec4b49 --- /dev/null +++ b/src/lib/json-text-processor.spec.ts @@ -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, '502')).toBe('502'); + }); +}); + +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); + }); +}); diff --git a/src/lib/json-text-processor.ts b/src/lib/json-text-processor.ts new file mode 100644 index 0000000..18acdc8 --- /dev/null +++ b/src/lib/json-text-processor.ts @@ -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; +} diff --git a/src/lib/smoke.spec.ts b/src/lib/smoke.spec.ts new file mode 100644 index 0000000..165b268 --- /dev/null +++ b/src/lib/smoke.spec.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest'; + +// 阶段一冒烟测试:验证 vitest 管线(数据层单测在阶段二补齐) +describe('vitest pipeline', () => { + it('runs', () => { + expect(1 + 1).toBe(2); + }); +});