diff --git a/src/stores/bench-store.spec.ts b/src/stores/bench-store.spec.ts new file mode 100644 index 0000000..f378fe3 --- /dev/null +++ b/src/stores/bench-store.spec.ts @@ -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): 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'); + }); +}); diff --git a/src/stores/bench-store.ts b/src/stores/bench-store.ts new file mode 100644 index 0000000..9d2d81f --- /dev/null +++ b/src/stores/bench-store.ts @@ -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([]); + + /** 当前激活的 tab;全部关光为 null(右侧渲染空态引导页) */ + const activeTab = ref(null); + + /** 每 tab 的 sk-key(仅内存态;key 值由 store 内按路径存取) */ + const skKeys = ref>({}); + + /* ---------- 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, + }; +});