/** * 工作台主 store:集合 / 环境 / 标签页 / 历史 / 设置 / 运行时状态。 * 持久化:localStorage(debounce),实时会话等运行时数据不落盘。 */ import type { Collection, CollectionNode, Environment, FolderNode, HistoryEntry, HttpRequestData, RequestKind, RequestNode, ThemeMode, WorkspaceData, WorkspaceSettings, } from "@opsxapi/core"; import { createRequest, createSeedWorkspace, defaultRequestData, deepCopyNode, findNode, insertIntoCollection, kv, moveNode, newId, uniqueName, } from "@opsxapi/core"; import { create } from "zustand"; const STORAGE_KEY = "opsxapi.workspace.v2"; export type LiveMessage = { id: string; dir: "in" | "out" | "system" | "error"; at: number; text: string; /** mqtt topic / socketio event / sse event */ topic?: string; }; export type RequestRuntime = { loading: boolean; error: string | null; /** http/graphql 响应 */ result: { status: number; headers: Record; body: string; timeMs: number; size: number; } | null; responseTab: "body" | "headers" | "raw"; /** 实时协议状态 */ status: string; messages: LiveMessage[]; sent: number; received: number; startedAt: number | null; }; export type Dialog = | { type: "new"; collectionId: string; parentId: string | null } | { type: "import" } | { type: "export"; collectionId: string } | { type: "environments" } | { type: "settings" } | { type: "rename"; id: string } | { type: "confirm"; title: string; message: string; action: () => void }; export function emptyRuntime(): RequestRuntime { return { loading: false, error: null, result: null, responseTab: "body", status: "closed", messages: [], sent: 0, received: 0, startedAt: null, }; } type PersistedShape = Pick< WorkspaceData, "collections" | "environments" | "activeEnvironmentId" | "history" | "settings" >; type WorkspaceStore = PersistedShape & { openTabIds: string[]; activeTabId: string | null; search: string; expanded: Record; dialog: Dialog | null; runtime: Record; init(): void; addCollection(name?: string): string; renameNode(id: string, name: string): void; removeNodeById(id: string): void; duplicateNodeById(id: string): void; moveNodeTo(id: string, collectionId: string, parentId: string | null): void; addFolder(collectionId: string, parentId: string | null, name?: string): string; addRequest(collectionId: string, parentId: string | null, kind: RequestKind, name?: string): string; updateRequestData( id: string, patch: Partial["data"]>, ): void; updateNodeDescription(id: string, description: string): void; importCollections(collections: Collection[], envVars: { key: string; value: string }[]): string[]; loadWorkspaceData(data: WorkspaceData): void; resetWorkspace(): void; openTab(id: string): void; closeTab(id: string): void; closeOtherTabs(id: string): void; setActiveTab(id: string | null): void; setActiveEnvironment(id: string | null): void; addEnvironment(name?: string): string; duplicateEnvironment(id: string): string; removeEnvironment(id: string): void; updateEnvironment(id: string, patch: Partial>): void; addEnvVariable(envId: string): void; updateEnvVariable(envId: string, rowId: string, patch: { key?: string; value?: string; enabled?: boolean }): void; removeEnvVariable(envId: string, rowId: string): void; setTheme(theme: ThemeMode): void; setTimeoutMs(ms: number): void; setRuntime(id: string, patch: Partial): void; pushLive(id: string, dir: LiveMessage["dir"], text: string, topic?: string): void; clearLive(id: string): void; resetRuntime(id: string): void; addHistory(entry: HistoryEntry): void; clearHistory(): void; setSearch(term: string): void; toggleExpanded(id: string, force?: boolean): void; setDialog(dialog: Dialog | null): void; }; /** 自愈历史坏数据:请求节点缺 data 时按 kind 补默认值(保名称与说明) */ function sanitizeCollections(collections: Collection[]): Collection[] { const fix = (nodes: CollectionNode[]): CollectionNode[] => nodes.map((n) => { if (n.type === "folder") return { ...n, children: fix(n.children) }; if (n.type === "request" && (!n.data || !n.kind)) { const kind = (n.kind ?? "http") as RequestKind; return { ...n, kind, data: { ...defaultRequestData(kind) }, } as RequestNode; } return n; }); return collections.map((c) => ({ ...c, children: fix(c.children) })); } function loadPersisted(): WorkspaceData | null { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; const data = JSON.parse(raw) as Partial; if (!Array.isArray(data.collections) || !Array.isArray(data.environments)) return null; return { version: 2, collections: sanitizeCollections(data.collections), environments: data.environments, activeEnvironmentId: data.activeEnvironmentId ?? data.environments[0]?.id ?? null, history: Array.isArray(data.history) ? data.history : [], settings: { theme: data.settings?.theme === "light" ? "light" : "dark", timeoutMs: data.settings?.timeoutMs ?? 30000, }, }; } catch { return null; } } let saveTimer: ReturnType | null = null; function schedulePersist(state: WorkspaceStore): void { if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(() => { try { const data: PersistedShape & { version: number } = { version: 2, collections: state.collections, environments: state.environments, activeEnvironmentId: state.activeEnvironmentId, history: state.history.slice(0, 100), settings: state.settings, }; localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch (e) { console.warn("[opsxapi] 工作区保存失败", e); } }, 400); } export function applyThemeToDocument(theme: ThemeMode): void { if (typeof document === "undefined") return; document.documentElement.dataset.theme = theme; } export const useWorkspace = create((set, get) => { const persist = () => schedulePersist(get()); return { collections: [], environments: [], activeEnvironmentId: null, history: [], settings: { theme: "dark", timeoutMs: 30000 }, openTabIds: [], activeTabId: null, search: "", expanded: {}, dialog: null, init() { const data = loadPersisted() ?? createSeedWorkspace(); set({ collections: data.collections, environments: data.environments, activeEnvironmentId: data.activeEnvironmentId, history: data.history, settings: data.settings, }); applyThemeToDocument(data.settings.theme); // 展开全部目录 const expanded: Record = {}; for (const c of data.collections) { walkFolders(c.children, (f) => { expanded[f.id] = true; }); } set({ expanded }); }, addCollection(name) { const collection: Collection = { id: newId(), type: "collection", name: name || "新建集合", children: [], createdAt: Date.now(), updatedAt: Date.now(), }; set({ collections: [...get().collections, collection] }); persist(); return collection.id; }, renameNode(id, name) { set({ collections: get().collections.map((c) => c.id === id ? { ...c, name, updatedAt: Date.now() } : renameIn(c, id, name), ), }); persist(); }, removeNodeById(id) { const state = get(); const openTabIds = state.openTabIds.filter((t) => t !== id); const activeTabId = state.activeTabId === id ? (openTabIds[openTabIds.length - 1] ?? null) : state.activeTabId; const collections = state.collections.map((c) => removeFrom(c, id)); set({ collections, openTabIds, activeTabId }); persist(); }, duplicateNodeById(id) { const state = get(); const ref = findNode(state.collections, id); if (!ref) return; const copy = deepCopyNode(ref.node, (name) => `${name} 副本`); const collections = insertIntoCollection( state.collections, ref.collection.id, ref.parent?.id ?? null, copy, ); set({ collections }); if (copy.type === "request") get().openTab(copy.id); persist(); }, moveNodeTo(id, collectionId, parentId) { set({ collections: moveNode(get().collections, id, collectionId, parentId) }); persist(); }, addFolder(collectionId, parentId, name) { const state = get(); const siblings = parentChildren(state.collections, collectionId, parentId); const folder: FolderNode = { id: newId(), type: "folder", name: uniqueName(siblings, name ?? "新建目录"), children: [], createdAt: Date.now(), updatedAt: Date.now(), }; set({ collections: insertIntoCollection(state.collections, collectionId, parentId, folder), expanded: { ...state.expanded, [folder.id]: true }, }); persist(); return folder.id; }, addRequest(collectionId, parentId, kind, name) { const state = get(); const siblings = parentChildren(state.collections, collectionId, parentId); const node: RequestNode = createRequest(kind, uniqueName(siblings, name ?? defaultNames(kind))); set({ collections: insertIntoCollection(state.collections, collectionId, parentId, node) }); get().openTab(node.id); persist(); return node.id; }, updateRequestData(id, patch) { set({ collections: get().collections.map((c) => patchRequestIn(c, id, patch)), }); persist(); }, updateNodeDescription(id, description) { set({ collections: get().collections.map((c) => descRequestIn(c, id, description)), }); persist(); }, importCollections(collections, envVars) { const state = get(); const existingNames = new Set(state.collections.map((c) => c.name)); const renamed = collections.map((c) => { if (!existingNames.has(c.name)) return c; let i = 2; while (existingNames.has(`${c.name} ${i}`)) i++; return { ...c, name: `${c.name} ${i}` }; }); // 环境变量并入当前环境 let environments = state.environments; let activeEnvironmentId = state.activeEnvironmentId; if (envVars.length) { const active = environments.find((e) => e.id === activeEnvironmentId) ?? environments[0]; if (active) { const known = new Set(active.variables.map((v) => v.key)); for (const v of envVars) { if (!known.has(v.key)) { active.variables.push(kv(v.key, v.value)); known.add(v.key); } } environments = environments.map((e) => (e.id === active.id ? { ...active } : e)); } else { const env: Environment = { id: newId(), name: "默认环境", variables: envVars.map((v) => kv(v.key, v.value)), }; environments = [...environments, env]; activeEnvironmentId = env.id; } } set({ collections: [...state.collections, ...renamed], environments, activeEnvironmentId }); persist(); return renamed.map((c) => c.id); }, loadWorkspaceData(data) { set({ collections: data.collections, environments: data.environments, activeEnvironmentId: data.activeEnvironmentId ?? data.environments[0]?.id ?? null, history: data.history ?? [], settings: data.settings, openTabIds: [], activeTabId: null, }); applyThemeToDocument(data.settings.theme); persist(); }, resetWorkspace() { const seed = createSeedWorkspace(); get().loadWorkspaceData(seed); }, openTab(id) { const state = get(); if (state.openTabIds.includes(id)) { set({ activeTabId: id }); return; } set({ openTabIds: [...state.openTabIds, id], activeTabId: id }); }, closeTab(id) { const state = get(); const openTabIds = state.openTabIds.filter((t) => t !== id); set({ openTabIds, activeTabId: state.activeTabId === id ? (openTabIds[openTabIds.length - 1] ?? null) : state.activeTabId, }); }, closeOtherTabs(id) { set({ openTabIds: [id], activeTabId: id }); }, setActiveTab(id) { set({ activeTabId: id }); }, setActiveEnvironment(id) { set({ activeEnvironmentId: id }); persist(); }, addEnvironment(name) { const env: Environment = { id: newId(), name: name || `环境 ${get().environments.length + 1}`, variables: [], }; set({ environments: [...get().environments, env] }); persist(); return env.id; }, duplicateEnvironment(id) { const state = get(); const source = state.environments.find((e) => e.id === id); if (!source) return ""; let name = `${source.name} 副本`; let i = 2; const names = new Set(state.environments.map((e) => e.name)); while (names.has(name)) name = `${source.name} 副本 ${i++}`; const copy: Environment = { id: newId(), name, variables: source.variables.map((v) => kv(v.key, v.value, v.enabled, v.description)), }; set({ environments: [...state.environments, copy] }); persist(); return copy.id; }, removeEnvironment(id) { const state = get(); const environments = state.environments.filter((e) => e.id !== id); set({ environments, activeEnvironmentId: state.activeEnvironmentId === id ? (environments[0]?.id ?? null) : state.activeEnvironmentId, }); persist(); }, updateEnvironment(id, patch) { set({ environments: get().environments.map((e) => (e.id === id ? { ...e, ...patch } : e)), }); persist(); }, addEnvVariable(envId) { set({ environments: get().environments.map((e) => e.id === envId ? { ...e, variables: [...e.variables, kv()] } : e, ), }); persist(); }, updateEnvVariable(envId, rowId, patch) { set({ environments: get().environments.map((e) => e.id === envId ? { ...e, variables: e.variables.map((v) => (v.id === rowId ? { ...v, ...patch } : v)), } : e, ), }); persist(); }, removeEnvVariable(envId, rowId) { set({ environments: get().environments.map((e) => e.id === envId ? { ...e, variables: e.variables.filter((v) => v.id !== rowId) } : e, ), }); persist(); }, setTheme(theme) { set({ settings: { ...get().settings, theme } }); applyThemeToDocument(theme); persist(); }, setTimeoutMs(ms) { set({ settings: { ...get().settings, timeoutMs: ms } }); persist(); }, setRuntime(id, patch) { const state = get(); set({ runtime: { ...state.runtime, [id]: { ...(state.runtime[id] ?? emptyRuntime()), ...patch }, }, }); }, pushLive(id, dir, text, topic) { const state = get(); const prev = state.runtime[id] ?? emptyRuntime(); const msg: LiveMessage = { id: newId(), dir, at: Date.now(), text, topic }; const messages = [...prev.messages, msg].slice(-500); set({ runtime: { ...state.runtime, [id]: { ...prev, messages, sent: dir === "out" ? prev.sent + 1 : prev.sent, received: dir === "in" ? prev.received + 1 : prev.received, }, }, }); }, clearLive(id) { const state = get(); const prev = state.runtime[id] ?? emptyRuntime(); set({ runtime: { ...state.runtime, [id]: { ...prev, messages: [] } } }); }, resetRuntime(id) { const state = get(); set({ runtime: { ...state.runtime, [id]: emptyRuntime() } }); }, addHistory(entry) { set({ history: [entry, ...get().history].slice(0, 100) }); persist(); }, clearHistory() { set({ history: [] }); persist(); }, setSearch(term) { set({ search: term }); }, toggleExpanded(id, force) { const expanded = { ...get().expanded }; expanded[id] = force ?? !expanded[id]; set({ expanded }); }, setDialog(dialog) { set({ dialog }); }, runtime: {} as Record, }; }); // ---------- 纯帮助函数 ---------- function walkFolders(nodes: CollectionNode[], fn: (f: FolderNode) => void): void { for (const node of nodes) { if (node.type === "folder") { fn(node); walkFolders(node.children, fn); } } } function renameIn(collection: Collection, id: string, name: string): Collection { const visit = (nodes: CollectionNode[]): CollectionNode[] => nodes.map((n) => { if (n.id === id) return { ...n, name, updatedAt: Date.now() }; if (n.type === "folder") return { ...n, children: visit(n.children) }; return n; }); return { ...collection, children: visit(collection.children) }; } function removeFrom(collection: Collection, id: string): Collection { const visit = (nodes: CollectionNode[]): CollectionNode[] => nodes .filter((n) => n.id !== id) .map((n) => (n.type === "folder" ? { ...n, children: visit(n.children) } : n)); return { ...collection, children: visit(collection.children) }; } function patchRequestIn( collection: Collection, id: string, patch: Record, ): Collection { const visit = (nodes: CollectionNode[]): CollectionNode[] => nodes.map((n) => { if (n.id === id && n.type === "request") { const merged = { ...n.data, ...patch } as RequestNode["data"]; return { ...n, data: merged, updatedAt: Date.now() } as CollectionNode; } if (n.type === "folder") return { ...n, children: visit(n.children) }; return n; }); return { ...collection, children: visit(collection.children) }; } function descRequestIn(collection: Collection, id: string, description: string): Collection { const visit = (nodes: CollectionNode[]): CollectionNode[] => nodes.map((n) => { if (n.id === id) return { ...n, description: description || undefined, updatedAt: Date.now() }; if (n.type === "folder") return { ...n, children: visit(n.children) }; return n; }); return { ...collection, children: visit(collection.children) }; } function parentChildren( collections: Collection[], collectionId: string, parentId: string | null, ): CollectionNode[] { const collection = collections.find((c) => c.id === collectionId); if (!collection) return []; if (parentId === null) return collection.children; let foundChildren: CollectionNode[] | null = null; const visit = (nodes: CollectionNode[]) => { for (const n of nodes) { if (n.id === parentId) { if (n.type === "folder") foundChildren = n.children; return; } if (n.type === "folder") visit(n.children); } }; visit(collection.children); return foundChildren ?? collection.children; } function defaultNames(kind: RequestKind): string { const labels: Record = { http: "新建 HTTP 接口", graphql: "新建 GraphQL", websocket: "新建 WebSocket", sse: "新建 SSE", socketio: "新建 Socket.IO", mqtt: "新建 MQTT", }; return labels[kind]; } /** 便捷选择器:当前环境变量表 */ export function activeEnvMap(): Record { const state = useWorkspace.getState(); const active = state.environments.find((e) => e.id === state.activeEnvironmentId); const out: Record = {}; for (const v of active?.variables ?? []) { if (v.enabled && v.key) out[v.key] = v.value; } return out; } /** 便捷选择器:请求体构建辅助(http/graphql 共用) */ export type { HttpRequestData };