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
+59
View File
@@ -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();
});
});
+155
View File
@@ -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);
}
+23
View File
@@ -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({});
});
});
+65
View File
@@ -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),
};
}
}