Archived
feat(ui): UI 骨架与请求集成(sider、tabs、接口面板 sk-key 走 store、手动查询)
t-tabs 用官方 list prop 数据驱动(受控 value + 动态增删语义干净);请求纯手动触发,enabled 门控 + refetch,staleTime Infinity 切 tab 不重发,queryKey ['endpoint', path, skKey] 换 key 即新查询;endpoint-panel 的 sk-key computed 读写 store,切 tab 输入保留。
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import AppShell from './app-shell.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppShell />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -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<typeof useManualEndpointQuery> | 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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<Ref<GatewayResponse | undefined>>;
|
||||||
|
/** 请求进行中 */
|
||||||
|
isFetching: Readonly<Ref<boolean>>;
|
||||||
|
/** 是否已发送过至少一次(区分「未发送」与「发送中」的空态) */
|
||||||
|
hasSent: Readonly<Ref<boolean>>;
|
||||||
|
/** 触发请求(key 为空时由调用方负责拦截,本层不校验) */
|
||||||
|
sendRequest: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动触发的 endpoint 查询。
|
||||||
|
* endpointPath 与 skKey 均为响应式 Ref:换接口 / 换 key 时 queryKey 随之变化(新缓存槽)。
|
||||||
|
*/
|
||||||
|
export function useManualEndpointQuery(
|
||||||
|
endpointPath: Ref<string>,
|
||||||
|
skKey: Ref<string>,
|
||||||
|
): ManualEndpointQuery {
|
||||||
|
const requestEnabled = ref(false);
|
||||||
|
const hasSent = ref(false);
|
||||||
|
|
||||||
|
const query = useQuery<GatewayResponse>({
|
||||||
|
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 };
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* 应用外壳:双栏布局(左 sider 接口列表 + 右 tabs 详情区)
|
||||||
|
* 暗色默认(设计文档 §2 定案);组件拆分见实施计划阶段四
|
||||||
|
*/
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
|
import RequestTabs from '@/components/request-tabs.vue';
|
||||||
|
import SideNav from '@/components/side-nav.vue';
|
||||||
|
import { useBenchStore } from '@/stores/bench-store';
|
||||||
|
|
||||||
|
const benchStore = useBenchStore();
|
||||||
|
const { activeTab } = storeToRefs(benchStore);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-shell">
|
||||||
|
<SideNav />
|
||||||
|
<main class="main-area">
|
||||||
|
<RequestTabs v-if="activeTab" />
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<p>从左侧选择一个接口开始调试</p>
|
||||||
|
<p class="empty-hint">sk-key 仅存于当前页面会话,刷新即重置</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-shell {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-area {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* 接口信息行:method 徽标 + 路径 + 鉴权徽标(宽松/严格)
|
||||||
|
* 阶段五会在此扩展可折叠的注意事项
|
||||||
|
*/
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { EndpointDefinition } from '@/api/endpoints';
|
||||||
|
|
||||||
|
const props = defineProps<{ endpoint: EndpointDefinition }>();
|
||||||
|
|
||||||
|
const authBadgeText = props.endpoint.authMode === 'lenient' ? '宽松' : '严格';
|
||||||
|
const authBadgeTitle = `${props.endpoint.authMiddlewareName}(${
|
||||||
|
props.endpoint.authMode === 'lenient'
|
||||||
|
? 'key 过期/禁用/额度耗尽仍可查询'
|
||||||
|
: 'key 无效/过期/额度耗尽会被拒绝'
|
||||||
|
})`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="endpoint-info-line">
|
||||||
|
<span class="method-badge">{{ endpoint.method }}</span>
|
||||||
|
<code class="endpoint-path">{{ endpoint.path }}</code>
|
||||||
|
<span class="auth-badge" :class="endpoint.authMode" :title="authBadgeTitle">
|
||||||
|
{{ authBadgeText }}
|
||||||
|
</span>
|
||||||
|
<span class="response-summary">{{ endpoint.responseSummary }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.endpoint-info-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-badge {
|
||||||
|
font: 600 11px/1 var(--td-font-family-mono, monospace);
|
||||||
|
color: #fff;
|
||||||
|
background: var(--td-success-color);
|
||||||
|
padding: 4px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-path {
|
||||||
|
font: 600 14px/1.3 var(--td-font-family-mono, monospace);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-badge.strict {
|
||||||
|
color: var(--td-warning-color);
|
||||||
|
border: 1px solid var(--td-warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-badge.lenient {
|
||||||
|
color: var(--td-success-color);
|
||||||
|
border: 1px solid var(--td-success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-summary {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* 单接口完整面板:信息行 → 注意事项折叠 → 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';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { findEndpointByPath } from '@/api/endpoints';
|
||||||
|
import { useManualEndpointQuery } from '@/api/endpoint-queries';
|
||||||
|
import EndpointInfoLine from '@/components/endpoint-info-line.vue';
|
||||||
|
import JsonTextView from '@/components/json-text-view.vue';
|
||||||
|
import { useBenchStore } from '@/stores/bench-store';
|
||||||
|
|
||||||
|
const props = defineProps<{ endpointPath: string }>();
|
||||||
|
|
||||||
|
const endpoint = computed(() => {
|
||||||
|
const found = findEndpointByPath(props.endpointPath);
|
||||||
|
if (!found) throw new Error(`未知接口路径:${props.endpointPath}`);
|
||||||
|
return found;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- sk-key(bench-store 持有:每 tab 独立、仅内存态、切 tab 保留) ---------- */
|
||||||
|
|
||||||
|
const benchStore = useBenchStore();
|
||||||
|
|
||||||
|
const skKeyInput = computed({
|
||||||
|
get: () => benchStore.getSkKey(props.endpointPath),
|
||||||
|
set: (value: string) => benchStore.setSkKey(props.endpointPath, value),
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- 手动请求(TanStack Query) ---------- */
|
||||||
|
|
||||||
|
const endpointPathRef = computed(() => props.endpointPath);
|
||||||
|
const { data: responseData, isFetching, hasSent, sendRequest } = useManualEndpointQuery(
|
||||||
|
endpointPathRef,
|
||||||
|
skKeyInput,
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyInputStatus = computed(() =>
|
||||||
|
hasSent.value && !skKeyInput.value.trim() ? 'error' : 'default',
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSend(): void {
|
||||||
|
if (!skKeyInput.value.trim()) return; // 空 key 不发(输入框已标红提示)
|
||||||
|
sendRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 响应区操作:复制 / 下载 ---------- */
|
||||||
|
|
||||||
|
const { copy: copyResponseToClipboard, copied, isSupported: clipboardSupported } = useClipboard({
|
||||||
|
source: () => responseData.value?.text ?? '',
|
||||||
|
legacy: true, // 非 https 环境降级 execCommand
|
||||||
|
});
|
||||||
|
|
||||||
|
const downloadFileName = computed(() => {
|
||||||
|
const pathSegment = props.endpointPath.replaceAll('/', '_').slice(1);
|
||||||
|
return `bench-${pathSegment}.json`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function downloadResponseAsFile(): void {
|
||||||
|
const text = responseData.value?.text;
|
||||||
|
if (!text) return;
|
||||||
|
const blob = new Blob([text], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = downloadFileName.value;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="endpoint-panel">
|
||||||
|
<!-- 信息行:method + 路径 + 鉴权徽标 -->
|
||||||
|
<section class="panel-section">
|
||||||
|
<EndpointInfoLine :endpoint="endpoint" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 注意事项折叠(坑文档,文案来自 endpoints.ts) -->
|
||||||
|
<section class="panel-section">
|
||||||
|
<t-collapse :default-value="[]" expand-mutex>
|
||||||
|
<t-collapse-panel value="notes" header="注意事项(接口实证调研结论)">
|
||||||
|
<ul class="note-list">
|
||||||
|
<li v-for="note in endpoint.notes" :key="note">{{ note }}</li>
|
||||||
|
</ul>
|
||||||
|
</t-collapse-panel>
|
||||||
|
</t-collapse>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 调试行:sk-key + 发送 -->
|
||||||
|
<section class="panel-section">
|
||||||
|
<div class="request-row">
|
||||||
|
<t-input
|
||||||
|
v-model="skKeyInput"
|
||||||
|
class="sk-key-input"
|
||||||
|
type="password"
|
||||||
|
:clearable="true"
|
||||||
|
placeholder="sk-xxxxxxxxxxxx"
|
||||||
|
:status="keyInputStatus"
|
||||||
|
:tips="keyInputStatus === 'error' ? '请填写 sk-key' : ''"
|
||||||
|
@enter="handleSend"
|
||||||
|
/>
|
||||||
|
<t-button
|
||||||
|
theme="primary"
|
||||||
|
:loading="isFetching"
|
||||||
|
:disabled="!skKeyInput.trim()"
|
||||||
|
@click="handleSend"
|
||||||
|
>
|
||||||
|
发送请求
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 响应区 -->
|
||||||
|
<section v-if="responseData" class="panel-section response-section">
|
||||||
|
<div class="response-meta">
|
||||||
|
<span
|
||||||
|
class="status"
|
||||||
|
:class="
|
||||||
|
responseData.status !== null && responseData.status >= 200 && responseData.status < 300
|
||||||
|
? 'ok'
|
||||||
|
: 'err'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ responseData.status !== null ? `HTTP ${responseData.status}` : '网络错误' }}
|
||||||
|
</span>
|
||||||
|
<span>{{ responseData.durationMs }}ms</span>
|
||||||
|
<span>{{ responseData.byteLength.toLocaleString('en-US') }} 字符</span>
|
||||||
|
<span v-if="responseData.networkError" class="network-error">
|
||||||
|
{{ responseData.networkError }}
|
||||||
|
</span>
|
||||||
|
<span class="meta-spacer"></span>
|
||||||
|
<t-button
|
||||||
|
v-if="clipboardSupported"
|
||||||
|
size="small"
|
||||||
|
variant="outline"
|
||||||
|
@click="copyResponseToClipboard()"
|
||||||
|
>
|
||||||
|
{{ copied ? '已复制' : '复制' }}
|
||||||
|
</t-button>
|
||||||
|
<t-button size="small" variant="outline" @click="downloadResponseAsFile">下载</t-button>
|
||||||
|
</div>
|
||||||
|
<JsonTextView :json="responseData.json" :raw-text="responseData.text" />
|
||||||
|
</section>
|
||||||
|
<section v-else-if="!hasSent" class="panel-section response-placeholder">
|
||||||
|
填写 sk-key 后点击「发送请求」
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.endpoint-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-section {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-key-input {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-section {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
font-family: var(--td-font-family-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.ok {
|
||||||
|
color: var(--td-success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.err {
|
||||||
|
color: var(--td-error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-error {
|
||||||
|
color: var(--td-error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-placeholder {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-list li {
|
||||||
|
margin: 4px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-list li::marker {
|
||||||
|
color: var(--td-warning-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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 变化),缓存天然隔离
|
||||||
|
*/
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Tabs, type TabPanelProps, type TabValue } from 'tdesign-vue-next';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { computed, h } from 'vue';
|
||||||
|
|
||||||
|
import { findEndpointByPath } from '@/api/endpoints';
|
||||||
|
import EndpointPanel from '@/components/endpoint-panel.vue';
|
||||||
|
import { useBenchStore } from '@/stores/bench-store';
|
||||||
|
|
||||||
|
const benchStore = useBenchStore();
|
||||||
|
const { openTabs, activeTab } = storeToRefs(benchStore);
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
function onTabChange(tabValue: TabValue): void {
|
||||||
|
if (typeof tabValue === 'string') {
|
||||||
|
benchStore.activateTab(tabValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTabRemove(options: { value: TabValue; index: number; e: MouseEvent }): void {
|
||||||
|
if (typeof options.value === 'string') {
|
||||||
|
benchStore.closeTab(options.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTabDragSort(context: {
|
||||||
|
currentIndex: number;
|
||||||
|
current: TabValue;
|
||||||
|
targetIndex: number;
|
||||||
|
target: TabValue;
|
||||||
|
}): void {
|
||||||
|
if (typeof context.current === 'string') {
|
||||||
|
benchStore.reorderTab(context.current, context.currentIndex, context.targetIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Tabs
|
||||||
|
:value="activeTab ?? undefined"
|
||||||
|
:list="tabPanelList"
|
||||||
|
theme="card"
|
||||||
|
class="request-tabs"
|
||||||
|
:drag-sort="true"
|
||||||
|
@change="onTabChange"
|
||||||
|
@remove="onTabRemove"
|
||||||
|
@drag-sort="onTabDragSort"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.request-tabs {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容区撑满剩余高度;不覆盖 .t-tab-panel 的 display(TDesign 靠它管理显隐) */
|
||||||
|
.request-tabs :deep(.t-tabs__content) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-tabs :deep(.t-tab-panel) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* 左侧接口导航:5 个路径平铺(不分组不编号,按 endpoints.ts 定案顺序)
|
||||||
|
* 点击 → openOrActivateTab(已开则激活,未开则新开)
|
||||||
|
*/
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
|
||||||
|
import { endpointList } from '@/api/endpoints';
|
||||||
|
import { useBenchStore } from '@/stores/bench-store';
|
||||||
|
|
||||||
|
const benchStore = useBenchStore();
|
||||||
|
const { activeTab } = storeToRefs(benchStore);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="side-nav">
|
||||||
|
<h1 class="app-title">new-api-key-bench</h1>
|
||||||
|
<p class="app-subtitle">NewAPI sk-key 接口调试台</p>
|
||||||
|
<nav class="endpoint-list">
|
||||||
|
<button
|
||||||
|
v-for="endpoint in endpointList"
|
||||||
|
:key="endpoint.path"
|
||||||
|
type="button"
|
||||||
|
class="endpoint-item"
|
||||||
|
:class="{ active: activeTab === endpoint.path }"
|
||||||
|
@click="benchStore.openOrActivateTab(endpoint.path)"
|
||||||
|
>
|
||||||
|
{{ endpoint.path }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.side-nav {
|
||||||
|
width: 300px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-right: 1px solid var(--td-component-border);
|
||||||
|
background: var(--td-bg-color-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
margin: 16px 16px 4px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-subtitle {
|
||||||
|
margin: 0 16px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--td-text-color-placeholder);
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--td-component-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-item {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-family: var(--td-font-family-mono, monospace);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-item:hover {
|
||||||
|
background: var(--td-bg-color-container-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-item.active {
|
||||||
|
color: var(--td-brand-color);
|
||||||
|
background: var(--td-brand-color-light);
|
||||||
|
border-left-color: var(--td-brand-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+33
@@ -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');
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user