Files
opsxapi/packages/ui/src/workspaceStore.ts
T
logan dec66db5aa feat: OpsxAPI v1.0 全协议接口工作台
- 六协议调试:HTTP/GraphQL/WebSocket/SSE/Socket.IO/MQTT
- 集合树/多标签/多环境变量/历史记录,localStorage 持久化
- 导入:OpenAPI 2/3/3.1、Postman、Apifox、Apipost、cURL、HAR、
  Insomnia、Hoppscotch、Markdown(自动识别,21 个单测覆盖)
- 导出:OpenAPI 3.0 JSON/YAML、Postman v2.1、Markdown 文档、
  全量备份;单请求生成 cURL/fetch/Python/Go 代码
- Gin 服务:HTTP 代理、NDJSON 流式代理(SSE 透传)、
  WebSocket 隧道(浏览器自定义握手 Header)
- 内置产品官网 /home 与用户文档站 /docs(SSR,随二进制交付)
- lucide 图标体系 + 品牌标识(Logo/favicon),明暗双主题
- 环境变量:彩色徽标切换器、管理弹窗、{{ 自动补全
2026-09-18 00:16:59 +08:00

696 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 工作台主 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<string, string>;
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<string, boolean>;
dialog: Dialog | null;
runtime: Record<string, RequestRuntime>;
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<K extends RequestKind>(
id: string,
patch: Partial<Extract<RequestNode, { kind: K }>["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<Omit<Environment, "id">>): 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<RequestRuntime>): 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<WorkspaceData>;
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<typeof setTimeout> | 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<WorkspaceStore>((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<string, boolean> = {};
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<string, RequestRuntime>,
};
});
// ---------- 纯帮助函数 ----------
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<string, unknown>,
): 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<RequestKind, string> = {
http: "新建 HTTP 接口",
graphql: "新建 GraphQL",
websocket: "新建 WebSocket",
sse: "新建 SSE",
socketio: "新建 Socket.IO",
mqtt: "新建 MQTT",
};
return labels[kind];
}
/** 便捷选择器:当前环境变量表 */
export function activeEnvMap(): Record<string, string> {
const state = useWorkspace.getState();
const active = state.environments.find((e) => e.id === state.activeEnvironmentId);
const out: Record<string, string> = {};
for (const v of active?.variables ?? []) {
if (v.enabled && v.key) out[v.key] = v.value;
}
return out;
}
/** 便捷选择器:请求体构建辅助(http/graphql 共用) */
export type { HttpRequestData };