Archived
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:
@@ -1,11 +1,7 @@
|
|||||||
/**
|
/** * 单接口完整面板:信息行 → 注意事项折叠 → sk-key 输入 + 发送按钮 → 响应区 * * sk-key 存在
|
||||||
* 单接口完整面板:信息行 → 注意事项折叠 → sk-key 输入 + 发送按钮 → 响应区
|
bench-store(每 tab 独立、无持久化、随刷新重置): * 2026-09 起从组件局部 ref 提升——EndpointPanel 随
|
||||||
*
|
tab 切换卸载重建, * 局部 ref 会在切回时丢输入;store 持有则切 tab 保留(数据仍由 Query 缓存兜底) *
|
||||||
* sk-key 存在 bench-store(每 tab 独立、无持久化、随刷新重置):
|
响应区:元信息行(状态/耗时/大小)+ 操作(复制/下载)+ 虚拟滚动 JSON 视图 */
|
||||||
* 2026-09 起从组件局部 ref 提升——EndpointPanel 随 tab 切换卸载重建,
|
|
||||||
* 局部 ref 会在切回时丢输入;store 持有则切 tab 保留(数据仍由 Query 缓存兜底)
|
|
||||||
* 响应区:元信息行(状态/耗时/大小)+ 操作(复制/下载)+ 虚拟滚动 JSON 视图
|
|
||||||
*/
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useClipboard } from '@vueuse/core';
|
import { useClipboard } from '@vueuse/core';
|
||||||
@@ -37,10 +33,12 @@ const skKeyInput = computed({
|
|||||||
/* ---------- 手动请求(TanStack Query) ---------- */
|
/* ---------- 手动请求(TanStack Query) ---------- */
|
||||||
|
|
||||||
const endpointPathRef = computed(() => props.endpointPath);
|
const endpointPathRef = computed(() => props.endpointPath);
|
||||||
const { data: responseData, isFetching, hasSent, sendRequest } = useManualEndpointQuery(
|
const {
|
||||||
endpointPathRef,
|
data: responseData,
|
||||||
skKeyInput,
|
isFetching,
|
||||||
);
|
hasSent,
|
||||||
|
sendRequest,
|
||||||
|
} = useManualEndpointQuery(endpointPathRef, skKeyInput);
|
||||||
|
|
||||||
const keyInputStatus = computed(() =>
|
const keyInputStatus = computed(() =>
|
||||||
hasSent.value && !skKeyInput.value.trim() ? 'error' : 'default',
|
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 ?? '',
|
source: () => responseData.value?.text ?? '',
|
||||||
legacy: true, // 非 https 环境降级 execCommand
|
legacy: true, // 非 https 环境降级 execCommand
|
||||||
});
|
});
|
||||||
@@ -182,9 +184,10 @@ function downloadResponseAsFile(): void {
|
|||||||
|
|
||||||
.response-section {
|
.response-section {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 220px; /* 响应区保底高度:窄窗口下不被上部固定分区压缩到不可用 */
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden; /* 分区固定:滚动收敛到 JsonTextView 自身 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.response-meta {
|
.response-meta {
|
||||||
@@ -192,6 +195,7 @@ function downloadResponseAsFile(): void {
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
flex-shrink: 0; /* 元信息行固定:不随响应区压缩 */
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
color: var(--td-text-color-secondary);
|
color: var(--td-text-color-secondary);
|
||||||
font-family: var(--td-font-family-mono, monospace);
|
font-family: var(--td-font-family-mono, monospace);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,20 +1,16 @@
|
|||||||
/**
|
/** * 右侧 tabs 容器:仿 Naive UI「纯导航模式」(TDesign 无官方等价 API,参照 * t-design-starter
|
||||||
* 右侧 tabs 容器:t-tabs 渲染已开 tab(标签即接口路径,可关闭、可拖拽)
|
的实现——t-tabs 只当导航条,面板内容渲染在组件外侧自有容器) * * 为什么不用 t-tabs 的 panel
|
||||||
*
|
渲染面板: * - 面板显隐由 .t-tab-panel.t-is-hidden(库内部)管理,做「固定分区 + JSON 区独立滚动」 *
|
||||||
* 渲染策略(2026-09-07 二次修复):改用 list prop 数据驱动(官方文档方式二),
|
必须穿透 .t-tabs__content / .t-tab-panel 的内部布局,耦合库实现 * - 外置后布局全在自己的
|
||||||
* 弃用 t-tab-panel 插槽写法——插槽 vnode 经 getSlotPanels() 解析过滤的链路
|
DOM:display-directive 语义(show:v-show 常驻保留状态) *
|
||||||
* 在受控 value + 动态增删场景下与 openTabs 脱钩,导致关闭 tab 后 nav 不更新。
|
由本组件手工实现,面板切走不销毁,sk-key/滚动位置/Query 缓存三重保留 * * list
|
||||||
*
|
仍数据驱动(label/removable/value),change/remove/drag-sort 事件语义不变; * t-tabs 空 panel
|
||||||
* 各 tab 状态保留:
|
残壳用一条收敛规则隐藏(见样式区注释)。 */
|
||||||
* - 响应数据:TanStack Query 缓存(queryKey 含 path + skKey),切走再切回原样恢复
|
|
||||||
* - sk-key:bench-store 持有(每 tab 独立),2026-09 前是面板局部 ref(随卸载丢失、需重贴)
|
|
||||||
* - 换 key 即新查询(queryKey 变化),缓存天然隔离
|
|
||||||
*/
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Tabs, type TabPanelProps, type TabValue } from 'tdesign-vue-next';
|
import { Tabs, type TabPanelProps, type TabValue } from 'tdesign-vue-next';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { computed, h } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
import { findEndpointByPath } from '@/api/endpoints';
|
import { findEndpointByPath } from '@/api/endpoints';
|
||||||
import EndpointPanel from '@/components/endpoint-panel.vue';
|
import EndpointPanel from '@/components/endpoint-panel.vue';
|
||||||
@@ -23,20 +19,20 @@ import { useBenchStore } from '@/stores/bench-store';
|
|||||||
const benchStore = useBenchStore();
|
const benchStore = useBenchStore();
|
||||||
const { openTabs, activeTab } = storeToRefs(benchStore);
|
const { openTabs, activeTab } = storeToRefs(benchStore);
|
||||||
|
|
||||||
/** list 数据驱动:openTabs 一变,nav 与 panel 立即跟随(受控语义干净) */
|
/** list 数据驱动:openTabs 一变,nav 立即跟随;不再传 panel(内容外置渲染) */
|
||||||
const tabPanelList = computed<TabPanelProps[]>(() =>
|
const tabPanelList = computed<TabPanelProps[]>(() =>
|
||||||
openTabs.value.map((tabPath) => ({
|
openTabs.value.map((tabPath) => ({
|
||||||
label: tabPath,
|
label: tabPath,
|
||||||
value: tabPath,
|
value: tabPath,
|
||||||
removable: true,
|
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 {
|
function onTabChange(tabValue: TabValue): void {
|
||||||
if (typeof tabValue === 'string') {
|
if (typeof tabValue === 'string') {
|
||||||
benchStore.activateTab(tabValue);
|
benchStore.activateTab(tabValue);
|
||||||
@@ -62,16 +58,27 @@ function onTabDragSort(context: {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Tabs
|
<div class="request-tabs">
|
||||||
:value="activeTab ?? undefined"
|
<Tabs
|
||||||
:list="tabPanelList"
|
:value="activeTab ?? undefined"
|
||||||
theme="card"
|
:list="tabPanelList"
|
||||||
class="request-tabs"
|
theme="card"
|
||||||
:drag-sort="true"
|
class="request-tabs__nav"
|
||||||
@change="onTabChange"
|
:drag-sort="true"
|
||||||
@remove="onTabRemove"
|
@change="onTabChange"
|
||||||
@drag-sort="onTabDragSort"
|
@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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -82,17 +89,24 @@ function onTabDragSort(context: {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 内容区撑满剩余高度;不覆盖 .t-tab-panel 的 display(TDesign 靠它管理显隐) */
|
.request-tabs__nav {
|
||||||
.request-tabs :deep(.t-tabs__content) {
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-tabs__panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-tabs :deep(.t-tab-panel) {
|
/*
|
||||||
flex: 1;
|
* 收敛 t-tabs 空 panel 残壳:panel 内容外置后,t-tabs__content 仍会渲染
|
||||||
min-height: 0;
|
* 空的 .t-tab-panel(库内部由 activeTab 驱动显隐),高约 0 但需防其撑开布局。
|
||||||
overflow-y: auto;
|
* 仅此一条触达库内部结构;依赖类名与版本记录于此(tdesign-vue-next ^1.20.7)。
|
||||||
|
*/
|
||||||
|
.request-tabs__nav :deep(.t-tabs__content) {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user