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:
2026-09-08 00:49:37 +08:00
parent 3f95335651
commit 60ba5b6306
10 changed files with 746 additions and 0 deletions
+57
View File
@@ -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();
});
});
+66
View File
@@ -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 };