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),明暗双主题
- 环境变量:彩色徽标切换器、管理弹窗、{{ 自动补全
This commit is contained in:
logan committed 2026-09-18 00:16:59 +08:00
commit dec66db5aa
182 files changed
+23377

No files matched your search

+695
View File
@@ -0,0 +1,695 @@
/**
* 工作台主 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 };