Archived
feat: 虚拟滚动 JSON 响应区 + README
json-text-view:DOM 恒定(可视窗口 ± overscan)、懒高亮(仅窗口内行 segments 切词)、spacer 撑高 + translateY 偏移;组件测试 stub useElementSize 固定视口断言行内容实际渲染。响应区完整化:元信息行/复制(useClipboard legacy 降级)/下载;README 定稿(运行需配 GATEWAY)。
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
# new-api-key-bench
|
||||||
|
|
||||||
|
NewAPI sk-key 接口的「文档 + 调试」二合一前端工具(bench),替代 Postman 的专用调试台,服务 karpool 拼车项目的接口调试与换算验证。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
Vite + Vue 3 + TypeScript + TDesign + TanStack Query(vue-query)+ Pinia + VueUse + Vitest。运行时与包管理器为 Bun。
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
网关地址只从环境变量 `GATEWAY` 读取(代码不写死默认值)。两种配置方式任选其一:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 方式一:复制示例文件后填入真实地址(.env.local 已被 gitignore,不会提交)
|
||||||
|
cp new-api-key-bench/.env.example new-api-key-bench/.env.local
|
||||||
|
# 然后编辑 .env.local,把 GATEWAY 改成你的网关地址
|
||||||
|
|
||||||
|
# 方式二:shell 环境变量(PowerShell:$env:GATEWAY="https://…")
|
||||||
|
export GATEWAY=https://your-gateway.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
未配置时 `bun run dev` 会直接报错提示;`build` 无代理不受影响。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd new-api-key-bench
|
||||||
|
bun install
|
||||||
|
bun run dev # 默认 http://localhost:5173,接口经 dev server 代理转发到 GATEWAY
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run test # vitest(单元 + 组件 + 集成冒烟全量)
|
||||||
|
bun run lint # oxlint + eslint
|
||||||
|
bun run build # type-check + vite build(dist/)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
1. 左侧选择接口(5 个,平铺不分组)→ 右侧开 tab
|
||||||
|
2. tab 内展开「注意事项」看该接口的实证调研坑文档
|
||||||
|
3. 填 sk-key(每 tab 独立、仅存内存、刷新即重置,不落盘;切换 tab 输入保留)
|
||||||
|
4. 点「发送请求」→ 响应区显示元信息(状态/耗时/字符数)+ 虚拟滚动高亮 JSON
|
||||||
|
5. 复制 / 下载 `.json`;非 2xx 响应体照常展示(调试 401/403 是刚需)
|
||||||
|
|
||||||
|
## 架构说明
|
||||||
|
|
||||||
|
- **CORS**:页面请求打相对路径(`/api`、`/v1`),由 Vite dev server 代理转发到网关(`vite.config.ts`,网关地址从环境变量 `GATEWAY` 读取),网关未配跨域头也不受影响
|
||||||
|
- **大响应性能**:`/api/log/token` 1000 条日志(pretty-print 2~4MB、约两万行)由 `json-text-view.vue` 虚拟滚动渲染——DOM 恒定 ~40 行,语法高亮仅对可视区行执行
|
||||||
|
- **请求语义**:纯手动触发(TanStack Query `enabled` 门控 + `refetch`),`staleTime: Infinity`,切 tab 不自动重发;queryKey 为 `['endpoint', path, skKey]`,换 key 即新查询
|
||||||
|
- **tab 状态**:Pinia(开/关/激活/拖拽重排 + 每 tab sk-key,Postman 语义);面板随 tab 切换卸载重建,sk-key 由 store 持有所以输入保留,响应数据由 Query 缓存兜底恢复;t-tabs 用官方 `list` prop 数据驱动(插槽 panel 在动态增删下与数据脱钩,是已踩过的坑)
|
||||||
|
|
||||||
|
## 已知边界
|
||||||
|
|
||||||
|
- 生产部署(`bun run build`)无 Vite 代理,需为网关路径配置反向代理
|
||||||
|
- `useClipboard` 在非 https 环境(如 http://localhost)自动降级 `execCommand`
|
||||||
|
- `/v1/models/:model` 单模型详情接口未纳入(数据源是硬编码静态表,与 `/v1/models` 不一致,无调试价值)
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- 交互与技术决策:`docs/bench交互设计文档.md`
|
||||||
|
- 施工计划与验收记录:`docs/bench实施计划.md`
|
||||||
|
- 接口数据源:`docs/NewAPI-sk-key接口文档.md`
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
/**
|
||||||
|
* json-text-view 虚拟滚动组件测试(阶段五测试预算,防性能回归)
|
||||||
|
* jsdom 下 ResizeObserver/真实布局不可用,故 stub useElementSize 返回固定视口高度,
|
||||||
|
* 让 computeVisibleRowWindow 走真实窗口路径;断言「行内容实际渲染」——
|
||||||
|
* tokens→segments 迁移时曾只改 script 漏改模板,type-check 挂而现有测试全绿没拦住
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import JsonTextView from './json-text-view.vue';
|
||||||
|
|
||||||
|
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@vueuse/core')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
// 固定视口 400px(约 20 行可视 + 2×5 overscan):窗口形状由测试直接控制
|
||||||
|
useElementSize: () => ({ width: ref(800), height: ref(400) }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeJsonText(entryCount: number): string {
|
||||||
|
const entries = Array.from({ length: entryCount }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
model_name: `model-${i}`,
|
||||||
|
quota: 2500,
|
||||||
|
}));
|
||||||
|
return JSON.stringify({ data: entries }, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('json-text-view 虚拟滚动', () => {
|
||||||
|
it('小 JSON(一屏内)全部行渲染:括号等 plain 不丢、token 按类着色', () => {
|
||||||
|
const wrapper = mount(JsonTextView, {
|
||||||
|
props: { json: { code: true, total_used: 673419457 }, rawText: '' },
|
||||||
|
attachTo: document.body,
|
||||||
|
});
|
||||||
|
const lines = wrapper.findAll('.line');
|
||||||
|
expect(lines).toHaveLength(4); // pretty-print 共 4 行({ + 2 字段 + })
|
||||||
|
// 首尾行是纯结构行(plain),必须渲染出来(丢括号 bug 的回归测试)
|
||||||
|
expect(lines[0]!.find('.line-content').text()).toBe('{');
|
||||||
|
expect(lines[3]!.find('.line-content').text()).toBe('}');
|
||||||
|
// key / boolean / number 着色正确,且整行内容与原文一致(textContent 保留前导缩进)
|
||||||
|
const codeLineText = lines[1]!.find('.line-content').element.textContent;
|
||||||
|
expect(codeLineText).toBe(' "code": true,');
|
||||||
|
expect(lines[1]!.find('.token.key').text()).toBe('"code":');
|
||||||
|
expect(lines[1]!.find('.token.boolean').text()).toBe('true');
|
||||||
|
expect(lines[2]!.find('.token.key').text()).toBe('"total_used":');
|
||||||
|
expect(lines[2]!.find('.token.number').text()).toBe('673419457');
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('两万行级 JSON:DOM 行数恒定(窗口 ± overscan,非总行数)', () => {
|
||||||
|
const text = makeJsonText(6000); // ~2.4 万行
|
||||||
|
const wrapper = mount(JsonTextView, {
|
||||||
|
props: { json: JSON.parse(text), rawText: text },
|
||||||
|
attachTo: document.body,
|
||||||
|
});
|
||||||
|
const renderedLineCount = wrapper.findAll('.line').length;
|
||||||
|
// 400px 视口 → 20 可视 + 2×5 缓冲 ≈ 30 行;绝不等于总行数
|
||||||
|
expect(renderedLineCount).toBeGreaterThan(0);
|
||||||
|
expect(renderedLineCount).toBeLessThan(100);
|
||||||
|
expect(text.split('\n').length).toBeGreaterThan(20000); // 前提成立
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非 JSON 响应:原文降级展示,行内容为原文本', () => {
|
||||||
|
const wrapper = mount(JsonTextView, {
|
||||||
|
props: { json: null, rawText: '<html>502 Bad Gateway</html>' },
|
||||||
|
attachTo: document.body,
|
||||||
|
});
|
||||||
|
const spacer = wrapper.find('.scroll-spacer');
|
||||||
|
expect(spacer.exists()).toBe(true);
|
||||||
|
expect(spacer.attributes('style')).toContain('height: 20px'); // 1 行 × 20px
|
||||||
|
expect(wrapper.findAll('.line-content').map((line) => line.text())).toEqual([
|
||||||
|
'<html>502 Bad Gateway</html>',
|
||||||
|
]);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* 虚拟滚动 JSON 文本视图(核心性能组件,对齐 DevTools Network Response 体验)
|
||||||
|
*
|
||||||
|
* 性能承诺(实施计划阶段五验收):/api/log/token 1000 条日志(2~4MB、约两万行)
|
||||||
|
* 渲染与全程滚动不掉帧;「收到响应 → 首屏渲染」小于 200ms。
|
||||||
|
*
|
||||||
|
* 实现要点:
|
||||||
|
* - DOM 恒定:只渲染可视窗口 ± overscan 的行(json-text-processor.computeVisibleRowWindow)
|
||||||
|
* - 懒高亮:仅对窗口内行做 token 切词(结构化 span),转义与着色在模板层,无 v-html
|
||||||
|
* - 滚动条:撑高度用总行数 × 行高的占位 div,窗口偏移用 translateY
|
||||||
|
*/
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useElementSize, useThrottleFn } from '@vueuse/core';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildJsonLineSegments,
|
||||||
|
computeGutterWidth,
|
||||||
|
computeVisibleRowWindow,
|
||||||
|
formatJsonOrRawText,
|
||||||
|
JSON_VIEW_ROW_HEIGHT_PX,
|
||||||
|
splitTextIntoLines,
|
||||||
|
} from '@/lib/json-text-processor';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 响应 JSON 值(JSON.parse 成功的结果;null 表示非 JSON 响应) */
|
||||||
|
json: unknown;
|
||||||
|
/** 响应体原文(非 JSON 响应降级展示用) */
|
||||||
|
rawText: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
/* ---------- 行数据(一次性派生,非响应式追踪内部) ---------- */
|
||||||
|
|
||||||
|
const formattedText = computed(() => formatJsonOrRawText(props.json, props.rawText));
|
||||||
|
const lineList = computed(() => splitTextIntoLines(formattedText.value));
|
||||||
|
|
||||||
|
/* ---------- 容器尺寸与滚动位置(VueUse) ---------- */
|
||||||
|
|
||||||
|
const scrollContainerRef = ref<HTMLElement | null>(null);
|
||||||
|
const { height: containerHeightPx } = useElementSize(scrollContainerRef);
|
||||||
|
|
||||||
|
const scrollTopPx = ref(0);
|
||||||
|
|
||||||
|
const handleScroll = useThrottleFn(() => {
|
||||||
|
if (scrollContainerRef.value) {
|
||||||
|
scrollTopPx.value = scrollContainerRef.value.scrollTop;
|
||||||
|
}
|
||||||
|
}, 33); // ~30fps 的窗口更新足够视觉流畅,重计算频率减半
|
||||||
|
|
||||||
|
/* ---------- 可视窗口(纯函数,阶段二已单测锁死) ---------- */
|
||||||
|
|
||||||
|
const visibleWindow = computed(() =>
|
||||||
|
computeVisibleRowWindow(
|
||||||
|
lineList.value.length,
|
||||||
|
scrollTopPx.value,
|
||||||
|
containerHeightPx.value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 窗口内行的渲染模型(行号 + 完整片段序列:token 与 plain 交替,括号不丢) */
|
||||||
|
interface RenderableLine {
|
||||||
|
lineNumber: number;
|
||||||
|
segments: ReturnType<typeof buildJsonLineSegments>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleLineList = computed<RenderableLine[]>(() => {
|
||||||
|
const lines: RenderableLine[] = [];
|
||||||
|
for (let index = visibleWindow.value.startIndex; index <= visibleWindow.value.endIndex; index += 1) {
|
||||||
|
const lineText = lineList.value[index];
|
||||||
|
if (lineText === undefined) continue;
|
||||||
|
lines.push({ lineNumber: index + 1, segments: buildJsonLineSegments(lineText) });
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalHeightPx = computed(() => lineList.value.length * JSON_VIEW_ROW_HEIGHT_PX);
|
||||||
|
const gutterWidth = computed(() => computeGutterWidth(lineList.value.length));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="scrollContainerRef" class="json-text-view" @scroll.passive="handleScroll">
|
||||||
|
<div class="scroll-spacer" :style="{ height: `${totalHeightPx}px` }">
|
||||||
|
<div class="visible-window" :style="{ transform: `translateY(${visibleWindow.offsetYPx}px)` }">
|
||||||
|
<div v-for="line in visibleLineList" :key="line.lineNumber" class="line">
|
||||||
|
<span class="gutter" :style="{ width: `${gutterWidth}ch` }">{{ line.lineNumber }}</span>
|
||||||
|
<span class="line-content">
|
||||||
|
<template v-for="(segment, segmentIndex) in line.segments" :key="segmentIndex">
|
||||||
|
<span v-if="segment.kind !== 'plain'" class="token" :class="segment.kind">{{
|
||||||
|
segment.text
|
||||||
|
}}</span>
|
||||||
|
<template v-else>{{ segment.text }}</template>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.json-text-view {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--td-bg-color-secondarycontainer);
|
||||||
|
border-radius: 6px;
|
||||||
|
font: 12.5px/20px var(--td-font-family-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 行高 20px 与 JSON_VIEW_ROW_HEIGHT_PX 常量对齐(改一处必须同步另一处) */
|
||||||
|
.line {
|
||||||
|
height: 20px;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gutter {
|
||||||
|
display: inline-block;
|
||||||
|
padding-right: 12px;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-content {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.key {
|
||||||
|
color: var(--td-brand-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.string {
|
||||||
|
color: var(--td-success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.number {
|
||||||
|
color: var(--td-warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token.boolean,
|
||||||
|
.token.null {
|
||||||
|
color: var(--td-error-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user