Files
opsxapi/packages/ui/src/workspaceStore.ts
T

705 lines
21 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) };
// 一次性迁移:旧演示地址更新为新的公共 echo 服务
if (
n.type === "request" &&
n.kind === "websocket" &&
n.data &&
(n.data as { url?: string }).url === "wss://echo.websocket.events"
) {
(n.data as { url: string }).url = "wss://echo.websocket.org";
}
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 };