feat(layout): tabs 纯导航 + 面板外置,响应区固定分区滚动

参照 t-design-starter 与 Naive UI display-directive=show 语义:t-tabs 只当导航条(list 不传 panel),EndpointPanel 渲染在外置自有容器,v-show 常驻切换(面板不销毁,sk-key/滚动位置/局部状态全保留);删除两条 :deep 穿透,仅留一条收敛规则隐藏空 panel 壳;响应区 overflow:hidden + min-height 220px 固定分区,滚动收敛到 JsonTextView 自身;新增 request-tabs 4 用例锁死显隐/不销毁语义。
This commit is contained in:
2026-09-08 10:48:22 +08:00
parent c021294322
commit d4fe766373
3 changed files with 182 additions and 49 deletions
+18 -14
View File
@@ -1,11 +1,7 @@
/**
* 单接口完整面板:信息行 → 注意事项折叠 → sk-key 输入 + 发送按钮 → 响应区
*
* sk-key 存在 bench-store(每 tab 独立、无持久化、随刷新重置):
* 2026-09 起从组件局部 ref 提升——EndpointPanel 随 tab 切换卸载重建,
* 局部 ref 会在切回时丢输入;store 持有则切 tab 保留(数据仍由 Query 缓存兜底)
* 响应区:元信息行(状态/耗时/大小)+ 操作(复制/下载)+ 虚拟滚动 JSON 视图
*/
/** * 单接口完整面板:信息行 → 注意事项折叠 → sk-key 输入 + 发送按钮 → 响应区 * * sk-key 存在
bench-store(每 tab 独立、无持久化、随刷新重置): * 2026-09 起从组件局部 ref 提升——EndpointPanel 随
tab 切换卸载重建, * 局部 ref 会在切回时丢输入;store 持有则切 tab 保留(数据仍由 Query 缓存兜底) *
响应区:元信息行(状态/耗时/大小)+ 操作(复制/下载)+ 虚拟滚动 JSON 视图 */
<script setup lang="ts">
import { useClipboard } from '@vueuse/core';
@@ -37,10 +33,12 @@ const skKeyInput = computed({
/* ---------- 手动请求(TanStack Query) ---------- */
const endpointPathRef = computed(() => props.endpointPath);
const { data: responseData, isFetching, hasSent, sendRequest } = useManualEndpointQuery(
endpointPathRef,
skKeyInput,
);
const {
data: responseData,
isFetching,
hasSent,
sendRequest,
} = useManualEndpointQuery(endpointPathRef, skKeyInput);
const keyInputStatus = computed(() =>
hasSent.value && !skKeyInput.value.trim() ? 'error' : 'default',
@@ -53,7 +51,11 @@ function handleSend(): void {
/* ---------- 响应区操作:复制 / 下载 ---------- */
const { copy: copyResponseToClipboard, copied, isSupported: clipboardSupported } = useClipboard({
const {
copy: copyResponseToClipboard,
copied,
isSupported: clipboardSupported,
} = useClipboard({
source: () => responseData.value?.text ?? '',
legacy: true, // 非 https 环境降级 execCommand
});
@@ -182,9 +184,10 @@ function downloadResponseAsFile(): void {
.response-section {
flex: 1;
min-height: 0;
min-height: 220px; /* 响应区保底高度:窄窗口下不被上部固定分区压缩到不可用 */
display: flex;
flex-direction: column;
overflow: hidden; /* 分区固定:滚动收敛到 JsonTextView 自身 */
}
.response-meta {
@@ -192,6 +195,7 @@ function downloadResponseAsFile(): void {
gap: 14px;
align-items: center;
margin-bottom: 10px;
flex-shrink: 0; /* 元信息行固定:不随响应区压缩 */
font-size: 12.5px;
color: var(--td-text-color-secondary);
font-family: var(--td-font-family-mono, monospace);
+115
View File
@@ -0,0 +1,115 @@
// @vitest-environment jsdom
/**
* request-tabs 组件测试:纯导航模式(面板外置)+ 面板显隐/状态保留
*
* 布局改造(2026-09):t-tabs 只当导航条,EndpointPanel 渲染在外置自有容器,
* v-show 切换(display-directive="show" 语义)——面板切走不销毁;
* 滚动容器收敛到 JsonTextView,不再穿透 .t-tabs__content/.t-tab-panel 布局
*/
import { mount } from '@vue/test-utils';
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';
import { createPinia, setActivePinia } from 'pinia';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { useBenchStore } from '@/stores/bench-store';
import EndpointPanel from './endpoint-panel.vue';
import RequestTabs from './request-tabs.vue';
let pinia: ReturnType<typeof createPinia>;
let queryClient: QueryClient;
beforeEach(() => {
pinia = createPinia();
setActivePinia(pinia);
// EndpointPanel 内部用 vue-query(仅挂载,不发请求),测试环境需提供 QueryClient
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, enabled: false } },
});
});
afterEach(() => {
document.body.innerHTML = '';
});
function mountTabs() {
return mount(RequestTabs, {
global: { plugins: [pinia, [VueQueryPlugin, { queryClient }]] },
attachTo: document.body,
});
}
describe('request-tabs 纯导航模式', () => {
it('打开两个 tab:导航渲染两个标签,两个面板均挂载(v-show 常驻,不销毁)', async () => {
const store = useBenchStore();
store.openOrActivateTab('/v1/models');
store.openOrActivateTab('/api/usage/token');
const wrapper = mountTabs();
await wrapper.vm.$nextTick();
// 导航标签数 = 打开 tab 数
const navItems = wrapper.findAll('.t-tabs__nav-item');
expect(navItems.length).toBe(2);
// 外置面板容器:两个 tab 的面板都存在(v-show 常驻)
const panels = wrapper.findAll('.request-tabs__panel');
expect(panels.length).toBe(2);
// 每个 panel 都渲染了 EndpointPanel(不销毁,display:none 隐藏)
expect(panels[0]!.find('.endpoint-panel').exists()).toBe(true);
expect(panels[1]!.find('.endpoint-panel').exists()).toBe(true);
// 激活的是第二个 tab(openOrActivateTab 最后一次调用):panel[1] 可见,panel[0] display:none
expect(panels[1]!.attributes('style')).toBeUndefined();
expect(panels[0]!.attributes('style')).toContain('display: none');
wrapper.unmount();
});
it('切换激活 tab:面板显隐跟随 store,面板不重新创建(状态保留语义)', async () => {
const store = useBenchStore();
store.openOrActivateTab('/v1/models');
store.openOrActivateTab('/api/usage/token');
const wrapper = mountTabs();
await wrapper.vm.$nextTick();
const panels = () => wrapper.findAll('.request-tabs__panel');
const firstPanelEl = panels()[0]!.element as HTMLElement;
store.activateTab('/api/usage/token');
await wrapper.vm.$nextTick();
// v-show 翻转:同一 DOM 元素仍在(未销毁重建)
expect(panels()[0]!.element).toBe(firstPanelEl);
expect(panels()[0]!.attributes('style')).toContain('display');
expect(panels()[1]!.attributes('style')).toBeUndefined();
wrapper.unmount();
});
it('关闭激活 tab:对应面板从 DOM 移除,其余面板保留', async () => {
const store = useBenchStore();
store.openOrActivateTab('/v1/models');
store.openOrActivateTab('/api/usage/token');
const wrapper = mountTabs();
await wrapper.vm.$nextTick();
store.closeTab('/v1/models');
await wrapper.vm.$nextTick();
const panels = wrapper.findAll('.request-tabs__panel');
expect(panels.length).toBe(1);
// EndpointPanel 的 props 在 DOM 上是 kebab-case attribute:注意 vue-test-utils 会加 data-v 前缀,直接读组件实例更稳
const remainingEndpointPath = panels[0]!.findComponent(EndpointPanel).props('endpointPath');
expect(remainingEndpointPath).toBe('/api/usage/token');
wrapper.unmount();
});
it('全部关闭后不渲染任何面板', async () => {
const store = useBenchStore();
store.openOrActivateTab('/v1/models');
const wrapper = mountTabs();
await wrapper.vm.$nextTick();
store.closeTab('/v1/models');
await wrapper.vm.$nextTick();
expect(wrapper.findAll('.request-tabs__panel')).toHaveLength(0);
expect(wrapper.findAll('.t-tabs__nav-item')).toHaveLength(0);
wrapper.unmount();
});
});
+40 -26
View File
@@ -1,20 +1,16 @@
/**
* 右侧 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 变化),缓存天然隔离
*/
/** * 右侧 tabs 容器:仿 Naive UI「纯导航模式」(TDesign 无官方等价 API,参照 * t-design-starter
的实现——t-tabs 只当导航条,面板内容渲染在组件外侧自有容器) * * 为什么不用 t-tabs 的 panel
渲染面板: * - 面板显隐由 .t-tab-panel.t-is-hidden(库内部)管理,做「固定分区 + JSON 区独立滚动」 *
必须穿透 .t-tabs__content / .t-tab-panel 的内部布局,耦合库实现 * - 外置后布局全在自己的
DOM:display-directive 语义(show:v-show 常驻保留状态) *
由本组件手工实现,面板切走不销毁,sk-key/滚动位置/Query 缓存三重保留 * * list
仍数据驱动(label/removable/value),change/remove/drag-sort 事件语义不变; * t-tabs 空 panel
残壳用一条收敛规则隐藏(见样式区注释)。 */
<script setup lang="ts">
import { Tabs, type TabPanelProps, type TabValue } from 'tdesign-vue-next';
import { storeToRefs } from 'pinia';
import { computed, h } from 'vue';
import { computed } from 'vue';
import { findEndpointByPath } from '@/api/endpoints';
import EndpointPanel from '@/components/endpoint-panel.vue';
@@ -23,20 +19,20 @@ import { useBenchStore } from '@/stores/bench-store';
const benchStore = useBenchStore();
const { openTabs, activeTab } = storeToRefs(benchStore);
/** list 数据驱动:openTabs 一变,nav 与 panel 立即跟随(受控语义干净) */
/** list 数据驱动:openTabs 一变,nav 立即跟随;不再传 panel(内容外置渲染) */
const tabPanelList = computed<TabPanelProps[]>(() =>
openTabs.value.map((tabPath) => ({
label: tabPath,
value: tabPath,
removable: true,
panel: () =>
// 只给激活 tab 挂内容;非激活 panel 渲染空(显隐由 t-tabs 管理)
tabPath === activeTab.value && findEndpointByPath(tabPath)
? h(EndpointPanel, { key: tabPath, endpointPath: tabPath })
: null,
})),
);
/** 已打开且确实存在的 tab(防御 openTabs 与静态接口表短暂不一致) */
const renderableTabs = computed(() =>
openTabs.value.filter((tabPath) => findEndpointByPath(tabPath) !== undefined),
);
function onTabChange(tabValue: TabValue): void {
if (typeof tabValue === 'string') {
benchStore.activateTab(tabValue);
@@ -62,16 +58,27 @@ function onTabDragSort(context: {
</script>
<template>
<div class="request-tabs">
<Tabs
:value="activeTab ?? undefined"
:list="tabPanelList"
theme="card"
class="request-tabs"
class="request-tabs__nav"
:drag-sort="true"
@change="onTabChange"
@remove="onTabRemove"
@drag-sort="onTabDragSort"
/>
<!-- 面板区(自有 DOM):v-show 常驻切换,等价 display-directive="show" -->
<div
v-for="tabPath in renderableTabs"
:key="tabPath"
v-show="tabPath === activeTab"
class="request-tabs__panel"
>
<EndpointPanel :endpoint-path="tabPath" />
</div>
</div>
</template>
<style scoped>
@@ -82,17 +89,24 @@ function onTabDragSort(context: {
flex-direction: column;
}
/* 内容区撑满剩余高度;不覆盖 .t-tab-panel 的 display(TDesign 靠它管理显隐) */
.request-tabs :deep(.t-tabs__content) {
.request-tabs__nav {
flex-shrink: 0;
}
.request-tabs__panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.request-tabs :deep(.t-tab-panel) {
flex: 1;
min-height: 0;
overflow-y: auto;
/*
* 收敛 t-tabs 空 panel 残壳:panel 内容外置后,t-tabs__content 仍会渲染
* 空的 .t-tab-panel(库内部由 activeTab 驱动显隐),高约 0 但需防其撑开布局。
* 仅此一条触达库内部结构;依赖类名与版本记录于此(tdesign-vue-next ^1.20.7)。
*/
.request-tabs__nav :deep(.t-tabs__content) {
display: none;
}
</style>