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 存在 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);
|
||||
|
||||
@@ -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 容器: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>
|
||||
<Tabs
|
||||
:value="activeTab ?? undefined"
|
||||
:list="tabPanelList"
|
||||
theme="card"
|
||||
class="request-tabs"
|
||||
:drag-sort="true"
|
||||
@change="onTabChange"
|
||||
@remove="onTabRemove"
|
||||
@drag-sort="onTabDragSort"
|
||||
/>
|
||||
<div class="request-tabs">
|
||||
<Tabs
|
||||
:value="activeTab ?? undefined"
|
||||
:list="tabPanelList"
|
||||
theme="card"
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user