feat(store): bench-store(tabs 开关激活/拖拽重排 + 每 tab sk-key)

sk-key 按接口路径存取,仅内存态、刷新即重置;closeTab 清除对应 key,重开为干净面板。面板组件随 tab 切换卸载重建,输入由 store 保留。
This commit is contained in:
2026-09-08 00:49:31 +08:00
parent 78f2e7136e
commit 3f95335651
2 changed files with 313 additions and 0 deletions
+205
View File
@@ -0,0 +1,205 @@
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it } from 'vitest';
import { useBenchStore } from './bench-store';
beforeEach(() => {
setActivePinia(createPinia());
});
describe('openOrActivateTab', () => {
it('未打开的接口:追加并激活', () => {
const store = useBenchStore();
store.openOrActivateTab('/v1/models');
expect(store.openTabs).toEqual(['/v1/models']);
expect(store.activeTab).toBe('/v1/models');
});
it('已打开的接口:仅激活,不重复追加(Postman 语义)', () => {
const store = useBenchStore();
store.openOrActivateTab('/v1/models');
store.openOrActivateTab('/api/log/token');
store.openOrActivateTab('/v1/models');
expect(store.openTabs).toEqual(['/v1/models', '/api/log/token']);
expect(store.activeTab).toBe('/v1/models');
});
it('保持开启顺序(不按字典序)', () => {
const store = useBenchStore();
store.openOrActivateTab('/api/usage/token');
store.openOrActivateTab('/v1/dashboard/billing/subscription');
store.openOrActivateTab('/v1/models');
expect(store.openTabs).toEqual([
'/api/usage/token',
'/v1/dashboard/billing/subscription',
'/v1/models',
]);
});
});
describe('skKeys(每 tab 独立,仅内存态)', () => {
it('未设置时返回空串', () => {
const store = useBenchStore();
expect(store.getSkKey('/v1/models')).toBe('');
});
it('setSkKey 按路径存取值(tab 间互不干扰)', () => {
const store = useBenchStore();
store.setSkKey('/v1/models', 'sk-models');
store.setSkKey('/api/log/token', 'sk-logs');
expect(store.getSkKey('/v1/models')).toBe('sk-models');
expect(store.getSkKey('/api/log/token')).toBe('sk-logs');
});
it('覆盖写入生效(同 tab 换 key)', () => {
const store = useBenchStore();
store.setSkKey('/v1/models', 'sk-old');
store.setSkKey('/v1/models', 'sk-new');
expect(store.getSkKey('/v1/models')).toBe('sk-new');
});
it('closeTab 清除该 tab 的 key;其他 tab 不受影响', () => {
const store = useBenchStore();
store.openOrActivateTab('/v1/models');
store.openOrActivateTab('/api/log/token');
store.setSkKey('/v1/models', 'sk-models');
store.setSkKey('/api/log/token', 'sk-logs');
store.closeTab('/v1/models');
expect(store.getSkKey('/v1/models')).toBe('');
expect(store.getSkKey('/api/log/token')).toBe('sk-logs');
// 重新打开是干净面板(key 不复活)
store.openOrActivateTab('/v1/models');
expect(store.getSkKey('/v1/models')).toBe('');
});
});
describe('closeTab', () => {
it('关闭非激活 tab:激活态不变', () => {
const store = useBenchStore();
store.openOrActivateTab('/v1/models');
store.openOrActivateTab('/api/log/token');
store.closeTab('/v1/models');
expect(store.openTabs).toEqual(['/api/log/token']);
expect(store.activeTab).toBe('/api/log/token');
});
it('关闭激活 tab:优先激活右侧相邻 tab,被关的是最后一个则取左侧', () => {
const store = useBenchStore();
store.openOrActivateTab('/v1/models');
store.openOrActivateTab('/v1/dashboard/billing/usage');
store.openOrActivateTab('/api/usage/token');
// 当前激活最后一个(/api/usage/token)→ 无右侧,激活左侧
store.closeTab('/api/usage/token');
expect(store.activeTab).toBe('/v1/dashboard/billing/usage');
// 重开 /api/usage/token → tabs = [models, billing/usage, api/usage/token]
// 关中间的 billing/usage(非激活):激活态不变(activeTab 仍是 api/usage/token)
store.openOrActivateTab('/api/usage/token');
store.closeTab('/v1/dashboard/billing/usage');
expect(store.openTabs).toEqual(['/v1/models', '/api/usage/token']);
expect(store.activeTab).toBe('/api/usage/token');
});
it('关闭激活 tab 且存在右侧相邻:右侧补位', () => {
const store = useBenchStore();
store.openOrActivateTab('/v1/models');
store.openOrActivateTab('/v1/dashboard/billing/usage');
store.openOrActivateTab('/api/usage/token');
// 激活中间的 billing/usage 后关闭 → 右侧 api/usage/token 补位
store.activateTab('/v1/dashboard/billing/usage');
store.closeTab('/v1/dashboard/billing/usage');
expect(store.activeTab).toBe('/api/usage/token');
});
it('关闭唯一 tab:activeTab 置空(右侧渲染空态)', () => {
const store = useBenchStore();
store.openOrActivateTab('/v1/models');
store.closeTab('/v1/models');
expect(store.openTabs).toEqual([]);
expect(store.activeTab).toBeNull();
expect(store.hasOpenTabs).toBe(false);
});
it('关闭未打开的路径:无操作', () => {
const store = useBenchStore();
store.openOrActivateTab('/v1/models');
store.closeTab('/v1/nonexistent');
expect(store.openTabs).toEqual(['/v1/models']);
});
});
describe('reorderTab', () => {
function openThreeTabs(store: ReturnType<typeof useBenchStore>): void {
store.openOrActivateTab('/v1/models');
store.openOrActivateTab('/v1/dashboard/billing/usage');
store.openOrActivateTab('/api/usage/token');
}
it('前移:0 → 2 位', () => {
const store = useBenchStore();
openThreeTabs(store);
store.reorderTab('/v1/models', 0, 2);
expect(store.openTabs).toEqual(['/v1/dashboard/billing/usage', '/api/usage/token', '/v1/models']);
});
it('后移:2 → 0 位', () => {
const store = useBenchStore();
openThreeTabs(store);
store.reorderTab('/api/usage/token', 2, 0);
expect(store.openTabs).toEqual(['/api/usage/token', '/v1/models', '/v1/dashboard/billing/usage']);
});
it('相邻换位:0 ↔ 1', () => {
const store = useBenchStore();
openThreeTabs(store);
store.reorderTab('/v1/models', 0, 1);
expect(store.openTabs).toEqual(['/v1/dashboard/billing/usage', '/v1/models', '/api/usage/token']);
});
it('原位不动:from === to', () => {
const store = useBenchStore();
openThreeTabs(store);
store.reorderTab('/v1/dashboard/billing/usage', 1, 1);
expect(store.openTabs).toEqual(['/v1/models', '/v1/dashboard/billing/usage', '/api/usage/token']);
});
it('路径与索引不匹配:无操作(防御 t-tabs 事件异常)', () => {
const store = useBenchStore();
openThreeTabs(store);
store.reorderTab('/v1/models', 2, 0); // 索引 2 上是 /api/usage/token
expect(store.openTabs).toEqual(['/v1/models', '/v1/dashboard/billing/usage', '/api/usage/token']);
});
it('越界索引:无操作', () => {
const store = useBenchStore();
openThreeTabs(store);
store.reorderTab('/v1/models', 0, 99);
store.reorderTab('/v1/models', -1, 0);
expect(store.openTabs).toEqual(['/v1/models', '/v1/dashboard/billing/usage', '/api/usage/token']);
});
it('重排不影响激活态', () => {
const store = useBenchStore();
openThreeTabs(store);
store.reorderTab('/v1/models', 0, 2);
expect(store.activeTab).toBe('/api/usage/token');
});
});
describe('activateTab', () => {
it('激活已打开的 tab', () => {
const store = useBenchStore();
store.openOrActivateTab('/v1/models');
store.openOrActivateTab('/api/log/token');
store.activateTab('/v1/models');
expect(store.activeTab).toBe('/v1/models');
});
it('未打开的路径:忽略(激活入口统一走 openOrActivateTab)', () => {
const store = useBenchStore();
store.openOrActivateTab('/v1/models');
store.activateTab('/v1/nonexistent');
expect(store.activeTab).toBe('/v1/models');
});
});
+108
View File
@@ -0,0 +1,108 @@
/**
* bench 工作台状态:tabs 的开 / 关 / 激活 + 每 tab 的 sk-key(Postman 语义)
* sk-key 2026-09 起提升到 store:EndpointPanel 随 tab 切换卸载重建,
* 局部 ref 会丢输入(切回要重贴 key),改由 store 持有以便切 tab 保留;
* 无持久化(不落 localStorage),刷新即重置
*/
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
/** tab 的唯一标识即接口路径(设计文档 §3.1:tab 标签直接显示接口路径) */
export type EndpointPath = string;
export const useBenchStore = defineStore('bench', () => {
/* ---------- state ---------- */
/** 已打开的 tab(按开启顺序) */
const openTabs = ref<EndpointPath[]>([]);
/** 当前激活的 tab;全部关光为 null(右侧渲染空态引导页) */
const activeTab = ref<EndpointPath | null>(null);
/** 每 tab 的 sk-key(仅内存态;key 值由 store 内按路径存取) */
const skKeys = ref<Record<EndpointPath, string>>({});
/* ---------- getters ---------- */
const hasOpenTabs = computed(() => openTabs.value.length > 0);
/* ---------- actions ---------- */
/**
* 打开或激活 tab(点击 sider 项 / 再次点击同接口时调用)
* Postman 语义:已开则激活,未开则追加并激活
*/
function openOrActivateTab(path: EndpointPath): void {
if (!openTabs.value.includes(path)) {
openTabs.value.push(path);
}
activeTab.value = path;
}
/**
* 关闭 tab;若关的是激活 tab,激活相邻 tab(优先右侧,无右侧取左侧)
* 全部关光 → activeTab = null
*/
function closeTab(path: EndpointPath): void {
const closingIndex = openTabs.value.indexOf(path);
if (closingIndex === -1) return;
openTabs.value.splice(closingIndex, 1);
// 关 tab 即弃 key(Postman 语义:重新打开是干净面板)
delete skKeys.value[path];
if (activeTab.value === path) {
// 优先激活紧邻的右侧 tab;被关的是最后一个则取左侧
const nextActive = openTabs.value[closingIndex] ?? openTabs.value[closingIndex - 1];
activeTab.value = nextActive ?? null;
}
}
/** 激活已打开的 tab(未打开则忽略——激活入口统一走 openOrActivateTab) */
function activateTab(path: EndpointPath): void {
if (openTabs.value.includes(path)) {
activeTab.value = path;
}
}
/* ---------- sk-key(每 tab 独立,仅内存态) ---------- */
/** 读取某 tab 的 sk-key(未设置返回空串,与面板初始态一致) */
function getSkKey(path: EndpointPath): string {
return skKeys.value[path] ?? '';
}
/** 写入某 tab 的 sk-key(空串即清除该项) */
function setSkKey(path: EndpointPath, key: string): void {
skKeys.value = { ...skKeys.value, [path]: key };
}
/**
* 拖拽重排 tab(t-tabs drag-sort 事件回调)
* fromIndex/toIndex 均为事件原样值,越界或路径不匹配时无操作(防御 t-tabs 事件异常)
*/
function reorderTab(path: EndpointPath, fromIndex: number, toIndex: number): void {
if (fromIndex < 0 || toIndex < 0) return;
if (fromIndex >= openTabs.value.length || toIndex >= openTabs.value.length) return;
if (openTabs.value[fromIndex] !== path) return;
// splice 两步走:先摘出,再插入目标位(toIndex 按「摘出后的数组」语义,与 t-tabs 一致)
const [movedTab] = openTabs.value.splice(fromIndex, 1);
if (movedTab === undefined) return;
openTabs.value.splice(toIndex, 0, movedTab);
}
return {
openTabs,
activeTab,
hasOpenTabs,
openOrActivateTab,
closeTab,
activateTab,
reorderTab,
getSkKey,
setSkKey,
};
});