From 60ba5b63068023182ef31725862185a8829c867f Mon Sep 17 00:00:00 2001 From: skycurtain Date: Tue, 8 Sep 2026 00:49:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20UI=20=E9=AA=A8=E6=9E=B6=E4=B8=8E?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E9=9B=86=E6=88=90=EF=BC=88sider=E3=80=81tabs?= =?UTF-8?q?=E3=80=81=E6=8E=A5=E5=8F=A3=E9=9D=A2=E6=9D=BF=20sk-key=20?= =?UTF-8?q?=E8=B5=B0=20store=E3=80=81=E6=89=8B=E5=8A=A8=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit t-tabs 用官方 list prop 数据驱动(受控 value + 动态增删语义干净);请求纯手动触发,enabled 门控 + refetch,staleTime Infinity 切 tab 不重发,queryKey ['endpoint', path, skKey] 换 key 即新查询;endpoint-panel 的 sk-key computed 读写 store,切 tab 输入保留。 --- src/App.vue | 9 + src/api/endpoint-queries.spec.ts | 57 ++++++ src/api/endpoint-queries.ts | 66 +++++++ src/app-shell.vue | 59 +++++++ src/components/endpoint-info-line.vue | 72 ++++++++ src/components/endpoint-panel.vue | 243 ++++++++++++++++++++++++++ src/components/request-tabs.vue | 98 +++++++++++ src/components/side-nav.vue | 88 ++++++++++ src/main.ts | 33 ++++ src/style.css | 21 +++ 10 files changed, 746 insertions(+) create mode 100644 src/App.vue create mode 100644 src/api/endpoint-queries.spec.ts create mode 100644 src/api/endpoint-queries.ts create mode 100644 src/app-shell.vue create mode 100644 src/components/endpoint-info-line.vue create mode 100644 src/components/endpoint-panel.vue create mode 100644 src/components/request-tabs.vue create mode 100644 src/components/side-nav.vue create mode 100644 src/main.ts create mode 100644 src/style.css diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..6e07923 --- /dev/null +++ b/src/App.vue @@ -0,0 +1,9 @@ + + + + + diff --git a/src/api/endpoint-queries.spec.ts b/src/api/endpoint-queries.spec.ts new file mode 100644 index 0000000..cf90e1d --- /dev/null +++ b/src/api/endpoint-queries.spec.ts @@ -0,0 +1,57 @@ +// vitest.config.ts 的 environment 是 node;这里需要 DOM 挂载 Vue 应用 +// @vitest-environment jsdom +/** + * 集成冒烟:手动触发语义的端到端验证(fetch 打桩) + * 重点验证:sendRequest 前 data 为空、发送后拿到数据 + */ +import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'; +import { describe, expect, it } from 'vitest'; +import { defineComponent, h, ref } from 'vue'; +import { createApp } from 'vue'; +import { createPinia, setActivePinia } from 'pinia'; + +import { useManualEndpointQuery } from './endpoint-queries'; + +describe('useManualEndpointQuery(集成冒烟)', () => { + it('未发送时不发请求(enabled 门控),发送后拿到数据', async () => { + setActivePinia(createPinia()); + let fetchCallCount = 0; + globalThis.fetch = async () => { + fetchCallCount += 1; + return new Response('{"ok":true}', { status: 200 }); + }; + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + let queryResult: ReturnType | null = null; + const app = createApp( + defineComponent({ + setup() { + queryResult = useManualEndpointQuery(ref('/v1/models'), ref('sk-test')); + return () => h('div'); + }, + }), + ); + app.use(createPinia()); + app.use(VueQueryPlugin, { queryClient }); + app.mount(document.createElement('div')); + + // 未发送:门控生效,不 fetch + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(fetchCallCount).toBe(0); + expect(queryResult!.hasSent.value).toBe(false); + + // 发送:真实 fetch + 数据落地 + queryResult!.sendRequest(); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(fetchCallCount).toBe(1); + expect(queryResult!.hasSent.value).toBe(true); + expect(queryResult!.data.value?.json).toEqual({ ok: true }); + expect(queryResult!.data.value?.status).toBe(200); + + app.unmount(); + }); +}); + diff --git a/src/api/endpoint-queries.ts b/src/api/endpoint-queries.ts new file mode 100644 index 0000000..8ac5226 --- /dev/null +++ b/src/api/endpoint-queries.ts @@ -0,0 +1,66 @@ +/** + * TanStack Query 接入:手动触发的 endpoint 请求 + * + * 语义(设计文档 §3.3 定案): + * - queryKey = [endpointPath, skKey]——同 tab 换 key 是新查询,不同 tab(不同 key)缓存天然隔离 + * - staleTime: Infinity——调试工具的响应不该自己变,切走再切回不自动重发 + * - 手动触发:sendRequest() 置位 enabled,首次发送后才拉数据;再次点击强制 refetch + */ + +import { useQuery, type UseQueryReturnType } from '@tanstack/vue-query'; +import { readonly, ref, type Ref } from 'vue'; + +import { requestGatewayEndpoint, type GatewayResponse } from './gateway-client'; + +/** 手动请求的返回契约 */ +export interface ManualEndpointQuery { + /** 响应数据(GatewayResponse,含 status/ms/bytes/text/json/networkError) */ + data: Readonly>; + /** 请求进行中 */ + isFetching: Readonly>; + /** 是否已发送过至少一次(区分「未发送」与「发送中」的空态) */ + hasSent: Readonly>; + /** 触发请求(key 为空时由调用方负责拦截,本层不校验) */ + sendRequest: () => void; +} + +/** + * 手动触发的 endpoint 查询。 + * endpointPath 与 skKey 均为响应式 Ref:换接口 / 换 key 时 queryKey 随之变化(新缓存槽)。 + */ +export function useManualEndpointQuery( + endpointPath: Ref, + skKey: Ref, +): ManualEndpointQuery { + const requestEnabled = ref(false); + const hasSent = ref(false); + + const query = useQuery({ + queryKey: ['endpoint', endpointPath, skKey], + queryFn: () => requestGatewayEndpoint(endpointPath.value, skKey.value), + enabled: requestEnabled, + staleTime: Infinity, + gcTime: Infinity, + retry: false, // 调试工具要看到真实的失败,不重试 + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + }); + + function sendRequest(): void { + hasSent.value = true; + requestEnabled.value = true; + // 已有缓存(如换回旧 key)时强制重新请求——「发送」按钮的语义就是真的发一次 + void query.refetch(); + } + + return { + data: query.data, + isFetching: query.isFetching, + hasSent: readonly(hasSent), + sendRequest, + }; +} + +/** 具名导出便于类型引用(避免调用方直接依赖 tanstack 内部类型) */ +export type { UseQueryReturnType }; diff --git a/src/app-shell.vue b/src/app-shell.vue new file mode 100644 index 0000000..67c3003 --- /dev/null +++ b/src/app-shell.vue @@ -0,0 +1,59 @@ +/** + * 应用外壳:双栏布局(左 sider 接口列表 + 右 tabs 详情区) + * 暗色默认(设计文档 §2 定案);组件拆分见实施计划阶段四 + */ + + + + + + diff --git a/src/components/endpoint-info-line.vue b/src/components/endpoint-info-line.vue new file mode 100644 index 0000000..fb9230e --- /dev/null +++ b/src/components/endpoint-info-line.vue @@ -0,0 +1,72 @@ +/** + * 接口信息行:method 徽标 + 路径 + 鉴权徽标(宽松/严格) + * 阶段五会在此扩展可折叠的注意事项 + */ + + + + + + diff --git a/src/components/endpoint-panel.vue b/src/components/endpoint-panel.vue new file mode 100644 index 0000000..883eeb6 --- /dev/null +++ b/src/components/endpoint-panel.vue @@ -0,0 +1,243 @@ +/** + * 单接口完整面板:信息行 → 注意事项折叠 → sk-key 输入 + 发送按钮 → 响应区 + * + * sk-key 存在 bench-store(每 tab 独立、无持久化、随刷新重置): + * 2026-09 起从组件局部 ref 提升——EndpointPanel 随 tab 切换卸载重建, + * 局部 ref 会在切回时丢输入;store 持有则切 tab 保留(数据仍由 Query 缓存兜底) + * 响应区:元信息行(状态/耗时/大小)+ 操作(复制/下载)+ 虚拟滚动 JSON 视图 + */ + + + + + + diff --git a/src/components/request-tabs.vue b/src/components/request-tabs.vue new file mode 100644 index 0000000..7017614 --- /dev/null +++ b/src/components/request-tabs.vue @@ -0,0 +1,98 @@ +/** + * 右侧 tabs 容器:t-tabs 渲染已开 tab(标签即接口路径,可关闭、可拖拽) + * + * 渲染策略(2026-09-07 二次修复):改用 list prop 数据驱动(官方文档方式二), + * 弃用 t-tab-panel 插槽写法——插槽 vnode 经 getSlotPanels() 解析过滤的链路 + * 在受控 value + 动态增删场景下与 openTabs 脱钩,导致关闭 tab 后 nav 不更新。 + * + * 各 tab 状态保留: + * - 响应数据:TanStack Query 缓存(queryKey 含 path + skKey),切走再切回原样恢复 + * - sk-key:bench-store 持有(每 tab 独立),2026-09 前是面板局部 ref(随卸载丢失、需重贴) + * - 换 key 即新查询(queryKey 变化),缓存天然隔离 + */ + + + + + + diff --git a/src/components/side-nav.vue b/src/components/side-nav.vue new file mode 100644 index 0000000..95e42a2 --- /dev/null +++ b/src/components/side-nav.vue @@ -0,0 +1,88 @@ +/** + * 左侧接口导航:5 个路径平铺(不分组不编号,按 endpoints.ts 定案顺序) + * 点击 → openOrActivateTab(已开则激活,未开则新开) + */ + + + + + + diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..8e9c121 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,33 @@ +import { createApp } from 'vue'; +import { VueQueryPlugin } from '@tanstack/vue-query'; +import { createPinia } from 'pinia'; + +import TDesign from 'tdesign-vue-next'; +import 'tdesign-vue-next/es/style/index.css'; + +import App from './App.vue'; +import './style.css'; + +const app = createApp(App); + +app.use(createPinia()); + +// TanStack Query:调试工具语义——不自动重取、失败不重试(详见 endpoint-queries.ts) +app.use(VueQueryPlugin, { + queryClientConfig: { + defaultOptions: { + queries: { + staleTime: Infinity, + gcTime: Infinity, + retry: false, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + }, + }, + }, +}); + +app.use(TDesign); + +app.mount('#app'); diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..1c6b226 --- /dev/null +++ b/src/style.css @@ -0,0 +1,21 @@ +/* bench 全局样式:暗色默认(设计文档 §2 定案)+ html/body 满高 + TDesign 暗色变量挂在 :root[theme-mode='dark'],在 html 上声明即全局生效 */ + +:root { + color-scheme: dark; + theme-mode: dark; +} + +html, +body, +#app { + height: 100%; + margin: 0; +} + +body { + background: var(--td-bg-color-page); + color: var(--td-text-color-primary); + font-family: var(--td-font-family); + -webkit-font-smoothing: antialiased; +}