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

+118
View File
@@ -0,0 +1,118 @@
/**
* OpsxAPI 工作台入口:组装顶栏 / 侧栏 / 标签页 / 编辑器 / 对话框。
*/
import { findNode } from "@opsxapi/core";
import { useEffect } from "react";
import { closeSession } from "./send";
import { Icon } from "./components/icons";
import { Logo } from "./components/Logo";
import { useWorkspace } from "./workspaceStore";
import { Sidebar } from "./components/Sidebar";
import { TabBar } from "./components/TabBar";
import { TopBar } from "./components/TopBar";
import { GraphqlEditor } from "./components/editors/GraphqlEditor";
import { HttpEditor } from "./components/editors/HttpEditor";
import { MqttEditor } from "./components/editors/MqttEditor";
import { SocketIoEditor } from "./components/editors/SocketIoEditor";
import { SseEditor } from "./components/editors/SseEditor";
import { WebsocketEditor } from "./components/editors/WebsocketEditor";
import { ExportDialog } from "./components/dialogs/ExportDialog";
import { ImportDialog } from "./components/dialogs/ImportDialog";
import {
EnvironmentsDialog,
NewItemDialog,
RenameDialog,
SettingsDialog,
} from "./components/dialogs/WorkspaceDialogs";
export function OpsxWorkspace() {
const init = useWorkspace((s) => s.init);
const inited = useWorkspace((s) => s.collections.length > 0 || s.environments.length > 0);
const collections = useWorkspace((s) => s.collections);
const activeTabId = useWorkspace((s) => s.activeTabId);
const dialog = useWorkspace((s) => s.dialog);
const openTab = useWorkspace((s) => s.openTab);
useEffect(() => {
init();
}, [init]);
// 关闭标签页时断开实时会话
useEffect(() => {
useWorkspace.subscribe((state, prev) => {
const closed = prev.openTabIds.filter((id) => !state.openTabIds.includes(id));
for (const id of closed) closeSession(id);
});
}, []);
const activeRef = activeTabId ? findNode(collections, activeTabId) : null;
const activeNode = activeRef?.node;
return (
<div className="flex h-screen min-h-[640px] flex-col overflow-hidden">
<TopBar />
<div className="flex min-h-0 flex-1">
<Sidebar />
<main className="flex min-w-0 flex-1 flex-col">
<TabBar />
{inited && activeNode === undefined && (
<EmptyState onQuick={(kind) => collections[0] && openTab(collections[0].id)} />
)}
{activeNode && activeNode.type === "request" && (
<>
{activeNode.kind === "http" && <HttpEditor node={activeNode} />}
{activeNode.kind === "graphql" && <GraphqlEditor node={activeNode} />}
{activeNode.kind === "websocket" && <WebsocketEditor node={activeNode} />}
{activeNode.kind === "sse" && <SseEditor node={activeNode} />}
{activeNode.kind === "socketio" && <SocketIoEditor node={activeNode} />}
{activeNode.kind === "mqtt" && <MqttEditor node={activeNode} />}
</>
)}
{activeNode && activeNode.type === "folder" && (
<EmptyState onQuick={() => undefined} />
)}
</main>
</div>
{dialog?.type === "new" && <NewItemDialog collectionId={dialog.collectionId} parentId={dialog.parentId} />}
{dialog?.type === "import" && <ImportDialog />}
{dialog?.type === "export" && <ExportDialog collectionId={dialog.collectionId} />}
{dialog?.type === "environments" && <EnvironmentsDialog />}
{dialog?.type === "settings" && <SettingsDialog />}
{dialog?.type === "rename" && <RenameDialog id={dialog.id} />}
</div>
);
}
function EmptyState({ onQuick }: { onQuick: (kind: string) => void }) {
const setDialog = useWorkspace((s) => s.setDialog);
void onQuick;
return (
<div className="grid min-h-0 flex-1 place-items-center">
<div className="text-center">
<div className="mb-3 opacity-80"><Logo size={56} /></div>
<div className="ops-dim mb-1 text-sm font-semibold">OpsxAPI · 接口工作台</div>
<div className="ops-faint mb-5 text-xs">
从左侧选择接口,或
<button className="ops-link mx-1" onClick={() => setDialog({ type: "import" })}>
导入数据
</button>
/
<button
className="ops-link ml-1"
onClick={() =>
setDialog({ type: "new", collectionId: useWorkspace.getState().collections[0]?.id ?? "", parentId: null })
}
>
新建请求
</button>
</div>
<div className="ops-faint text-[11px] leading-6">
HTTP · GraphQL · WebSocket · SSE · Socket.IO · MQTT
<br />
支持 OpenAPI / Postman / Apifox / cURL / HAR 等格式导入导出
</div>
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
/**
* 环境徽标:取环境名首字的彩色单字标(颜色按 id 稳定分配)。
* 左栏列表、顶栏切换器、环境弹窗共用,帮助一眼识别当前环境。
*/
import type { CSSProperties } from "react";
const PALETTE = [
"#3b82f6", // blue
"#8b5cf6", // violet
"#10b981", // emerald
"#f59e0b", // amber
"#f43f5e", // rose
"#06b6d4", // cyan
"#ec4899", // pink
"#84cc16", // lime
];
function hashColor(id: string): string {
let h = 0;
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
return PALETTE[h % PALETTE.length];
}
function firstChar(name: string): string {
const trimmed = (name || "").trim();
if (!trimmed) return "环";
// 取第一个字符(含中文);英文取大写
const ch = Array.from(trimmed)[0];
return /[a-z]/.test(ch) ? ch.toUpperCase() : ch;
}
export function EnvBadge({
envId,
name,
size = 24,
style,
}: {
envId: string;
name: string;
size?: number;
style?: CSSProperties;
}) {
const color = hashColor(envId);
return (
<span
className="env-badge"
style={{
width: size,
height: size,
fontSize: Math.round(size * 0.46),
background: `color-mix(in srgb, ${color} 16%, transparent)`,
color,
borderColor: `color-mix(in srgb, ${color} 40%, transparent)`,
...style,
}}
aria-hidden="true"
>
{firstChar(name)}
</span>
);
}
+22
View File
@@ -0,0 +1,22 @@
/**
* OpsxAPI 产品标识 —— 蓝→青渐变圆角方块 + 双向请求/响应箭头。
*/
export function Logo({ size = 26, className }: { size?: number; className?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" className={className} aria-label="OpsxAPI">
<defs>
<linearGradient id="opsx-logo-g" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#2563EB" />
<stop offset="1" stopColor="#06B6D4" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#opsx-logo-g)" />
<g stroke="#fff" strokeWidth="5" strokeLinecap="round" strokeLinejoin="round" fill="none">
<path d="M17 25h26" />
<path d="M37 19l6 6-6 6" />
<path d="M47 39H21" />
<path d="M27 33l-6 6 6 6" />
</g>
</svg>
);
}
+306
View File
@@ -0,0 +1,306 @@
/**
* 左侧集合树:搜索 / 目录 / 请求 / 右键菜单 / 拖拽移动 / 历史记录。
*/
import type { Collection, CollectionNode, RequestKind } from "@opsxapi/core";
import { KIND_LABELS, findNode, searchTree } from "@opsxapi/core";
import { useMemo, useState } from "react";
import { useWorkspace } from "../workspaceStore";
import { ContextMenu, cx, formatTime, type MenuItem } from "./common";
import { Icon, type IconName } from "./icons";
import { Logo } from "./Logo";
const KIND_ICONS: Record<RequestKind, IconName> = {
http: "globe",
graphql: "hexagon",
websocket: "zap",
sse: "radio",
socketio: "plug-zap",
mqtt: "mailbox",
};
export function Sidebar() {
const collections = useWorkspace((s) => s.collections);
const search = useWorkspace((s) => s.search);
const setSearch = useWorkspace((s) => s.setSearch);
const history = useWorkspace((s) => s.history);
const clearHistory = useWorkspace((s) => s.clearHistory);
const openTab = useWorkspace((s) => s.openTab);
const [showHistory, setShowHistory] = useState(true);
const [menu, setMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null);
const hitIds = useMemo(() => searchTree(collections, search), [collections, search]);
return (
<aside className="ops-panel flex w-64 shrink-0 flex-col" style={{ borderRight: "1px solid var(--border)" }}>
<div className="p-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
<input
className="ops-input ops-input-sm"
placeholder="搜索接口 / 目录…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="ops-scroll min-h-0 flex-1 p-2">
{collections.length === 0 && (
<div className="ops-faint px-2 py-8 text-center text-xs leading-5">
还没有集合
<br />
点击顶部「导入」或「新建」开始
</div>
)}
{collections.map((collection) => (
<div key={collection.id} className="mb-1">
<TreeRow
node={collection}
depth={0}
hitIds={hitIds}
collectionId={collection.id}
isCollection
onMenu={setMenu}
/>
{collection.children
.filter((n) => search === "" || hitIds.has(n.id))
.map((node) => (
<TreeChildren
key={node.id}
nodes={[node]}
depth={1}
hitIds={hitIds}
collectionId={collection.id}
onMenu={setMenu}
/>
))}
</div>
))}
</div>
{/* 历史 */}
<div style={{ borderTop: "1px solid var(--border)" }}>
<button
className="ops-faint flex w-full items-center justify-between px-3 py-2 text-[11px] font-semibold uppercase tracking-wider"
onClick={() => setShowHistory((v) => !v)}
>
<span>历史记录 · {history.length}</span>
<span>{showHistory ? "▾" : "▸"}</span>
</button>
{showHistory && (
<div className="ops-scroll max-h-44 px-2 pb-2">
{history.length === 0 && <div className="ops-faint px-2 py-3 text-center text-[11px]">暂无记录</div>}
{history.slice(0, 30).map((h) => (
<button
key={h.id}
className="ops-tree-row w-full !py-1 text-left"
onClick={() => {
if (h.nodeId && findNode(useWorkspace.getState().collections, h.nodeId)) {
openTab(h.nodeId);
}
}}
title={`${h.label}\n${h.url}`}
>
<span className="ops-faint w-14 shrink-0 text-[10px]">{formatTime(h.at)}</span>
{h.method && <span className={`ops-method-${h.method} w-12 shrink-0 text-[10px] font-bold`}>{h.method}</span>}
<span className="truncate text-[11px]">{h.label}</span>
{h.status !== undefined && (
<span className={cx("ml-auto text-[10px]", h.ok ? "ops-success" : "ops-danger")}>{h.status}</span>
)}
</button>
))}
{history.length > 0 && (
<button className="ops-btn ops-btn-ghost w-full py-1 text-[11px]" onClick={clearHistory}>
清空历史
</button>
)}
</div>
)}
</div>
<ContextMenu menu={menu} onClose={() => setMenu(null)} />
</aside>
);
}
function TreeChildren({
nodes,
depth,
hitIds,
collectionId,
onMenu,
}: {
nodes: CollectionNode[];
depth: number;
hitIds: Set<string>;
collectionId: string;
onMenu: (m: { x: number; y: number; items: MenuItem[] } | null) => void;
}) {
const expanded = useWorkspace((s) => s.expanded);
const toggleExpanded = useWorkspace((s) => s.toggleExpanded);
const search = useWorkspace((s) => s.search);
return (
<>
{nodes
.filter((n) => search === "" || hitIds.has(n.id))
.map((node) => (
<div key={node.id}>
<TreeRow node={node} depth={depth} hitIds={hitIds} collectionId={collectionId} onMenu={onMenu} />
{node.type === "folder" && expanded[node.id] && (
<TreeChildren
nodes={node.children}
depth={depth + 1}
hitIds={hitIds}
collectionId={collectionId}
onMenu={onMenu}
/>
)}
</div>
))}
</>
);
}
function TreeRow({
node,
depth,
hitIds,
collectionId,
isCollection = false,
onMenu,
}: {
node: CollectionNode | Collection;
depth: number;
hitIds: Set<string>;
collectionId: string;
isCollection?: boolean;
onMenu: (m: { x: number; y: number; items: MenuItem[] } | null) => void;
}) {
const activeTabId = useWorkspace((s) => s.activeTabId);
const expanded = useWorkspace((s) => s.expanded);
const toggleExpanded = useWorkspace((s) => s.toggleExpanded);
const openTab = useWorkspace((s) => s.openTab);
const removeNodeById = useWorkspace((s) => s.removeNodeById);
const duplicateNodeById = useWorkspace((s) => s.duplicateNodeById);
const addRequest = useWorkspace((s) => s.addRequest);
const addFolder = useWorkspace((s) => s.addFolder);
const addCollection = useWorkspace((s) => s.addCollection);
const setDialog = useWorkspace((s) => s.setDialog);
const moveNodeTo = useWorkspace((s) => s.moveNodeTo);
const [dragOver, setDragOver] = useState(false);
const isFolder = node.type === "folder" || node.type === "collection";
const requestNode = node.type === "request" ? node : null;
const isOpen = isFolder && expanded[node.id];
const isActive = requestNode !== null && node.id === activeTabId;
const buildMenu = (): MenuItem[] => {
const items: MenuItem[] = [];
if (isCollection) {
items.push(
{ label: "新建请求", icon: <Icon name="plus" size={14} />, onClick: () => setDialog({ type: "new", collectionId, parentId: null }) },
{ label: "新建目录", icon: <Icon name="folder-plus" size={14} />, onClick: () => addFolder(collectionId, null) },
{ type: "sep" },
{ label: "重命名", icon: <Icon name="pencil" size={14} />, onClick: () => setDialog({ type: "rename", id: node.id }) },
{ label: "导出该集合…", icon: <Icon name="upload" size={14} />, onClick: () => setDialog({ type: "export", collectionId }) },
{ type: "sep" },
{ label: "新建集合", icon: <Icon name="library" size={14} />, onClick: () => addCollection() },
);
return items;
}
if (isFolder) {
items.push(
{ label: "新建请求", icon: <Icon name="plus" size={14} />, onClick: () => setDialog({ type: "new", collectionId, parentId: node.id }) },
{ label: "新建子目录", icon: <Icon name="folder-plus" size={14} />, onClick: () => addFolder(collectionId, node.id) },
{ type: "sep" },
{ label: "重命名", icon: "✎", onClick: () => setDialog({ type: "rename", id: node.id }) },
{
label: "复制目录",
icon: <Icon name="copy" size={14} />,
onClick: () => duplicateNodeById(node.id),
},
{ type: "sep" },
{ label: "删除目录", icon: <Icon name="trash" size={14} />, danger: true, onClick: () => removeNodeById(node.id) },
);
return items;
}
// request
if (node.kind === "http") {
items.unshift({ label: "打开", icon: <Icon name="play" size={14} />, onClick: () => openTab(node.id) });
items.push(
{ label: "重命名", icon: <Icon name="pencil" size={14} />, onClick: () => setDialog({ type: "rename", id: node.id }) },
{ label: "复制接口", icon: <Icon name="copy" size={14} />, onClick: () => duplicateNodeById(node.id) },
{ type: "sep" },
{ label: "删除", icon: <Icon name="trash" size={14} />, danger: true, onClick: () => removeNodeById(node.id) },
);
} else {
items.push(
{ label: "打开", icon: <Icon name="play" size={14} />, onClick: () => openTab(node.id) },
{ label: "重命名", icon: <Icon name="pencil" size={14} />, onClick: () => setDialog({ type: "rename", id: node.id }) },
{ label: "复制", icon: <Icon name="copy" size={14} />, onClick: () => duplicateNodeById(node.id) },
{ type: "sep" },
{ label: "删除", icon: <Icon name="trash" size={14} />, danger: true, onClick: () => removeNodeById(node.id) },
);
}
return items;
};
const label = <span className="truncate">{node.name}</span>;
return (
<div
className={cx(
"ops-tree-row",
isActive && "ops-tree-row-active",
dragOver && "ops-drop-target",
)}
style={{ paddingLeft: 8 + depth * 14 }}
draggable={!isCollection}
onDragStart={(e) => {
e.dataTransfer.setData("text/opsxapi-node", node.id);
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
if (!isFolder) return;
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
if (!isFolder) return;
const dragId = e.dataTransfer.getData("text/opsxapi-node");
if (dragId && dragId !== node.id) {
const parentId = isCollection ? null : node.id;
moveNodeTo(dragId, collectionId, parentId);
if (parentId) toggleExpanded(parentId, true);
}
}}
onClick={() => {
if (isFolder) toggleExpanded(node.id);
else openTab(node.id);
}}
onContextMenu={(e) => {
e.preventDefault();
onMenu({ x: e.clientX, y: e.clientY, items: buildMenu() });
}}
>
{isFolder ? (
<Icon name={isOpen ? "chevron-down" : "chevron-right"} size={12} className="ops-faint" />
) : (
<span className="w-3 shrink-0" />
)}
{node.type === "collection" ? (
<Logo size={14} className="shrink-0" />
) : node.type === "folder" ? (
<Icon name={isOpen ? "folder-open" : "folder"} size={13} className="shrink-0 ops-dim" />
) : requestNode && requestNode.kind === "http" ? (
<span className={`ops-method-badge ops-method-${requestNode.data.method}`} style={{ width: 46, textAlign: "center" }}>
{requestNode.data.method}
</span>
) : requestNode ? (
<Icon name={KIND_ICONS[requestNode.kind]} size={13} className="shrink-0 ops-dim" title={KIND_LABELS[requestNode.kind]} />
) : null}
<span className={cx("min-w-0 flex-1 truncate", isFolder && "font-medium")}>{label}</span>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
/**
* 请求标签页栏。
*/
import { KIND_LABELS, findNode } from "@opsxapi/core";
import { useState } from "react";
import { useWorkspace } from "../workspaceStore";
import { ContextMenu, cx, type MenuItem } from "./common";
const KIND_TAB_ICON: Record<string, string> = {
http: "",
graphql: "◈",
websocket: "⚡",
sse: "📡",
socketio: "🔌",
mqtt: "📮",
};
export function TabBar() {
const collections = useWorkspace((s) => s.collections);
const openTabIds = useWorkspace((s) => s.openTabIds);
const activeTabId = useWorkspace((s) => s.activeTabId);
const setActiveTab = useWorkspace((s) => s.setActiveTab);
const closeTab = useWorkspace((s) => s.closeTab);
const closeOtherTabs = useWorkspace((s) => s.closeOtherTabs);
const [menu, setMenu] = useState<{ x: number; y: number; id: string } | null>(null);
const nodes = openTabIds
.map((id) => findNode(collections, id)?.node)
.filter((n): n is NonNullable<typeof n> => Boolean(n));
const menuItems = (id: string): MenuItem[] => [
{ label: "关闭", onClick: () => closeTab(id) },
{ label: "关闭其他", onClick: () => closeOtherTabs(id) },
{
label: "关闭全部",
onClick: () => {
for (const n of nodes) closeTab(n.id);
},
},
];
return (
<div
className="ops-panel-2 flex min-h-[38px] shrink-0 items-stretch overflow-x-auto"
style={{ borderBottom: "1px solid var(--border)" }}
onContextMenu={(e) => {
if (e.target === e.currentTarget) e.preventDefault();
}}
>
{nodes.length === 0 && (
<div className="ops-faint flex items-center px-4 text-xs">没有打开的请求</div>
)}
{nodes.map((node) => {
const active = node.id === activeTabId;
const runtime = useWorkspace.getState().runtime[node.id];
const busy = runtime?.loading || runtime?.status === "connecting" || runtime?.status === "open";
return (
<div
key={node.id}
className={cx(
"group flex cursor-pointer items-center gap-2 whitespace-nowrap px-3.5 text-xs transition",
active ? "" : "ops-faint hover:opacity-100",
)}
style={
active
? { background: "var(--panel)", borderTop: "2px solid var(--accent)", color: "var(--text)" }
: { borderTop: "2px solid transparent" }
}
onClick={() => setActiveTab(node.id)}
onAuxClick={(e) => e.button === 1 && closeTab(node.id)}
onContextMenu={(e) => {
e.preventDefault();
setMenu({ x: e.clientX, y: e.clientY, id: node.id });
}}
title={node.name}
>
{node.type === "request" && node.kind === "http" ? (
<span className={`ops-method-${node.data.method} font-bold`}>{node.data.method}</span>
) : node.type === "request" ? (
<span title={KIND_LABELS[node.kind]}>{KIND_TAB_ICON[node.kind]}</span>
) : null}
<span className="max-w-48 truncate">{node.name}</span>
{busy && <span className="ops-dot ops-dot-connecting" />}
<button
className={cx(
"rounded px-1 text-[13px] leading-none opacity-40 transition group-hover:opacity-100",
)}
style={{ color: active ? "var(--text)" : "inherit" }}
onClick={(e) => {
e.stopPropagation();
closeTab(node.id);
}}
>
×
</button>
</div>
);
})}
<ContextMenu
menu={menu ? { x: menu.x, y: menu.y, items: menuItems(menu.id) } : null}
onClose={() => setMenu(null)}
/>
</div>
);
}
+215
View File
@@ -0,0 +1,215 @@
/**
* 顶栏:logo / 新建 / 导入导出 / 环境 / 主题 / 设置。
*/
import { detectTransport } from "@opsxapi/transport";
import { useEffect, useMemo, useState } from "react";
import { useWorkspace } from "../workspaceStore";
import { ContextMenu, cx, type MenuItem } from "./common";
import { EnvBadge } from "./EnvBadge";
import { Icon, type IconName } from "./icons";
import { Logo } from "./Logo";
const NEW_ITEMS: { kind: string; label: string; icon: IconName }[] = [
{ kind: "http", label: "HTTP 请求", icon: "globe" },
{ kind: "graphql", label: "GraphQL", icon: "hexagon" },
{ kind: "websocket", label: "WebSocket", icon: "zap" },
{ kind: "sse", label: "SSE", icon: "radio" },
{ kind: "socketio", label: "Socket.IO", icon: "plug-zap" },
{ kind: "mqtt", label: "MQTT", icon: "mailbox" },
];
export function TopBar() {
const collections = useWorkspace((s) => s.collections);
const environments = useWorkspace((s) => s.environments);
const activeEnvironmentId = useWorkspace((s) => s.activeEnvironmentId);
const settings = useWorkspace((s) => s.settings);
const setActiveEnvironment = useWorkspace((s) => s.setActiveEnvironment);
const setTheme = useWorkspace((s) => s.setTheme);
const setDialog = useWorkspace((s) => s.setDialog);
const addRequest = useWorkspace((s) => s.addRequest);
const addFolder = useWorkspace((s) => s.addFolder);
const [newMenu, setNewMenu] = useState<{ x: number; y: number } | null>(null);
const [exportMenu, setExportMenu] = useState<{ x: number; y: number } | null>(null);
const transportKind = useMemo(() => detectTransport({ ginBaseUrl: "" }).kind, []);
useEffect(() => {
document.title = "OpsxAPI · 接口工作台";
}, []);
const firstCollection = collections[0];
const quickCreate = (kind: string) => {
if (!firstCollection) return;
if (kind === "folder") addFolder(firstCollection.id, null);
else addRequest(firstCollection.id, null, kind as never);
};
const newItems: MenuItem[] = [
...NEW_ITEMS.map((i) => ({
label: i.label,
icon: <Icon name={i.icon} size={14} />,
onClick: () => quickCreate(i.kind),
})),
{ type: "sep" as const },
{ label: "新建目录", icon: <Icon name="folder-plus" size={14} />, onClick: () => quickCreate("folder") },
{ type: "sep" as const },
{ label: "选择位置新建…", icon: <Icon name="library" size={14} />, onClick: () => setDialog({ type: "new", collectionId: firstCollection?.id ?? "", parentId: null }) },
];
const activeEnv = environments.find((e) => e.id === activeEnvironmentId);
const [envMenu, setEnvMenu] = useState<{ x: number; y: number } | null>(null);
const envItems: MenuItem[] = [
...environments.map((env) => ({
label: (
<span className="flex min-w-0 items-center gap-2">
<EnvBadge envId={env.id} name={env.name} size={20} />
<span className="truncate">{env.name}</span>
{env.id === activeEnvironmentId ? (
<span className="ml-auto pl-3 text-[10px] ops-success">● 当前</span>
) : (
<span className="ops-faint ml-auto pl-3 text-[10px]">
{env.variables.filter((v) => v.enabled && v.key).length} 变量
</span>
)}
</span>
),
onClick: () => setActiveEnvironment(env.id),
})),
{ type: "sep" as const },
{
label: "不使用环境",
icon: <Icon name="circle-slash" size={14} />,
onClick: () => setActiveEnvironment(null),
},
{
label: "管理环境…",
icon: <Icon name="settings" size={14} />,
onClick: () => setDialog({ type: "environments" }),
},
];
return (
<header
className="ops-panel ops-divider flex h-12 shrink-0 items-center justify-between gap-3 px-4"
style={{ zIndex: 30 }}
>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Logo size={26} />
<span className="text-[14px] font-bold tracking-tight">OpsxAPI</span>
</div>
<span className="ops-chip" style={{ padding: "1px 8px", fontSize: 10 }}>
HTTP · GraphQL · WS · SSE · Socket.IO · MQTT
</span>
</div>
<div className="flex items-center gap-2">
<button
className="ops-btn ops-btn-primary"
onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setNewMenu({ x: rect.left, y: rect.bottom + 4 });
}}
disabled={!firstCollection}
>
<Icon name="plus" size={14} /> 新建
</button>
<button className="ops-btn ops-btn-secondary" onClick={() => setDialog({ type: "import" })}>
<Icon name="download" size={14} /> 导入
</button>
<button
className="ops-btn ops-btn-secondary"
onClick={(e) => {
if (collections.length === 1) {
setDialog({ type: "export", collectionId: collections[0].id });
return;
}
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setExportMenu({ x: rect.left, y: rect.bottom + 4 });
}}
disabled={collections.length === 0}
>
<Icon name="upload" size={14} /> 导出
</button>
<div className="mx-1 h-5 w-px" style={{ background: "var(--border)" }} />
<button
className="ops-envswitch"
onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setEnvMenu((m) => (m ? null : { x: rect.left, y: rect.bottom + 4 }));
}}
title="切换环境"
>
{activeEnv ? (
<>
<EnvBadge envId={activeEnv.id} name={activeEnv.name} size={18} />
<span className="max-w-28 truncate">{activeEnv.name}</span>
<span className="ops-faint text-[10px]">
{activeEnv.variables.filter((v) => v.enabled && v.key).length} 变量
</span>
</>
) : (
<span className="ops-faint">不使用环境</span>
)}
<Icon name="chevron-down" size={13} className="ops-faint" />
</button>
<button className="ops-btn ops-btn-ghost" onClick={() => setDialog({ type: "environments" })} title="环境管理">
<Icon name="settings" size={14} /> 环境
</button>
<div className="mx-1 h-5 w-px" style={{ background: "var(--border)" }} />
<button
className="ops-btn ops-btn-ghost"
onClick={() => setTheme(settings.theme === "dark" ? "light" : "dark")}
title="切换主题"
>
<Icon name={settings.theme === "dark" ? "moon" : "sun"} size={15} />
</button>
<a
className="ops-btn ops-btn-ghost"
href="/docs"
target="_blank"
rel="noopener noreferrer"
title="用户文档"
>
<Icon name="book-open" size={14} /> 文档
</a>
<button className="ops-btn ops-btn-ghost" onClick={() => setDialog({ type: "settings" })} title="设置">
<Icon name="ellipsis" size={16} />
</button>
<span className={cx("ops-chip")} style={{ padding: "1px 8px", fontSize: 10 }} title="请求通道">
{transportKind}
</span>
</div>
<ContextMenu
menu={newMenu ? { x: newMenu.x, y: newMenu.y, items: newItems } : null}
onClose={() => setNewMenu(null)}
/>
<ContextMenu
menu={envMenu ? { x: envMenu.x, y: envMenu.y, items: envItems } : null}
onClose={() => setEnvMenu(null)}
/>
<ContextMenu
menu={
exportMenu
? {
x: exportMenu.x,
y: exportMenu.y,
items: collections.map((c) => ({
label: c.name,
onClick: () => setDialog({ type: "export", collectionId: c.id }),
})),
}
: null
}
onClose={() => setExportMenu(null)}
/>
</header>
);
}
+179
View File
@@ -0,0 +1,179 @@
/**
* 带环境变量自动补全的输入框:
* 输入 "{{" 后弹出当前环境变量浮层,↑↓ 选择、Enter/Tab 插入 {{变量名}}。
*/
import { useWorkspace } from "../workspaceStore";
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { EnvBadge } from "./EnvBadge";
type Candidate = { name: string; value: string; description?: string };
export function VarHintInput({
value,
onChange,
placeholder,
className,
onKeyDown,
autoFocus,
spellCheck = false,
ariaLabel,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
className?: string;
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
autoFocus?: boolean;
spellCheck?: boolean;
ariaLabel?: string;
}) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [query, setQuery] = useState<{ prefix: string; caret: number } | null>(null);
const [active, setActive] = useState(0);
const candidates = useMemo<Candidate[]>(() => {
const state = useWorkspace.getState();
const env = state.environments.find((e) => e.id === state.activeEnvironmentId);
return (env?.variables ?? [])
.filter((v) => v.enabled && v.key.trim())
.map((v) => ({ name: v.key.trim(), value: v.value, description: v.description }));
}, [query]); // query 变化时重查(环境可能被改)
const filtered = useMemo(() => {
if (!query) return [];
const q = query.prefix.toLowerCase();
const list = candidates.filter((c) => c.name.toLowerCase().includes(q));
return list.slice(0, 8);
}, [candidates, query]);
// 光标前未闭合的 {{ 即触发
const detect = (v: string, caret: number) => {
const before = v.slice(0, caret);
const idx = before.lastIndexOf("{{");
if (idx < 0) return null;
const frag = before.slice(idx + 2);
if (frag.includes("}") || frag.includes("{") || frag.includes(" ") || frag.length > 40) return null;
return { prefix: frag, caret };
};
const sync = () => {
const el = inputRef.current;
if (!el) return;
const q = detect(el.value, el.selectionStart ?? 0);
setQuery(q);
setActive(0);
};
const insert = (name: string) => {
const el = inputRef.current;
if (!el || !query) return;
const idx = el.value.slice(0, query.caret).lastIndexOf("{{");
if (idx < 0) return;
const next = `${el.value.slice(0, idx)}{{${name}}}` + el.value.slice(query.caret);
onChange(next);
setQuery(null);
const caretTarget = idx + name.length + 4;
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(caretTarget, caretTarget);
});
};
// 过滤结果为空时收起
useEffect(() => {
if (query && filtered.length === 0) setQuery(null);
}, [filtered.length, query]);
// 外部清空时重置
useLayoutEffect(() => {
if (!value) setQuery(null);
}, [value]);
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (query && filtered.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
e.stopPropagation();
setActive((a) => Math.min(a + 1, filtered.length - 1));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
e.stopPropagation();
setActive((a) => Math.max(a - 1, 0));
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
e.stopPropagation();
insert(filtered[active].name);
return;
}
if (e.key === "Escape") {
e.stopPropagation();
setQuery(null);
return;
}
}
onKeyDown?.(e);
};
const envName = useWorkspace(
(s) => s.environments.find((e) => e.id === s.activeEnvironmentId)?.name ?? "",
);
const envId = useWorkspace((s) => s.activeEnvironmentId);
return (
<div className="var-hint-wrap">
<input
ref={inputRef}
className={className}
placeholder={placeholder}
value={value}
aria-label={ariaLabel}
spellCheck={spellCheck}
autoFocus={autoFocus}
onChange={(e) => {
const el = e.currentTarget;
onChange(el.value);
// 同步阶段探测(input 事件时 DOM 已更新,避免 React 并发提交的竞态)
setQuery(detect(el.value, el.selectionStart ?? el.value.length));
setActive(0);
}}
onKeyUp={sync}
onClick={sync}
onBlur={() => setTimeout(() => setQuery(null), 120)}
onKeyDown={handleKeyDown}
/>
{query && filtered.length > 0 && (
<div className="var-hint-panel" onMouseDown={(e) => e.preventDefault()}>
<div className="var-hint-head">
{envId ? (
<>
<EnvBadge envId={envId} name={envName} size={16} />
<span>{envName || "当前环境"}</span>
</>
) : (
<span>未选择环境</span>
)}
<span className="ops-faint">{"输入 {{变量名}} 引用 · Enter 插入"}</span>
</div>
{filtered.map((c, i) => (
<button
key={c.name}
className={i === active ? "var-hint-item sel" : "var-hint-item"}
onMouseEnter={() => setActive(i)}
onMouseDown={(e) => {
e.preventDefault();
insert(c.name);
}}
>
<span className="var-hint-name">{"{{"}{c.name}{"}}"}</span>
<span className="var-hint-value">{c.value || "(空)"}</span>
</button>
))}
</div>
)}
</div>
);
}
+357
View File
@@ -0,0 +1,357 @@
/**
* 基础组件:Modal / 右键菜单 / JSON 高亮 / KV 编辑器 / 复制按钮。
*/
import type { KVRow } from "@opsxapi/core";
import { kv } from "@opsxapi/core";
import { Icon } from "./icons";
import { VarHintInput } from "./VarHintInput";
import { useEffect, useRef, useState, type ReactNode } from "react";
export function cx(...xs: Array<string | false | null | undefined>): string {
return xs.filter(Boolean).join(" ");
}
/* ---------------- Modal ---------------- */
export function Modal({
title,
onClose,
children,
footer,
width,
}: {
title: ReactNode;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
width?: string;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div className="ops-modal-mask" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<div className="ops-modal" style={width ? { maxWidth: width } : undefined}>
<div className="ops-modal-header">
<div className="ops-modal-title">{title}</div>
<button className="ops-btn ops-btn-ghost px-2" onClick={onClose} aria-label="关闭">
<Icon name="x" size={15} />
</button>
</div>
<div className="ops-modal-body">{children}</div>
{footer && <div className="ops-modal-footer">{footer}</div>}
</div>
</div>
);
}
/* ---------------- 右键菜单 ---------------- */
export type MenuItem =
| { type: "sep" }
| {
type?: "item";
label: ReactNode;
icon?: ReactNode;
danger?: boolean;
onClick: () => void;
};
export function ContextMenu({
menu,
onClose,
}: {
menu: { x: number; y: number; items: MenuItem[] } | null;
onClose: () => void;
}) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!menu) return;
const close = () => onClose();
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("click", close);
window.addEventListener("resize", close);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("resize", close);
window.removeEventListener("keydown", onKey);
};
}, [menu, onClose]);
if (!menu) return null;
const style: React.CSSProperties = {
left: Math.min(menu.x, window.innerWidth - 210),
top: Math.min(menu.y, window.innerHeight - menu.items.length * 30 - 20),
};
return (
<div className="ops-menu" style={style} ref={ref} onClick={(e) => e.stopPropagation()}>
{menu.items.map((item, i) =>
"type" in item && item.type === "sep" ? (
<div key={i} className="ops-menu-sep" />
) : (
<button
key={i}
className={cx("ops-menu-item", item.danger && "ops-menu-item-danger", "ops-menu-item-flex")}
onClick={() => {
item.onClick();
onClose();
}}
>
{item.icon && <span className="w-4 text-center">{item.icon}</span>}
{item.label}
</button>
),
)}
</div>
);
}
/* ---------------- JSON 高亮 ---------------- */
export function JsonView({ text, className }: { text: string; className?: string }) {
let value: unknown;
try {
value = JSON.parse(text);
} catch {
return <span className={className}>{text}</span>;
}
return <pre className={cx("ops-code ops-code-no-bg m-0", className)}>{renderJson(value, 0)}</pre>;
}
function renderJson(value: unknown, indent: number, key?: string): ReactNode {
const pad = " ".repeat(indent);
const padEnd = " ".repeat(Math.max(0, indent - 1));
const keyPart = key !== undefined ? <span className="ops-json-key">{JSON.stringify(key)}: </span> : null;
if (value === null) {
return (
<div key={key}>
{pad}
{keyPart}
<span className="ops-json-null">null</span>
</div>
);
}
if (typeof value === "string") {
return (
<div key={key}>
{pad}
{keyPart}
<span className="ops-json-str">{JSON.stringify(value)}</span>
</div>
);
}
if (typeof value === "number") {
return (
<div key={key}>
{pad}
{keyPart}
<span className="ops-json-num">{String(value)}</span>
</div>
);
}
if (typeof value === "boolean") {
return (
<div key={key}>
{pad}
{keyPart}
<span className="ops-json-bool">{String(value)}</span>
</div>
);
}
if (Array.isArray(value)) {
if (value.length === 0) {
return (
<div key={key}>
{pad}
{keyPart}[]
</div>
);
}
return (
<div key={key}>
{pad}
{keyPart}<span>{"["}</span>
{value.map((v, i) => (
<div key={i}>{renderJson(v, indent + 1)}</div>
))}
{padEnd}<span>]</span>
</div>
);
}
// object
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) {
return (
<div key={key}>
{pad}
{keyPart}{"{}"}
</div>
);
}
return (
<div key={key}>
{pad}
{keyPart}<span>{"{"}</span>
{entries.map(([k, v]) => (
<div key={k}>{renderJson(v, indent + 1, k)}</div>
))}
{padEnd}<span>{"}"}</span>
</div>
);
}
/* ---------------- 复制按钮 ---------------- */
export function CopyButton({ text, label = "复制" }: { text: string; label?: string }) {
const [done, setDone] = useState(false);
return (
<button
className="ops-btn ops-btn-secondary"
onClick={async () => {
try {
await navigator.clipboard.writeText(text);
setDone(true);
setTimeout(() => setDone(false), 1200);
} catch {
/* ignore */
}
}}
>
{done ? "已复制 ✓" : (<><Icon name="copy" size={13} /> {label}</>)}
</button>
);
}
/* ---------------- KV 编辑器 ---------------- */
export function KVEditor({
rows,
onChange,
onAdd,
onUpdate,
onRemove,
keyPlaceholder = "Key",
valuePlaceholder = "Value",
withDescription = false,
compact = false,
}: {
rows: KVRow[];
onChange?: (rows: KVRow[]) => void;
onAdd?: () => void;
onUpdate?: (rowId: string, patch: Partial<KVRow>) => void;
onRemove?: (rowId: string) => void;
keyPlaceholder?: string;
valuePlaceholder?: string;
withDescription?: boolean;
compact?: boolean;
}) {
const update = (id: string, patch: Partial<KVRow>) => {
if (onUpdate) onUpdate(id, patch);
else onChange?.(rows.map((r) => (r.id === id ? { ...r, ...patch } : r)));
};
const remove = (id: string) => {
if (onRemove) onRemove(id);
else onChange?.(rows.filter((r) => r.id !== id));
};
const add = () => {
if (onAdd) onAdd();
else onChange?.([...rows, kv()]);
};
const enabledCount = rows.filter((r) => r.enabled && r.key.trim()).length;
return (
<div className="space-y-1.5">
{!compact && rows.length > 0 && (
<div className="ops-faint mb-1 text-[11px]">
共 {rows.length} 行 · 启用 {enabledCount} 行
</div>
)}
{rows.map((r) => (
<div
key={r.id}
className={cx(
"grid items-center gap-2",
withDescription
? "grid-cols-[22px_minmax(0,1fr)_minmax(0,1.3fr)_minmax(0,1fr)_26px]"
: "grid-cols-[22px_minmax(0,1fr)_minmax(0,1.4fr)_26px]",
)}
>
<input
type="checkbox"
checked={r.enabled}
onChange={(e) => update(r.id, { enabled: e.target.checked })}
className="mx-auto accent-[var(--accent)]"
aria-label="启用"
/>
<input
className="ops-input ops-input-sm font-mono"
placeholder={keyPlaceholder}
value={r.key}
onChange={(e) => update(r.id, { key: e.target.value })}
/>
<VarHintInput
className="ops-input ops-input-sm font-mono"
placeholder={valuePlaceholder}
value={r.value}
onChange={(v) => update(r.id, { value: v })}
/>
{withDescription && (
<input
className="ops-input ops-input-sm"
placeholder="说明"
value={r.description ?? ""}
onChange={(e) => update(r.id, { description: e.target.value })}
/>
)}
<button
className="ops-faint hover:ops-danger grid place-items-center px-1"
onClick={() => remove(r.id)}
title="删除"
>
<Icon name="x" size={13} />
</button>
</div>
))}
<button className="ops-btn ops-btn-ghost px-2 py-1" onClick={add}>
<Icon name="plus" size={13} /> 添加
</button>
</div>
);
}
/* ---------------- 状态点 ---------------- */
export function StatusDot({ status }: { status: string }) {
const cls =
status === "open"
? "ops-dot-open"
: status === "connecting" || status === "reconnecting"
? "ops-dot-connecting"
: status === "error"
? "ops-dot-error"
: "ops-dot-closed";
return <span className={cx("ops-dot", cls)} />;
}
export function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
export function formatTime(at: number): string {
const d = new Date(at);
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(
d.getSeconds(),
).padStart(2, "0")}`;
}
@@ -0,0 +1,134 @@
/**
* 导出对话框:OpenAPI JSON/YAML、Postman、Markdown、全量备份。
*/
import { countRequests, exportBackup, exportCollectionAs, type ExportFormat } from "@opsxapi/core";
import { useMemo, useState } from "react";
import { useWorkspace } from "../../workspaceStore";
import { CopyButton, Modal, cx } from "../common";
import { Icon } from "../icons";
const FORMATS: { key: ExportFormat; label: string; ext: string; hint: string }[] = [
{ key: "openapi-json", label: "OpenAPI 3.0 · JSON", ext: "json", hint: "标准 OpenAPI 规范,可回导 Apifox/Swagger" },
{ key: "openapi-yaml", label: "OpenAPI 3.0 · YAML", ext: "yaml", hint: "标准 OpenAPI 规范(YAML)" },
{ key: "postman", label: "Postman v2.1", ext: "json", hint: "Postman Collection,可直接导入 Postman" },
{ key: "markdown", label: "Markdown 接口文档", ext: "md", hint: "带目录的接口文档,可放 Wiki" },
];
export function ExportDialog({ collectionId }: { collectionId: string }) {
const collections = useWorkspace((s) => s.collections);
const environments = useWorkspace((s) => s.environments);
const setDialog = useWorkspace((s) => s.setDialog);
const [selectedCollection, setSelectedCollection] = useState(collectionId);
const [backupMode, setBackupMode] = useState(collections.length === 0);
const [format, setFormat] = useState<ExportFormat>("openapi-json");
const collection = collections.find((c) => c.id === selectedCollection) ?? collections[0];
const { content, filename } = useMemo(() => {
if (!collection) return { content: "", filename: "" };
const meta = FORMATS.find((f) => f.key === format) ?? FORMATS[0];
const safeName = collection.name.replace(/[\\/:*?"<>|\s]+/g, "-");
try {
return {
content: exportCollectionAs(format, collection),
filename: `${safeName}.${meta.ext}`,
};
} catch (e) {
return { content: `导出失败:${e instanceof Error ? e.message : String(e)}`, filename: "error.txt" };
}
}, [collection, format]);
const backupContent = useMemo(
() => exportBackup(collections, environments),
[collections, environments],
);
const finalContent = backupMode ? backupContent : content;
const finalFilename = backupMode
? `opsxapi-backup-${new Date().toISOString().slice(0, 10)}.json`
: filename;
const download = () => {
const blob = new Blob([finalContent], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = finalFilename;
a.click();
URL.revokeObjectURL(url);
};
return (
<Modal
title="导出数据"
onClose={() => setDialog(null)}
width="720px"
footer={
<>
<button className="ops-btn ops-btn-secondary" onClick={() => setDialog(null)}>
关闭
</button>
<CopyButton text={finalContent} label="复制到剪贴板" />
<button className="ops-btn ops-btn-primary" onClick={download}>
<Icon name="download" size={13} /> 下载 {finalFilename}
</button>
</>
}
>
<div className="space-y-4">
<label className="flex cursor-pointer items-center gap-2 text-[13px]">
<input
type="checkbox"
checked={backupMode}
onChange={(e) => setBackupMode(e.target.checked)}
className="accent-[var(--accent)]"
/>
全量备份(全部集合 + 环境,用于迁移 / 恢复)
</label>
{!backupMode && (
<>
{collections.length > 1 && (
<div className="flex items-center gap-2">
<span className="ops-label mb-0">集合</span>
<select
className="ops-input ops-input-sm w-64"
value={selectedCollection}
onChange={(e) => setSelectedCollection(e.target.value)}
>
{collections.map((c) => (
<option key={c.id} value={c.id}>
{c.name}({countRequests(c)} 接口)
</option>
))}
</select>
</div>
)}
<div className="grid grid-cols-2 gap-2">
{FORMATS.map((f) => (
<button
key={f.key}
className={cx(
"rounded-xl border p-3 text-left transition",
format === f.key ? "ops-chip-active" : "ops-faint hover:opacity-80",
)}
style={{ borderColor: format === f.key ? "var(--accent)" : "var(--border)" }}
onClick={() => setFormat(f.key)}
>
<div className="text-[13px] font-semibold">{f.label}</div>
<div className="mt-0.5 text-[11px] opacity-70">{f.hint}</div>
</button>
))}
</div>
</>
)}
<pre className="ops-code max-h-64 overflow-auto whitespace-pre-wrap text-[11px]">
{finalContent.slice(0, 4000)}
{finalContent.length > 4000 ? "\n…(预览截断,导出为完整内容)" : ""}
</pre>
</div>
</Modal>
);
}
@@ -0,0 +1,288 @@
/**
* 导入对话框:粘贴 / 文件 / URL(Swagger URL)三种来源 + 格式自动识别。
*/
import type { Collection } from "@opsxapi/core";
import {
FORMAT_LABELS,
detectImportFormat,
importByFormat,
countRequests,
type ImportFormat,
} from "@opsxapi/core";
import { useMemo, useRef, useState } from "react";
import { useWorkspace } from "../../workspaceStore";
import { Modal, cx } from "../common";
import { Icon } from "../icons";
type Source = "paste" | "file" | "url";
const ALL_FORMATS: (ImportFormat | "auto")[] = [
"auto",
"openapi",
"postman",
"apipost",
"curl",
"har",
"insomnia",
"hoppscotch",
"markdown",
"backup",
];
type ImportSummary = {
collections: Collection[];
requestCount: number;
envVarCount: number;
warnings: string[];
};
export function ImportDialog() {
const setDialog = useWorkspace((s) => s.setDialog);
const importCollections = useWorkspace((s) => s.importCollections);
const [source, setSource] = useState<Source>("paste");
const [text, setText] = useState("");
const [url, setUrl] = useState("");
const [format, setFormat] = useState<ImportFormat | "auto">("auto");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [summary, setSummary] = useState<ImportSummary | null>(null);
const fileRef = useRef<HTMLInputElement | null>(null);
const detected = useMemo(() => (text.trim() ? detectImportFormat(text) : null), [text]);
const effectiveFormat = (): ImportFormat => {
if (format !== "auto") return format;
if (!text.trim()) throw new Error("请先粘贴内容 / 选择文件 / 填写 URL");
const d = detectImportFormat(text);
if (d === "unknown") throw new Error("无法识别格式,请手动选择格式");
return d;
};
const doImport = (raw?: string) => {
setError(null);
setBusy(true);
try {
const content = raw ?? text;
if (!content.trim()) throw new Error("内容为空");
const fmt = effectiveFormat();
const result = importByFormat(content, fmt);
const requestCount = result.collections.reduce((acc, c) => acc + countRequests(c), 0);
importCollections(result.collections, result.environmentVariables);
setSummary({
collections: result.collections,
requestCount,
envVarCount: result.environmentVariables.length,
warnings: result.warnings,
});
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const loadFile = (file: File) => {
const reader = new FileReader();
reader.onload = () => {
const content = String(reader.result ?? "");
setText(content);
setSource("paste");
setTimeout(() => doImport(content), 30);
};
reader.readAsText(file);
};
const loadUrl = async () => {
setError(null);
setBusy(true);
try {
if (!url.trim()) throw new Error("请填写 URL");
// 经 Gin 代理拉取,避开 CORS
const res = await fetch("/v1/proxy/http", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
method: "GET",
url: url.trim(),
headers: { Accept: "application/json, text/yaml, text/plain, */*" },
body: { mode: "none" },
}),
});
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(`拉取失败 ${res.status}:${t.slice(0, 160)}`);
}
const payload = (await res.json()) as { body?: string; status?: number };
if (payload.status && payload.status >= 400) {
throw new Error(`目标返回 HTTP ${payload.status}`);
}
const content = payload.body ?? "";
if (!content.trim()) throw new Error("URL 返回内容为空");
setText(content);
setSource("paste");
doImport(content);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
return (
<Modal
title="导入数据"
onClose={() => setDialog(null)}
width="680px"
footer={
summary ? (
<button className="ops-btn ops-btn-primary" onClick={() => setDialog(null)}>
完成
</button>
) : (
<>
<button className="ops-btn ops-btn-secondary" onClick={() => setDialog(null)}>
取消
</button>
<button
className="ops-btn ops-btn-primary"
disabled={busy}
onClick={() => (source === "url" ? void loadUrl() : doImport())}
>
<Icon name="download" size={13} /> {busy ? "导入中…" : "导入"}
</button>
</>
)
}
>
{summary ? (
<div className="space-y-3">
<div className="ops-success text-sm font-semibold">导入成功 ✓</div>
<div className="space-y-1 text-[13px]">
{summary.collections.map((c) => (
<div key={c.id} className="flex items-center gap-2">
<span>📚</span>
<b>{c.name}</b>
<span className="ops-dim">· {countRequests(c)} 个接口</span>
</div>
))}
</div>
{summary.envVarCount > 0 && (
<div className="ops-dim text-xs">
已向当前环境追加 {summary.envVarCount} 个变量(如 baseUrl / 路径参数)
</div>
)}
{summary.warnings.length > 0 && (
<div className="ops-msg ops-msg-sys">
{summary.warnings.map((w, i) => (
<div key={i}>⚠ {w}</div>
))}
</div>
)}
</div>
) : (
<div className="space-y-4">
{/* 来源切换 */}
<div className="flex gap-1.5">
{(
[
["paste", "粘贴文本 / cURL"],
["file", "文件导入"],
["url", "URL 导入(Swagger)"],
] as [Source, string][]
).map(([key, label]) => (
<button
key={key}
className={cx("ops-chip", source === key && "ops-chip-active")}
onClick={() => setSource(key)}
>
{label}
</button>
))}
</div>
{source === "paste" && (
<textarea
className="ops-code min-h-52 w-full resize-y font-mono"
placeholder={
"粘贴 OpenAPI/Swagger(JSON/YAML)、Postman、Apifox、Apipost、HAR、Insomnia、Hoppscotch、Markdown 或 cURL 命令…"
}
value={text}
onChange={(e) => setText(e.target.value)}
spellCheck={false}
/>
)}
{source === "file" && (
<div
className="grid cursor-pointer place-items-center rounded-xl border-2 border-dashed py-12 text-center transition"
style={{ borderColor: "var(--border)" }}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) loadFile(file);
}}
onClick={() => fileRef.current?.click()}
>
<div className="ops-dim mb-2 flex justify-center"><Icon name="file-plus" size={30} strokeWidth={1.5} /></div>
<div className="ops-dim text-sm">点击选择或拖入文件</div>
<div className="ops-faint mt-1 text-[11px]">.json / .yaml / .yml / .har / .md / .txt</div>
<input
ref={fileRef}
type="file"
className="hidden"
accept=".json,.yaml,.yml,.har,.md,.txt,.log"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) loadFile(file);
}}
/>
</div>
)}
{source === "url" && (
<div className="space-y-2">
<input
className="ops-input font-mono"
placeholder="https://petstore3.swagger.io/api/v3/openapi.json"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && void loadUrl()}
spellCheck={false}
/>
<div className="ops-faint text-[11px] leading-5">
经本地 Gin 代理拉取(避开浏览器 CORS),支持 Swagger/OpenAPI JSON 或 YAML 地址。
</div>
</div>
)}
{/* 格式 */}
<div className="flex flex-wrap items-center gap-2">
<span className="ops-label mb-0">格式</span>
<select className="ops-input ops-input-sm w-56" value={format} onChange={(e) => setFormat(e.target.value as ImportFormat | "auto")}>
{ALL_FORMATS.map((f) => (
<option key={f} value={f}>
{f === "auto" ? "自动识别" : FORMAT_LABELS[f]}
</option>
))}
</select>
{detected && format === "auto" && (
<span className={cx("ops-chip", detected !== "unknown" && "ops-chip-active")}>
{detected === "unknown" ? "未识别" : `已识别:${FORMAT_LABELS[detected]}`}
</span>
)}
</div>
{error && <div className="ops-msg ops-msg-err !font-sans text-[13px]">{error}</div>}
<div className="ops-faint text-[11px] leading-5">
支持:OpenAPI 3.0/3.1、Swagger 2.0(JSON/YAML/URL)、Postman v2.0/2.1、Apifox 导出、
Apipost 项目、YApi / Apidoc / Apizza / Eolink(请先导出为 OpenAPI 或 Postman 格式)、
cURL、HAR 抓包、Insomnia、Hoppscotch、Markdown 接口文档、OpsxAPI 备份。
</div>
</div>
)}
</Modal>
);
}
@@ -0,0 +1,432 @@
/**
* 工作区对话框:新建项 / 重命名 / 环境管理 / 设置。
*/
import type { RequestKind } from "@opsxapi/core";
import { KIND_LABELS, walk } from "@opsxapi/core";
import { useState } from "react";
import { useWorkspace } from "../../workspaceStore";
import { KVEditor, Modal, cx } from "../common";
import { EnvBadge } from "../EnvBadge";
import { Icon, type IconName } from "../icons";
const KIND_MENU: { kind: RequestKind | "folder"; icon: IconName; desc: string }[] = [
{ kind: "http", icon: "globe", desc: "REST 接口调试" },
{ kind: "graphql", icon: "hexagon", desc: "Query + Variables" },
{ kind: "websocket", icon: "zap", desc: "双向实时通信" },
{ kind: "sse", icon: "radio", desc: "服务端推送事件流" },
{ kind: "socketio", icon: "plug-zap", desc: "Socket.IO 事件收发" },
{ kind: "mqtt", icon: "mailbox", desc: "IoT 消息订阅发布" },
{ kind: "folder", icon: "folder-plus", desc: "接口分组目录" },
];
export function NewItemDialog({
collectionId,
parentId,
}: {
collectionId: string;
parentId: string | null;
}) {
const collections = useWorkspace((s) => s.collections);
const addRequest = useWorkspace((s) => s.addRequest);
const addFolder = useWorkspace((s) => s.addFolder);
const setDialog = useWorkspace((s) => s.setDialog);
const [kind, setKind] = useState<RequestKind | "folder">("http");
const [name, setName] = useState("");
const [targetCollection, setTargetCollection] = useState(collectionId || (collections[0]?.id ?? ""));
const [targetParent, setTargetParent] = useState<string | null>(parentId);
const folderOptions: { id: string | null; label: string }[] = [{ id: null, label: "(集合根目录)" }];
const collection = collections.find((c) => c.id === targetCollection);
if (collection) {
walk(collection.children, (node) => {
if (node.type === "folder") {
folderOptions.push({ id: node.id, label: ` `.repeat(0) + node.name });
}
});
}
const create = () => {
if (kind === "folder") addFolder(targetCollection, targetParent, name || undefined);
else addRequest(targetCollection, targetParent, kind, name || undefined);
setDialog(null);
};
return (
<Modal
title="新建"
onClose={() => setDialog(null)}
footer={
<>
<button className="ops-btn ops-btn-secondary" onClick={() => setDialog(null)}>
取消
</button>
<button className="ops-btn ops-btn-primary" onClick={create}>
创建
</button>
</>
}
>
<div className="space-y-4">
<div className="grid grid-cols-4 gap-2">
{KIND_MENU.map((k) => (
<button
key={k.kind}
className={cx(
"rounded-xl border p-3 text-center transition",
kind === k.kind ? "ops-chip-active" : "hover:opacity-80",
)}
style={{ borderColor: kind === k.kind ? "var(--accent)" : "var(--border)" }}
onClick={() => setKind(k.kind)}
>
<div className="text-xl ops-dim"><Icon name={k.icon} size={22} /></div>
<div className="mt-1 text-xs font-semibold">
{k.kind === "folder" ? "目录" : KIND_LABELS[k.kind]}
</div>
<div className="ops-faint mt-0.5 text-[10px]">{k.desc}</div>
</button>
))}
</div>
<div>
<label className="ops-label">名称</label>
<input
className="ops-input"
placeholder={kind === "folder" ? "新建目录" : `新建 ${KIND_LABELS[kind as RequestKind]} 接口`}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && create()}
autoFocus
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="ops-label">目标集合</label>
<select
className="ops-input"
value={targetCollection}
onChange={(e) => {
setTargetCollection(e.target.value);
setTargetParent(null);
}}
>
{collections.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="ops-label">目标目录</label>
<select
className="ops-input"
value={targetParent ?? ""}
onChange={(e) => setTargetParent(e.target.value || null)}
>
{folderOptions.map((f) => (
<option key={f.id ?? "root"} value={f.id ?? ""}>
{f.label}
</option>
))}
</select>
</div>
</div>
</div>
</Modal>
);
}
export function RenameDialog({ id }: { id: string }) {
const collections = useWorkspace((s) => s.collections);
const renameNode = useWorkspace((s) => s.renameNode);
const updateEnvironment = useWorkspace((s) => s.updateEnvironment);
const setDialog = useWorkspace((s) => s.setDialog);
const isCollection = collections.some((c) => c.id === id);
const isEnv = useWorkspace((s) => s.environments.some((e) => e.id === id));
const [name, setName] = useState(() => resolveName(id, isEnv));
const submit = () => {
if (isEnv) {
updateEnvironment(id, { name: name.trim() || "未命名环境" });
} else {
renameNode(id, name.trim() || "未命名");
}
setDialog(null);
};
void isCollection;
return (
<Modal
title="重命名"
onClose={() => setDialog(null)}
footer={
<>
<button className="ops-btn ops-btn-secondary" onClick={() => setDialog(null)}>
取消
</button>
<button className="ops-btn ops-btn-primary" onClick={submit}>
保存
</button>
</>
}
>
<input
className="ops-input"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
autoFocus
/>
</Modal>
);
}
function resolveName(id: string, isEnv: boolean): string {
const state = useWorkspace.getState();
if (isEnv) return state.environments.find((e) => e.id === id)?.name ?? "";
const collection = state.collections.find((c) => c.id === id);
if (collection) return collection.name;
let name = "";
for (const c of state.collections) {
walk(c.children, (n) => {
if (n.id === id) name = n.name;
});
}
return name;
}
export function EnvironmentsDialog() {
const environments = useWorkspace((s) => s.environments);
const activeEnvironmentId = useWorkspace((s) => s.activeEnvironmentId);
const setActiveEnvironment = useWorkspace((s) => s.setActiveEnvironment);
const addEnvironment = useWorkspace((s) => s.addEnvironment);
const duplicateEnvironment = useWorkspace((s) => s.duplicateEnvironment);
const removeEnvironment = useWorkspace((s) => s.removeEnvironment);
const updateEnvironment = useWorkspace((s) => s.updateEnvironment);
const addEnvVariable = useWorkspace((s) => s.addEnvVariable);
const updateEnvVariable = useWorkspace((s) => s.updateEnvVariable);
const removeEnvVariable = useWorkspace((s) => s.removeEnvVariable);
const setDialog = useWorkspace((s) => s.setDialog);
const [selected, setSelected] = useState(activeEnvironmentId ?? environments[0]?.id ?? "");
const [search, setSearch] = useState("");
const env = environments.find((e) => e.id === selected);
const isActive = env?.id === activeEnvironmentId;
const filteredVars = (env?.variables ?? []).filter((v) => {
const q = search.trim().toLowerCase();
if (!q) return true;
return v.key.toLowerCase().includes(q) || (v.description ?? "").toLowerCase().includes(q);
});
return (
<Modal
title="环境管理"
onClose={() => setDialog(null)}
width="780px"
footer={
<div className="flex w-full items-center justify-between">
<span className="ops-faint text-[11px]">
修改即时生效并自动保存;请求中 {"{{变量名}}"} 将替换为当前启用环境的值
</span>
<button className="ops-btn ops-btn-primary" onClick={() => setDialog(null)}>
完成
</button>
</div>
}
>
<div className="env-dialog">
{/* 左栏:环境列表 */}
<aside className="env-dialog-side">
<div className="env-dialog-group">环境</div>
{environments.map((e) => (
<button
key={e.id}
className={cx("env-dialog-item", e.id === selected && "active")}
onClick={() => setSelected(e.id)}
>
<EnvBadge envId={e.id} name={e.name} size={22} />
<span className="min-w-0 flex-1 truncate text-left">{e.name}</span>
{e.id === activeEnvironmentId && <span className="env-dialog-current">当前</span>}
</button>
))}
{environments.length === 0 && (
<div className="ops-faint px-2 py-4 text-center text-xs">还没有环境</div>
)}
<button
className="env-dialog-new"
onClick={() => {
const id = addEnvironment();
setSelected(id);
}}
>
<Icon name="plus" size={13} /> 新建环境
</button>
</aside>
{/* 右栏:编辑区 */}
<div className="env-dialog-main">
{env ? (
<>
<div className="ops-hint">
<Icon name="info" size={14} />
<span>
在 URL、参数、Header、Body、鉴权中输入 <b>{"{{变量名}}"}</b> 引用下方变量;
顶部切换环境即全局替换。
<a href="/docs/env" target="_blank" rel="noopener noreferrer" className="ops-link ml-1">
查看文档 →
</a>
</span>
</div>
<div className="flex items-center gap-2">
<EnvBadge envId={env.id} name={env.name} size={28} />
<input
className="ops-input flex-1"
value={env.name}
onChange={(e) => updateEnvironment(env.id, { name: e.target.value })}
placeholder="环境名称"
/>
{isActive ? (
<button className="ops-btn ops-btn-secondary" onClick={() => setActiveEnvironment(null)}>
取消使用
</button>
) : (
<button className="ops-btn ops-btn-primary" onClick={() => setActiveEnvironment(env.id)}>
<Icon name="play" size={13} /> 设为当前
</button>
)}
<button
className="ops-btn ops-btn-secondary"
title="复制环境"
onClick={() => setSelected(duplicateEnvironment(env.id))}
>
<Icon name="copy" size={13} /> 复制
</button>
<button
className="ops-btn ops-btn-ghost ops-danger"
title="删除环境"
onClick={() => {
removeEnvironment(env.id);
setSelected(useWorkspace.getState().environments[0]?.id ?? "");
}}
>
<Icon name="trash" size={13} /> 删除
</button>
</div>
<div className="mt-4 flex items-center gap-2">
<div className="text-[13px] font-semibold">变量</div>
<span className="ops-faint text-[11px]">{env.variables.length} 项</span>
<input
className="ops-input ops-input-sm ml-auto w-44"
placeholder="搜索变量 / 描述…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<button className="ops-btn ops-btn-secondary" onClick={() => addEnvVariable(env.id)}>
<Icon name="plus" size={13} /> 添加变量
</button>
</div>
<div className="env-dialog-vars">
{env.variables.length === 0 ? (
<div className="ops-faint py-8 text-center text-xs">
暂无变量,点击右上「添加变量」;推荐先定义 baseUrl 与 token
</div>
) : (
<KVEditor
rows={filteredVars}
onAdd={() => addEnvVariable(env.id)}
onUpdate={(rowId, patch) => updateEnvVariable(env.id, rowId, patch)}
onRemove={(rowId) => removeEnvVariable(env.id, rowId)}
keyPlaceholder="变量名"
valuePlaceholder="值"
withDescription
/>
)}
{search && filteredVars.length === 0 && env.variables.length > 0 && (
<div className="ops-faint py-6 text-center text-xs">没有匹配的变量</div>
)}
</div>
</>
) : (
<div className="ops-faint grid h-full place-items-center text-sm">
{environments.length === 0 ? "左侧点击「新建环境」开始" : "选择左侧环境"}
</div>
)}
</div>
</div>
</Modal>
);
}
export function SettingsDialog() {
const settings = useWorkspace((s) => s.settings);
const setTheme = useWorkspace((s) => s.setTheme);
const setTimeoutMs = useWorkspace((s) => s.setTimeoutMs);
const resetWorkspace = useWorkspace((s) => s.resetWorkspace);
const setDialog = useWorkspace((s) => s.setDialog);
return (
<Modal
title="设置"
onClose={() => setDialog(null)}
footer={
<button className="ops-btn ops-btn-primary" onClick={() => setDialog(null)}>
完成
</button>
}
>
<div className="max-w-md space-y-5">
<div>
<label className="ops-label">主题</label>
<div className="flex gap-1.5">
{(["dark", "light"] as const).map((t) => (
<button
key={t}
className={cx("ops-chip", settings.theme === t && "ops-chip-active")}
onClick={() => setTheme(t)}
>
{t === "dark" ? "🌙 深色" : "☀️ 浅色"}
</button>
))}
</div>
</div>
<div>
<label className="ops-label">请求超时(毫秒)</label>
<input
type="number"
className="ops-input w-40"
value={settings.timeoutMs}
min={1000}
step={1000}
onChange={(e) => setTimeoutMs(Number(e.target.value) || 30000)}
/>
</div>
<div>
<label className="ops-label">数据</label>
<div className="ops-faint mb-2 text-[11px] leading-5">
所有集合与环境保存在浏览器 localStorage;全量备份请在「导出 → 全量备份」中生成。
</div>
<button
className="ops-btn ops-btn-danger"
onClick={() => {
if (confirm("确定重置工作区?将恢复到初始示例数据(当前数据不可恢复,建议先导出备份)")) {
resetWorkspace();
setDialog(null);
}
}}
>
<Icon name="trash" size={13} /> 重置工作区
</button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,104 @@
/**
* GraphQL 编辑器:Query / Variables / Headers / Auth + 响应。
*/
import type { GraphqlApiNode, GraphqlRequestData, KVRow } from "@opsxapi/core";
import { useState } from "react";
import { sendGraphqlRequest } from "../../send";
import { useWorkspace } from "../../workspaceStore";
import { KVEditor, cx } from "../common";
import { Icon } from "../icons";
import { VarHintInput } from "../VarHintInput";
import { AuthEditor } from "./HttpEditor";
import { ResponsePanel } from "./ResponsePanel";
const TABS = ["Query", "Variables", "Headers", "Auth"] as const;
type Tab = (typeof TABS)[number];
export function GraphqlEditor({ node }: { node: GraphqlApiNode }) {
const data = node.data;
const update = useWorkspace((s) => s.updateRequestData);
const runtime = useWorkspace((s) => s.runtime[node.id]);
const [tab, setTab] = useState<Tab>("Query");
const patch = (p: Partial<GraphqlRequestData>) => update(node.id, p);
const variablesValid = (() => {
try {
JSON.parse(data.variables || "{}");
return true;
} catch {
return false;
}
})();
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 flex-wrap items-center gap-2 px-3 py-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
<span className="ops-chip inline-flex items-center gap-1" style={{ padding: "2px 10px" }}>POST <Icon name="hexagon" size={12} /> GraphQL</span>
<VarHintInput
className="ops-input flex-1 font-mono text-[13px]"
placeholder="https://api.example.com/graphql 或 {{baseUrl}}/graphql"
value={data.url}
onChange={(v) => patch({ url: v })}
onKeyDown={(e) => e.key === "Enter" && sendGraphqlRequest(node)}
/>
<button
className="ops-btn ops-btn-primary min-w-[84px]"
disabled={runtime?.loading}
onClick={() => sendGraphqlRequest(node)}
>
<Icon name="play" size={13} /> {runtime?.loading ? "发送中…" : "执行"}
</button>
</div>
<div className="flex shrink-0 px-2" style={{ borderBottom: "1px solid var(--border)" }}>
{TABS.map((t) => (
<button key={t} className={cx("ops-tab", tab === t && "ops-tab-active")} onClick={() => setTab(t)}>
{t}
{t === "Headers" && data.headers.some((h: { enabled: boolean; key: string }) => h.enabled && h.key) ? (
<span className="ops-count">{data.headers.filter((h: { enabled: boolean; key: string }) => h.enabled && h.key).length}</span>
) : null}
</button>
))}
</div>
<div className="ops-scroll shrink-0 p-3.5" style={{ maxHeight: "42%", minHeight: 120 }}>
{tab === "Query" && (
<textarea
className="ops-code min-h-48 w-full resize-y font-mono"
spellCheck={false}
placeholder="query Hero($id: ID!) {\n hero(id: $id) {\n name\n }\n}"
value={data.query}
onChange={(e) => patch({ query: e.target.value })}
/>
)}
{tab === "Variables" && (
<div className="space-y-2">
<textarea
className="ops-code min-h-40 w-full resize-y font-mono"
spellCheck={false}
placeholder='{\n "id": "1"\n}'
value={data.variables}
onChange={(e) => patch({ variables: e.target.value })}
style={{ borderColor: variablesValid ? undefined : "var(--danger)" }}
/>
{!variablesValid && <div className="ops-danger text-[11px]">Variables 不是合法 JSON</div>}
</div>
)}
{tab === "Headers" && (
<KVEditor rows={data.headers} onChange={(rows: KVRow[]) => patch({ headers: rows })} />
)}
{tab === "Auth" && (
<AuthEditor
data={{ ...data, method: "POST", query: [], cookies: [], bodyMode: "json", bodyRaw: "", bodyForm: [] } as never}
patch={(p: { auth?: GraphqlRequestData["auth"] }) => {
if (p.auth) patch({ auth: p.auth });
}}
/>
)}
</div>
<ResponsePanel nodeId={node.id} />
</div>
);
}
@@ -0,0 +1,317 @@
/**
* HTTP 请求编辑器:方法/URL/Send + Params/Body/Headers/Cookies/Auth/说明 + 代码生成。
*/
import type { HttpApiNode, HttpRequestData, KVRow } from "@opsxapi/core";
import {
BODY_MODE_LABELS,
RAW_BODY_MODES,
REQUEST_METHODS,
exportCurl,
generateCode,
type CodeTarget,
} from "@opsxapi/core";
import { replaceVariables } from "@opsxapi/core";
import { useState } from "react";
import { sendHttpRequest } from "../../send";
import { activeEnvMap, useWorkspace } from "../../workspaceStore";
import { ContextMenu, CopyButton, KVEditor, cx, type MenuItem } from "../common";
import { Icon } from "../icons";
import { VarHintInput } from "../VarHintInput";
import { ResponsePanel } from "./ResponsePanel";
type BodyTab = "none" | "json" | "xml" | "text" | "html" | "urlencoded" | "form-data";
const REQ_TABS = ["Params", "Body", "Headers", "Cookies", "Auth", "说明"] as const;
type ReqTab = (typeof REQ_TABS)[number];
export function HttpEditor({ node }: { node: HttpApiNode }) {
const data = node.data;
const update = useWorkspace((s) => s.updateRequestData);
const updateDesc = useWorkspace((s) => s.updateNodeDescription);
const runtime = useWorkspace((s) => s.runtime[node.id]);
const [tab, setTab] = useState<ReqTab>("Params");
const [codeMenu, setCodeMenu] = useState<{ x: number; y: number } | null>(null);
const [codeView, setCodeView] = useState<{ target: CodeTarget; code: string } | null>(null);
const env = activeEnvMap();
const resolvedUrl = replaceVariables(data.url, env);
const patch = (p: Partial<HttpRequestData>) => update(node.id, p);
const counts: Partial<Record<ReqTab, number>> = {
Params: data.query.filter((r) => r.enabled && r.key.trim()).length,
Headers: data.headers.filter((r) => r.enabled && r.key.trim()).length,
Cookies: data.cookies.filter((r) => r.enabled && r.key.trim()).length,
};
const codeItems: MenuItem[] = (
[
["curl", "cURL"],
["fetch", "JavaScript fetch"],
["python", "Python requests"],
["go", "Go net/http"],
] as [CodeTarget, string][]
).map(([target, label]) => ({
label,
onClick: () => setCodeView({ target, code: generateCode(target, data) }),
}));
return (
<div className="flex min-h-0 flex-1 flex-col">
{/* URL 栏 */}
<div className="flex shrink-0 flex-wrap items-center gap-2 px-3 py-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
<select
value={data.method}
onChange={(e) => patch({ method: e.target.value as HttpRequestData["method"] })}
className={cx("ops-input ops-input-sm w-[92px] font-bold", `ops-method-${data.method}`)}
style={{ background: "var(--panel-2)" }}
>
{REQUEST_METHODS.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
<VarHintInput
className="ops-input flex-1 font-mono text-[13px]"
placeholder="https://api.example.com/v1/resource 或 {{baseUrl}}/path"
value={data.url}
onChange={(v) => patch({ url: v })}
onKeyDown={(e) => e.key === "Enter" && sendHttpRequest(node)}
/>
<button
className="ops-btn ops-btn-primary min-w-[84px]"
disabled={runtime?.loading}
onClick={() => sendHttpRequest(node)}
>
<Icon name="play" size={13} /> {runtime?.loading ? "发送中…" : "发送"}
</button>
<button
className="ops-btn ops-btn-secondary"
onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setCodeMenu({ x: rect.left, y: rect.bottom + 4 });
}}
title="生成请求代码"
>
<Icon name="code" size={13} /> 代码
</button>
</div>
{data.url.includes("{{") && (
<div className="ops-faint px-4 py-1 text-[11px]" style={{ borderBottom: "1px solid var(--border)" }}>
实际地址:<span className="ops-accent font-mono">{resolvedUrl}</span>
</div>
)}
{/* 请求 tabs */}
<div className="flex shrink-0 px-2" style={{ borderBottom: "1px solid var(--border)" }}>
{REQ_TABS.map((t) => (
<button key={t} className={cx("ops-tab", tab === t && "ops-tab-active")} onClick={() => setTab(t)}>
{t}
{counts[t] ? <span className="ops-count">{counts[t]}</span> : null}
</button>
))}
</div>
<div className="ops-scroll shrink-0 p-3.5" style={{ maxHeight: "42%", minHeight: 120 }}>
{tab === "Params" && (
<KVEditor rows={data.query} onChange={(rows: KVRow[]) => patch({ query: rows })} withDescription />
)}
{tab === "Headers" && (
<KVEditor rows={data.headers} onChange={(rows: KVRow[]) => patch({ headers: rows })} />
)}
{tab === "Cookies" && (
<KVEditor rows={data.cookies} onChange={(rows: KVRow[]) => patch({ cookies: rows })} />
)}
{tab === "Auth" && <AuthEditor data={data} patch={patch} />}
{tab === "Body" && <BodyEditor data={data} patch={patch} />}
{tab === "说明" && (
<textarea
className="ops-code min-h-40 w-full resize-y"
placeholder="接口说明(支持 Markdown)"
value={node.description ?? ""}
onChange={(e) => updateDesc(node.id, e.target.value)}
/>
)}
</div>
<ResponsePanel nodeId={node.id} />
<ContextMenu
menu={codeMenu ? { x: codeMenu.x, y: codeMenu.y, items: codeItems } : null}
onClose={() => setCodeMenu(null)}
/>
{codeView && (
<CodeViewModal
title={`代码 · ${codeView.target}`}
code={codeView.code}
onClose={() => setCodeView(null)}
/>
)}
</div>
);
}
export function CodeViewModal({ title, code, onClose }: { title: string; code: string; onClose: () => void }) {
return (
<div className="ops-modal-mask" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<div className="ops-modal" style={{ maxWidth: 720 }}>
<div className="ops-modal-header">
<div className="ops-modal-title">{title}</div>
<button className="ops-btn ops-btn-ghost px-2" onClick={onClose}>✕</button>
</div>
<div className="ops-modal-body">
<pre className="ops-code max-h-[55vh] overflow-auto whitespace-pre-wrap">{code}</pre>
</div>
<div className="ops-modal-footer">
<CopyButton text={code} label="复制全部" />
</div>
</div>
</div>
);
}
/* ---------------- Auth ---------------- */
export function AuthEditor({
data,
patch,
}: {
data: HttpRequestData;
patch: (p: Partial<HttpRequestData>) => void;
}) {
const auth = data.auth;
const setAuth = (p: Partial<HttpRequestData["auth"]>) => patch({ auth: { ...auth, ...p } });
return (
<div className="max-w-xl space-y-3">
<div>
<label className="ops-label">鉴权方式</label>
<select
className="ops-input w-56"
value={auth.mode}
onChange={(e) => setAuth({ mode: e.target.value as HttpRequestData["auth"]["mode"] })}
>
<option value="none">No Auth</option>
<option value="bearer">Bearer Token</option>
<option value="basic">Basic Auth</option>
<option value="apikey">API Key</option>
</select>
</div>
{auth.mode === "bearer" && (
<div>
<label className="ops-label">Token(支持 {"{{var}}"})</label>
<input className="ops-input font-mono" value={auth.bearer ?? ""} onChange={(e) => setAuth({ bearer: e.target.value })} placeholder="{{token}}" />
</div>
)}
{auth.mode === "basic" && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="ops-label">用户名</label>
<input className="ops-input" value={auth.basicUser ?? ""} onChange={(e) => setAuth({ basicUser: e.target.value })} />
</div>
<div>
<label className="ops-label">密码</label>
<input type="password" className="ops-input" value={auth.basicPass ?? ""} onChange={(e) => setAuth({ basicPass: e.target.value })} />
</div>
</div>
)}
{auth.mode === "apikey" && (
<div className="grid grid-cols-3 gap-3">
<div>
<label className="ops-label">Key 名</label>
<input className="ops-input" value={auth.apiKeyName ?? ""} onChange={(e) => setAuth({ apiKeyName: e.target.value })} placeholder="X-API-Key" />
</div>
<div>
<label className="ops-label">值</label>
<input className="ops-input" value={auth.apiKeyValue ?? ""} onChange={(e) => setAuth({ apiKeyValue: e.target.value })} />
</div>
<div>
<label className="ops-label">位置</label>
<select
className="ops-input"
value={auth.apiKeyIn ?? "header"}
onChange={(e) => setAuth({ apiKeyIn: e.target.value as "header" | "query" })}
>
<option value="header">Header</option>
<option value="query">Query</option>
</select>
</div>
</div>
)}
</div>
);
}
/* ---------------- Body ---------------- */
function BodyEditor({
data,
patch,
}: {
data: HttpRequestData;
patch: (p: Partial<HttpRequestData>) => void;
}) {
const mode = data.bodyMode as BodyTab;
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-1.5">
{(Object.keys(BODY_MODE_LABELS) as BodyTab[]).map((m) => (
<button
key={m}
className={cx("ops-chip", mode === m && "ops-chip-active")}
onClick={() => patch({ bodyMode: m })}
>
{BODY_MODE_LABELS[m]}
</button>
))}
{mode === "json" && (
<button
className="ops-btn ops-btn-ghost ml-2"
onClick={() => {
try {
patch({ bodyRaw: JSON.stringify(JSON.parse(data.bodyRaw), null, 2) });
} catch {
/* ignore */
}
}}
>
格式化
</button>
)}
</div>
{mode === "none" && <div className="ops-faint py-6 text-center text-xs">该请求不携带请求体</div>}
{RAW_BODY_MODES.includes(mode) && (
<textarea
className="ops-code min-h-48 w-full resize-y font-mono"
spellCheck={false}
placeholder={
mode === "json"
? '{\n "key": "value"\n}'
: mode === "xml"
? "<root>\n <item>value</item>\n</root>"
: "原始文本内容"
}
value={data.bodyRaw}
onChange={(e) => patch({ bodyRaw: e.target.value })}
/>
)}
{(mode === "urlencoded" || mode === "form-data") && (
<KVEditor
rows={data.bodyForm}
onChange={(rows) => patch({ bodyForm: rows })}
keyPlaceholder="字段名"
valuePlaceholder="值"
/>
)}
{mode === "form-data" && (
<div className="ops-faint text-[11px]">当前支持文本字段;文件上传请用 cURL / 代码生成。</div>
)}
</div>
);
}
export { exportCurl };
@@ -0,0 +1,134 @@
/**
* MQTT 工作台:broker 配置 + 订阅 + 发布 + 消息日志。
*/
import type { MqttApiNode, MqttRequestData } from "@opsxapi/core";
import { useState } from "react";
import { connectMqttSession, mqttPublish } from "../../send";
import { useWorkspace } from "../../workspaceStore";
import { cx } from "../common";
import { Icon } from "../icons";
import { LiveLog, LiveStatus } from "./liveCommon";
const TABS = ["消息", "Broker / 订阅", "发布", "说明"] as const;
type Tab = (typeof TABS)[number];
export function MqttEditor({ node }: { node: MqttApiNode }) {
const data = node.data;
const update = useWorkspace((s) => s.updateRequestData);
const updateDesc = useWorkspace((s) => s.updateNodeDescription);
const runtime = useWorkspace((s) => s.runtime[node.id]);
const [tab, setTab] = useState<Tab>("消息");
const patch = (p: Partial<MqttRequestData>) => update(node.id, p);
const connected = runtime?.status === "open";
return (
<div className="flex min-h-0 flex-1 flex-col gap-2 p-2.5">
<div className="flex shrink-0 flex-wrap items-center gap-2">
<span className="ops-chip inline-flex items-center gap-1" style={{ padding: "2px 10px" }}><Icon name="mailbox" size={13} /> MQTT</span>
<input
className="ops-input flex-1 font-mono text-[13px]"
placeholder="wss://broker.emqx.io:8084/mqtt"
value={data.brokerUrl}
onChange={(e) => patch({ brokerUrl: e.target.value })}
/>
<button
className={cx("ops-btn min-w-[88px]", connected ? "ops-btn-danger" : "ops-btn-success")}
onClick={() => connectMqttSession(node)}
>
<Icon name={connected ? "unplug" : "plug-zap"} size={13} />
{runtime?.status === "connecting" ? "连接中…" : connected ? "断开" : "连接"}
</button>
<LiveStatus nodeId={node.id} />
</div>
<div className="flex shrink-0 gap-1" style={{ borderBottom: "1px solid var(--border)" }}>
{TABS.map((t) => (
<button key={t} className={cx("ops-tab", tab === t && "ops-tab-active")} onClick={() => setTab(t)}>
{t}
</button>
))}
</div>
{tab === "消息" && (
<div className="min-h-0 flex-1">
<LiveLog nodeId={node.id} />
</div>
)}
{tab === "Broker / 订阅" && (
<div className="ops-scroll max-w-2xl space-y-4 p-1">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="ops-label">用户名</label>
<input className="ops-input" value={data.username} onChange={(e) => patch({ username: e.target.value })} />
</div>
<div>
<label className="ops-label">密码</label>
<input type="password" className="ops-input" value={data.password} onChange={(e) => patch({ password: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="ops-label">ClientId</label>
<input className="ops-input font-mono" value={data.clientId} onChange={(e) => patch({ clientId: e.target.value })} />
</div>
<div>
<label className="ops-label">QoS</label>
<select className="ops-input" value={data.qos} onChange={(e) => patch({ qos: Number(e.target.value) as 0 | 1 | 2 })}>
<option value={0}>0 · 至多一次</option>
<option value={1}>1 · 至少一次</option>
<option value={2}>2 · 恰好一次</option>
</select>
</div>
</div>
<div>
<label className="ops-label">订阅主题(逗号分隔,支持 +/# 通配)</label>
<input
className="ops-input font-mono"
value={data.subscribeTopics}
onChange={(e) => patch({ subscribeTopics: e.target.value })}
placeholder="opsxapi/demo, sensor/#"
/>
<div className="ops-faint mt-1.5 text-[11px]">订阅主题在建立连接时生效;修改后需重连。</div>
</div>
</div>
)}
{tab === "发布" && (
<div className="ops-scroll max-w-2xl space-y-4 p-1">
<div>
<label className="ops-label">发布主题</label>
<input
className="ops-input font-mono"
value={data.publishTopic}
onChange={(e) => patch({ publishTopic: e.target.value })}
placeholder="opsxapi/demo"
/>
</div>
<div>
<label className="ops-label">Payload</label>
<textarea
className="ops-code min-h-32 w-full resize-y font-mono"
value={data.publishPayload}
onChange={(e) => patch({ publishPayload: e.target.value })}
spellCheck={false}
/>
</div>
<button className="ops-btn ops-btn-primary" disabled={!connected} onClick={() => mqttPublish(node.id, node)}>
<Icon name="send" size={13} /> 发布到 {data.publishTopic || "(未填主题)"}
</button>
</div>
)}
{tab === "说明" && (
<textarea
className="ops-code min-h-40 w-full resize-y"
placeholder="接口说明(支持 Markdown)"
value={node.description ?? ""}
onChange={(e) => updateDesc(node.id, e.target.value)}
/>
)}
</div>
);
}
@@ -0,0 +1,126 @@
/**
* HTTP / GraphQL 响应面板:Pretty / Raw / 响应头 + 状态条。
*/
import { useMemo } from "react";
import { useWorkspace } from "../../workspaceStore";
import { CopyButton, JsonView, cx, formatBytes } from "../common";
export function ResponsePanel({ nodeId }: { nodeId: string }) {
const runtime = useWorkspace((s) => s.runtime[nodeId]);
const setRuntime = useWorkspace((s) => s.setRuntime);
const tab = runtime?.responseTab ?? "body";
const result = runtime?.result ?? null;
const error = runtime?.error ?? null;
const loading = runtime?.loading ?? false;
const contentType = useMemo(() => {
const ct = result?.headers?.["content-type"] ?? "";
return ct.toLowerCase();
}, [result]);
const isJson = contentType.includes("json") || (!contentType && looksLikeJson(result?.body ?? ""));
const prettyBody = useMemo(() => {
if (!result?.body) return "";
if (isJson) {
try {
return JSON.stringify(JSON.parse(result.body), null, 2);
} catch {
return result.body;
}
}
return result.body;
}, [result, isJson]);
return (
<section className="flex min-h-0 flex-1 flex-col" style={{ borderTop: "1px solid var(--border)" }}>
<div
className="flex shrink-0 items-center justify-between px-3"
style={{ borderBottom: "1px solid var(--border)" }}
>
<div className="flex">
{(
[
["body", "响应体"],
["raw", "Raw"],
["headers", "响应头"],
] as const
).map(([key, label]) => (
<button
key={key}
className={cx("ops-tab", tab === key && "ops-tab-active")}
onClick={() => setRuntime(nodeId, { responseTab: key })}
>
{label}
</button>
))}
</div>
<div className="flex items-center gap-3 pr-1 text-xs">
{result ? (
<>
<span>
状态{" "}
<b className={result.status < 400 ? "ops-success" : "ops-danger"}>{result.status}</b>
</span>
<span className="ops-dim">
耗时 <b>{result.timeMs} ms</b>
</span>
<span className="ops-dim">
大小 <b>{formatBytes(result.size)}</b>
</span>
{contentType && (
<span className="ops-faint hidden truncate lg:inline" style={{ maxWidth: 200 }}>
{result.headers["content-type"]}
</span>
)}
<CopyButton text={result.body} label="复制体" />
</>
) : (
<span className="ops-faint">Status · Time · Size</span>
)}
</div>
</div>
<div className="ops-scroll min-h-0 flex-1 p-3">
{loading && <div className="ops-dim animate-pulse py-6 text-center text-sm">请求发送中…</div>}
{!loading && error && (
<div
className="ops-msg ops-msg-err mb-3 !font-sans text-[13px]"
style={{ whiteSpace: "pre-wrap" }}
>
{error}
</div>
)}
{!loading && !error && !result && (
<div className="ops-faint grid h-full place-items-center text-sm">
发送请求后这里显示响应
</div>
)}
{!loading && result && tab === "body" && (
<div className="ops-code h-full overflow-auto">
{isJson ? <JsonView text={prettyBody} /> : <pre className="m-0 whitespace-pre-wrap">{result.body}</pre>}
</div>
)}
{!loading && result && tab === "raw" && (
<pre className="ops-code m-0 h-full overflow-auto whitespace-pre-wrap break-all">{result.body}</pre>
)}
{!loading && result && tab === "headers" && (
<table className="ops-code w-full border-collapse text-left">
<tbody>
{Object.entries(result.headers).map(([k, v]) => (
<tr key={k} className="align-top">
<td className="ops-accent pr-4 whitespace-nowrap">{k}</td>
<td className="break-all">{v}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
);
}
function looksLikeJson(s: string): boolean {
const t = s.trimStart();
return t.startsWith("{") || t.startsWith("[");
}
@@ -0,0 +1,127 @@
/**
* Socket.IO 工作台:连接配置 + 事件监听 + emit 发送。
*/
import type { SocketIoApiNode, SocketIoRequestData } from "@opsxapi/core";
import { useState } from "react";
import { connectSocketIoSession, socketIoEmit } from "../../send";
import { useWorkspace } from "../../workspaceStore";
import { cx } from "../common";
import { Icon } from "../icons";
import { Composer, LiveLog, LiveStatus } from "./liveCommon";
const TABS = ["事件", "连接配置", "说明"] as const;
type Tab = (typeof TABS)[number];
export function SocketIoEditor({ node }: { node: SocketIoApiNode }) {
const data = node.data;
const update = useWorkspace((s) => s.updateRequestData);
const updateDesc = useWorkspace((s) => s.updateNodeDescription);
const runtime = useWorkspace((s) => s.runtime[node.id]);
const [tab, setTab] = useState<Tab>("事件");
const [listenInput, setListenInput] = useState(data.listenEvents.join(", "));
const [sendEvent, setSendEvent] = useState(data.sendEvent);
const [sendArgs, setSendArgs] = useState(data.sendArgs);
const patch = (p: Partial<SocketIoRequestData>) => update(node.id, p);
const connected = runtime?.status === "open";
const emit = () => {
patch({ sendEvent, sendArgs });
socketIoEmit(node.id, sendEvent, sendArgs);
};
return (
<div className="flex min-h-0 flex-1 flex-col gap-2 p-2.5">
<div className="flex shrink-0 flex-wrap items-center gap-2">
<span className="ops-chip inline-flex items-center gap-1" style={{ padding: "2px 10px" }}><Icon name="plug-zap" size={13} /> Socket.IO</span>
<input
className="ops-input flex-1 font-mono text-[13px]"
placeholder="http://127.0.0.1:3000(不含 /socket.io 路径)"
value={data.url}
onChange={(e) => patch({ url: e.target.value })}
/>
<button
className={cx("ops-btn min-w-[88px]", connected ? "ops-btn-danger" : "ops-btn-success")}
onClick={() => connectSocketIoSession(node)}
>
<Icon name={connected ? "unplug" : "plug-zap"} size={13} />
{runtime?.status === "connecting" ? "连接中…" : connected ? "断开" : "连接"}
</button>
<LiveStatus nodeId={node.id} />
</div>
<div className="flex shrink-0 gap-1" style={{ borderBottom: "1px solid var(--border)" }}>
{TABS.map((t) => (
<button key={t} className={cx("ops-tab", tab === t && "ops-tab-active")} onClick={() => setTab(t)}>
{t}
</button>
))}
</div>
{tab === "事件" && (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<div className="min-h-0 flex-1">
<LiveLog nodeId={node.id} />
</div>
<Composer
value={sendArgs}
onChange={setSendArgs}
disabled={!connected}
onSend={emit}
sendLabel="emit"
placeholder='[\n "hello",\n 42\n] (JSON 数组,作为 emit 参数)'
extra={
<div className="flex items-center gap-2 px-2.5 pt-2.5">
<span className="ops-faint text-[11px]">事件名</span>
<input
className="ops-input ops-input-sm w-44 font-mono"
value={sendEvent}
onChange={(e) => setSendEvent(e.target.value)}
placeholder="message"
/>
</div>
}
/>
</div>
)}
{tab === "连接配置" && (
<div className="ops-scroll max-w-2xl space-y-4 p-1">
<div>
<label className="ops-label">监听事件(逗号分隔,留空 = 接收全部)</label>
<input
className="ops-input font-mono"
value={listenInput}
onChange={(e) => setListenInput(e.target.value)}
onBlur={() =>
patch({ listenEvents: listenInput.split(",").map((s: string) => s.trim()).filter(Boolean) })
}
placeholder="message, notification"
/>
</div>
<div>
<label className="ops-label">连接选项(JSON:path / transports / auth…)</label>
<textarea
className="ops-code min-h-32 w-full resize-y font-mono"
value={data.options}
onChange={(e) => patch({ options: e.target.value })}
spellCheck={false}
/>
</div>
<div className="ops-faint text-[11px] leading-5">
transports 默认 ["websocket", "polling"];auth 用于带令牌握手。事件名列表在连接时生效。
</div>
</div>
)}
{tab === "说明" && (
<textarea
className="ops-code min-h-40 w-full resize-y"
placeholder="接口说明(支持 Markdown)"
value={node.description ?? ""}
onChange={(e) => updateDesc(node.id, e.target.value)}
/>
)}
</div>
);
}
@@ -0,0 +1,121 @@
/**
* SSE 工作台:连接配置 + 事件流日志。
*/
import type { KVRow, SseApiNode, SseRequestData } from "@opsxapi/core";
import { useState } from "react";
import { connectSse } from "../../send";
import { hasSession } from "../../send";
import { useWorkspace } from "../../workspaceStore";
import { KVEditor, cx } from "../common";
import { Icon } from "../icons";
import { VarHintInput } from "../VarHintInput";
import { LiveLog, LiveStatus } from "./liveCommon";
import { AuthEditor } from "./HttpEditor";
const TABS = ["事件流", "连接配置", "说明"] as const;
type Tab = (typeof TABS)[number];
export function SseEditor({ node }: { node: SseApiNode }) {
const data = node.data;
const update = useWorkspace((s) => s.updateRequestData);
const updateDesc = useWorkspace((s) => s.updateNodeDescription);
const runtime = useWorkspace((s) => s.runtime[node.id]);
const [tab, setTab] = useState<Tab>("事件流");
const patch = (p: Partial<SseRequestData>) => update(node.id, p);
const connected = runtime?.status === "open" || runtime?.status === "connecting";
return (
<div className="flex min-h-0 flex-1 flex-col gap-2 p-2.5">
<div className="flex shrink-0 flex-wrap items-center gap-2">
<span className="ops-chip inline-flex items-center gap-1" style={{ padding: "2px 10px" }}><Icon name="radio" size={13} /> SSE</span>
<VarHintInput
className="ops-input flex-1 font-mono text-[13px]"
placeholder="https://stream.example.com/events"
value={data.url}
onChange={(v) => patch({ url: v })}
/>
<button
className={cx("ops-btn min-w-[88px]", connected ? "ops-btn-danger" : "ops-btn-success")}
onClick={() => connectSse(node)}
>
<Icon name={connected ? "unplug" : "plug-zap"} size={13} />
{runtime?.status === "connecting" ? "连接中…" : connected ? "断开" : "连接"}
</button>
<LiveStatus nodeId={node.id} />
</div>
<div className="flex shrink-0 gap-1" style={{ borderBottom: "1px solid var(--border)" }}>
{TABS.map((t) => (
<button key={t} className={cx("ops-tab", tab === t && "ops-tab-active")} onClick={() => setTab(t)}>
{t}
</button>
))}
</div>
{tab === "事件流" && (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<div className="min-h-0 flex-1">
<LiveLog nodeId={node.id} />
</div>
<div className="ops-faint shrink-0 px-1 text-[11px]">
直连失败会自动切换 Gin 代理(绕开 CORS);服务端事件按 event: 分发,data 为 JSON 时自动格式化。
</div>
</div>
)}
{tab === "连接配置" && (
<div className="ops-scroll max-w-2xl space-y-4 p-1">
<div>
<label className="ops-label">Query 参数</label>
<KVEditor rows={data.query} onChange={(rows: KVRow[]) => patch({ query: rows })} />
</div>
<div>
<label className="ops-label">Headers</label>
<KVEditor rows={data.headers} onChange={(rows: KVRow[]) => patch({ headers: rows })} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="ops-label">Last-Event-ID(断点续传)</label>
<input
className="ops-input font-mono"
value={data.lastEventId}
onChange={(e) => patch({ lastEventId: e.target.value })}
placeholder="留空不携带"
/>
</div>
<div className="flex items-end gap-2 pb-1">
<label className="flex cursor-pointer items-center gap-2 text-xs">
<input
type="checkbox"
checked={data.dataOnly}
onChange={(e) => patch({ dataOnly: e.target.checked })}
className="accent-[var(--accent)]"
/>
仅显示 data 内容
</label>
</div>
</div>
<div>
<label className="ops-label">鉴权</label>
<AuthEditor
data={{ ...data, kind: "http", method: "GET", url: data.url, query: [], cookies: [], headers: [], bodyMode: "none", bodyRaw: "", bodyForm: [] } as never}
patch={(p: { auth?: SseRequestData["auth"] }) => (p.auth ? patch({ auth: p.auth }) : undefined)}
/>
</div>
</div>
)}
{tab === "说明" && (
<textarea
className="ops-code min-h-40 w-full resize-y"
placeholder="接口说明(支持 Markdown)"
value={node.description ?? ""}
onChange={(e) => updateDesc(node.id, e.target.value)}
/>
)}
</div>
);
}
export { hasSession };
@@ -0,0 +1,100 @@
/**
* WebSocket 工作台。
*/
import type { KVRow, WebsocketApiNode, WebsocketRequestData } from "@opsxapi/core";
import { useState } from "react";
import { connectWs, wsSend } from "../../send";
import { useWorkspace } from "../../workspaceStore";
import { KVEditor, cx } from "../common";
import { Icon } from "../icons";
import { VarHintInput } from "../VarHintInput";
import { Composer, LiveLog, LiveStatus } from "./liveCommon";
const TABS = ["消息", "握手配置", "说明"] as const;
type Tab = (typeof TABS)[number];
export function WebsocketEditor({ node }: { node: WebsocketApiNode }) {
const data = node.data;
const update = useWorkspace((s) => s.updateRequestData);
const runtime = useWorkspace((s) => s.runtime[node.id]);
const updateDesc = useWorkspace((s) => s.updateNodeDescription);
const [tab, setTab] = useState<Tab>("消息");
const [input, setInput] = useState('{\n "op": "ping"\n}');
const patch = (p: Partial<WebsocketRequestData>) => update(node.id, p);
const connected = runtime?.status === "open";
return (
<div className="flex min-h-0 flex-1 flex-col gap-2 p-2.5">
{/* 连接栏 */}
<div className="flex shrink-0 flex-wrap items-center gap-2">
<span className="ops-chip inline-flex items-center gap-1" style={{ padding: "2px 10px" }}><Icon name="zap" size={13} /> WS</span>
<VarHintInput
className="ops-input flex-1 font-mono text-[13px]"
placeholder="wss://echo.websocket.events"
value={data.url}
onChange={(v) => patch({ url: v })}
/>
<button
className={cx("ops-btn min-w-[88px]", connected ? "ops-btn-danger" : "ops-btn-success")}
onClick={() => connectWs(node)}
>
<Icon name={connected ? "unplug" : "plug-zap"} size={13} />
{runtime?.status === "connecting" ? "连接中…" : connected ? "断开" : "连接"}
</button>
<LiveStatus nodeId={node.id} />
</div>
{/* tabs */}
<div className="flex shrink-0 gap-1" style={{ borderBottom: "1px solid var(--border)" }}>
{TABS.map((t) => (
<button key={t} className={cx("ops-tab", tab === t && "ops-tab-active")} onClick={() => setTab(t)}>
{t}
</button>
))}
</div>
{tab === "消息" && (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<div className="min-h-0 flex-1">
<LiveLog nodeId={node.id} />
</div>
<Composer
value={input}
onChange={setInput}
disabled={!connected}
onSend={() => wsSend(node.id, input)}
sendLabel="发送消息"
/>
</div>
)}
{tab === "握手配置" && (
<div className="ops-scroll max-w-2xl space-y-4 p-1">
<div>
<label className="ops-label">子协议(逗号分隔)</label>
<input
className="ops-input font-mono"
placeholder="graphql-transport-ws, json"
value={data.protocols}
onChange={(e) => patch({ protocols: e.target.value })}
/>
</div>
<div>
<label className="ops-label">握手 Header(浏览器直连不支持自定义 Header,将自动走 Gin 隧道)</label>
<KVEditor rows={data.headers} onChange={(rows: KVRow[]) => patch({ headers: rows })} />
</div>
</div>
)}
{tab === "说明" && (
<textarea
className="ops-code min-h-40 w-full resize-y"
placeholder="接口说明(支持 Markdown)"
value={node.description ?? ""}
onChange={(e) => updateDesc(node.id, e.target.value)}
/>
)}
</div>
);
}
@@ -0,0 +1,141 @@
/**
* 实时协议编辑器共用件:消息日志 / 发送器 / 状态栏。
*/
import { useEffect, useRef } from "react";
import { useWorkspace } from "../../workspaceStore";
import { StatusDot, cx, formatTime } from "../common";
/** 稳定空数组:避免 useSyncExternalStore 因新引用无限重渲染 */
const EMPTY_MESSAGES: never[] = [];
export function LiveLog({ nodeId }: { nodeId: string }) {
const messages = useWorkspace((s) => s.runtime[nodeId]?.messages ?? EMPTY_MESSAGES);
const clearLive = useWorkspace((s) => s.clearLive);
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
}, [messages.length]);
return (
<div className="ops-card flex min-h-0 flex-1 flex-col overflow-hidden">
<div
className="flex shrink-0 items-center justify-between px-3 py-2"
style={{ borderBottom: "1px solid var(--border)" }}
>
<div className="text-xs font-semibold">消息日志 · {messages.length}</div>
<div className="flex gap-2">
<button className="ops-btn ops-btn-ghost py-0.5" onClick={() => clearLive(nodeId)}>
清空
</button>
</div>
</div>
<div ref={ref} className="ops-scroll min-h-0 flex-1 space-y-1.5 p-3" style={{ background: "var(--code-bg)" }}>
{messages.length === 0 && (
<div className="ops-faint grid h-full place-items-center text-xs">连接后收发消息会显示在这里</div>
)}
{messages.map((m) => (
<div key={m.id} className={cx("ops-msg", `ops-msg-${m.dir === "in" ? "in" : m.dir === "out" ? "out" : m.dir === "error" ? "err" : "sys"}`)}>
<div className="mb-0.5 flex items-center gap-2 text-[10px] uppercase tracking-wide opacity-60">
<span>{formatTime(m.at)}</span>
<span className="rounded px-1" style={{ background: "var(--hover)" }}>
{m.dir === "in" ? "接收" : m.dir === "out" ? "发送" : m.dir === "error" ? "错误" : "系统"}
</span>
{m.topic && <span className="normal-case">{m.topic}</span>}
</div>
<div className="whitespace-pre-wrap break-all">{m.text}</div>
</div>
))}
</div>
</div>
);
}
export function Composer({
value,
onChange,
onSend,
disabled,
placeholder = "消息内容(JSON / 文本)",
sendLabel = "发送",
extra,
}: {
value: string;
onChange: (v: string) => void;
onSend: () => void;
disabled?: boolean;
placeholder?: string;
sendLabel?: string;
extra?: React.ReactNode;
}) {
return (
<div className="ops-card flex shrink-0 flex-col overflow-hidden">
{extra}
<div className="p-2.5">
<textarea
className="ops-code min-h-24 w-full resize-y"
spellCheck={false}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) onSend();
}}
/>
</div>
<div className="flex items-center gap-2 px-2.5 pb-2.5">
<button className="ops-btn ops-btn-primary" disabled={disabled} onClick={onSend}>
{sendLabel}
</button>
<button
className="ops-btn ops-btn-secondary"
onClick={() => {
try {
onChange(JSON.stringify(JSON.parse(value), null, 2));
} catch {
/* ignore */
}
}}
>
格式化 JSON
</button>
<span className="ops-faint ml-auto text-[11px]">⌘/Ctrl + Enter 快速发送</span>
</div>
</div>
);
}
export function LiveStatus({ nodeId, extraStats }: { nodeId: string; extraStats?: React.ReactNode }) {
const runtime = useWorkspace((s) => s.runtime[nodeId]);
const status = runtime?.status ?? "closed";
const statusText: Record<string, string> = {
closed: "未连接",
connecting: "连接中",
open: "已连接",
reconnecting: "重连中",
error: "错误",
};
const elapsed = runtime?.startedAt && status === "open" ? Math.round((Date.now() - runtime.startedAt) / 1000) : null;
return (
<div className="flex items-center gap-3 text-xs">
<span className="flex items-center gap-1.5">
<StatusDot status={status} />
<b className={cx(status === "open" && "ops-success", status === "error" && "ops-danger")}>
{statusText[status] ?? status}
</b>
</span>
<span className="ops-dim">
↑ {runtime?.sent ?? 0} · ↓ {runtime?.received ?? 0}
</span>
{elapsed !== null && <span className="ops-faint">{formatDuration(elapsed)}</span>}
{extraStats}
</div>
);
}
function formatDuration(sec: number): string {
if (sec < 60) return `${sec}s`;
if (sec < 3600) return `${Math.floor(sec / 60)}m${sec % 60}s`;
return `${Math.floor(sec / 3600)}h${Math.floor((sec % 3600) / 60)}m`;
}
+119
View File
@@ -0,0 +1,119 @@
/**
* 内置 SVG 图标(源自 lucide,ISC License:https://lucide.dev)
* 统一 24 网格 / currentColor 描边,尺寸由 size 控制。
*/
import type { CSSProperties } from "react";
export type IconName =
| "plus"
| "download"
| "upload"
| "globe"
| "moon"
| "sun"
| "ellipsis"
| "book-open"
| "library"
| "folder"
| "folder-open"
| "folder-plus"
| "file-plus"
| "file-text"
| "pencil"
| "chevron-right"
| "chevron-down"
| "zap"
| "radio"
| "plug-zap"
| "mailbox"
| "hexagon"
| "x"
| "trash"
| "copy"
| "play"
| "unplug"
| "eraser"
| "code"
| "clock"
| "settings"
| "arrow-left-right"
| "shield-check"
| "search"
| "send"
| "circle-slash"
| "info";
const ICONS: Record<IconName, string> = {
"plus": "<path d=\"M5 12h14\" /> <path d=\"M12 5v14\" />",
"download": "<path d=\"M12 15V3\" /> <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\" /> <path d=\"m7 10 5 5 5-5\" />",
"upload": "<path d=\"M12 3v12\" /> <path d=\"m17 8-5-5-5 5\" /> <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\" />",
"globe": "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <path d=\"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20\" /> <path d=\"M2 12h20\" />",
"moon": "<path d=\"M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401\" />",
"sun": "<circle cx=\"12\" cy=\"12\" r=\"4\" /> <path d=\"M12 2v2\" /> <path d=\"M12 20v2\" /> <path d=\"m4.93 4.93 1.41 1.41\" /> <path d=\"m17.66 17.66 1.41 1.41\" /> <path d=\"M2 12h2\" /> <path d=\"M20 12h2\" /> <path d=\"m6.34 17.66-1.41 1.41\" /> <path d=\"m19.07 4.93-1.41 1.41\" />",
"ellipsis": "<circle cx=\"12\" cy=\"12\" r=\"1\" /> <circle cx=\"19\" cy=\"12\" r=\"1\" /> <circle cx=\"5\" cy=\"12\" r=\"1\" />",
"book-open": "<path d=\"M12 5v16\" /> <path d=\"M20.001 19A2 2 0 0022 17V5a2 2 0 00-1.999-2L16 3.002A5 5 0 0012 5a5 5 0 00-4-2H4a2 2 0 00-2 2v12a2 2 0 001.999 2H8a5 5 0 014 2 5 5 0 014-2z\" />",
"library": "<path d=\"m16 6 4 14\" /> <path d=\"M12 6v14\" /> <path d=\"M8 8v12\" /> <path d=\"M4 4v16\" />",
"folder": "<path d=\"M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z\" />",
"folder-open": "<path d=\"m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2\" />",
"folder-plus": "<path d=\"M12 10v6\" /> <path d=\"M9 13h6\" /> <path d=\"M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z\" />",
"file-plus": "<path d=\"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z\" /> <path d=\"M14 2v5a1 1 0 0 0 1 1h5\" /> <path d=\"M9 15h6\" /> <path d=\"M12 18v-6\" />",
"file-text": "<path d=\"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z\" /> <path d=\"M14 2v5a1 1 0 0 0 1 1h5\" /> <path d=\"M10 9H8\" /> <path d=\"M16 13H8\" /> <path d=\"M16 17H8\" />",
"pencil": "<path d=\"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z\" /> <path d=\"m15 5 4 4\" />",
"chevron-right": "<path d=\"m9 18 6-6-6-6\" />",
"chevron-down": "<path d=\"m6 9 6 6 6-6\" />",
"zap": "<path d=\"M15.914 4a1.5 1.5 0 00-2.474-1.561l-9 9A1.5 1.5 0 005.5 14h4.002a.5.5 0 01.471.666L8.086 20a1.5 1.5 0 002.475 1.56l9-9A1.5 1.5 0 0018.5 10h-3.997a.5.5 0 01-.472-.667z\" />",
"radio": "<path d=\"M16.247 7.761a6 6 0 0 1 0 8.478\" /> <path d=\"M19.075 4.933a10 10 0 0 1 0 14.134\" /> <path d=\"M4.925 19.067a10 10 0 0 1 0-14.134\" /> <path d=\"M7.753 16.239a6 6 0 0 1 0-8.478\" /> <circle cx=\"12\" cy=\"12\" r=\"2\" />",
"plug-zap": "<path d=\"M6.3 20.3a2.4 2.4 0 0 0 3.4 0L12 18l-6-6-2.3 2.3a2.4 2.4 0 0 0 0 3.4Z\" /> <path d=\"m2 22 3-3\" /> <path d=\"M7.5 13.5 10 11\" /> <path d=\"M10.5 16.5 13 14\" /> <path d=\"m18 3-4 4h6l-4 4\" />",
"mailbox": "<path d=\"M22 17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9.5C2 7 4 5 6.5 5H18c2.2 0 4 1.8 4 4v8Z\" /> <polyline points=\"15,9 18,9 18,11\" /> <path d=\"M6.5 5C9 5 11 7 11 9.5V17a2 2 0 0 1-2 2\" /> <line x1=\"6\" x2=\"7\" y1=\"10\" y2=\"10\" />",
"hexagon": "<path d=\"M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z\" />",
"x": "<path d=\"M18 6 6 18\" /> <path d=\"m6 6 12 12\" />",
"trash": "<path d=\"M10 11v6\" /> <path d=\"M14 11v6\" /> <path d=\"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6\" /> <path d=\"M3 6h18\" /> <path d=\"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2\" />",
"copy": "<rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\" /> <path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\" />",
"play": "<path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\" />",
"unplug": "<path d=\"m19 5 3-3\" /> <path d=\"m2 22 3-3\" /> <path d=\"M6.3 20.3a2.4 2.4 0 0 0 3.4 0L12 18l-6-6-2.3 2.3a2.4 2.4 0 0 0 0 3.4Z\" /> <path d=\"M7.5 13.5 10 11\" /> <path d=\"M10.5 16.5 13 14\" /> <path d=\"m12 6 6 6 2.3-2.3a2.4 2.4 0 0 0 0-3.4l-2.6-2.6a2.4 2.4 0 0 0-3.4 0Z\" />",
"eraser": "<path d=\"M21 21H8a2 2 0 0 1-1.42-.587l-3.994-3.999a2 2 0 0 1 0-2.828l10-10a2 2 0 0 1 2.829 0l5.999 6a2 2 0 0 1 0 2.828L12.834 21\" /> <path d=\"m5.082 11.09 8.828 8.828\" />",
"code": "<path d=\"m16 18 6-6-6-6\" /> <path d=\"m8 6-6 6 6 6\" />",
"clock": "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <path d=\"M12 6v6l4 2\" />",
"settings": "<path d=\"M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915\" /> <circle cx=\"12\" cy=\"12\" r=\"3\" />",
"arrow-left-right": "<path d=\"M8 3 4 7l4 4\" /> <path d=\"M4 7h16\" /> <path d=\"m16 21 4-4-4-4\" /> <path d=\"M20 17H4\" />",
"info": "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <path d=\"M12 16v-4\" /> <path d=\"M12 8h.01\" />",
"circle-slash": "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <line x1=\"9\" x2=\"15\" y1=\"15\" y2=\"9\" />",
"send": "<path d=\"M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z\" /> <path d=\"m21.854 2.147-10.94 10.939\" />",
"shield-check": "<path d=\"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z\" /> <path d=\"m9 12 2 2 4-4\" />",
"search": "<path d=\"m21 21-4.34-4.34\" /> <circle cx=\"11\" cy=\"11\" r=\"8\" />",
};
export function Icon({
name,
size = 16,
strokeWidth = 2,
className,
style,
title,
}: {
name: IconName;
size?: number;
strokeWidth?: number;
className?: string;
style?: CSSProperties;
title?: string;
}) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
style={{ flexShrink: 0, ...style }}
role={title ? "img" : undefined}
aria-label={title}
aria-hidden={title ? undefined : "true"}
dangerouslySetInnerHTML={{ __html: ICONS[name] }}
/>
);
}
+3
View File
@@ -0,0 +1,3 @@
export { OpsxWorkspace } from "./App";
export { useWorkspace } from "./workspaceStore";
export type { Dialog, LiveMessage, RequestRuntime } from "./workspaceStore";
+452
View File
@@ -0,0 +1,452 @@
/**
* 发送/连接逻辑:HTTP、GraphQL、WebSocket、SSE、Socket.IO、MQTT。
* 实时会话注册表不进 React 状态,只把消息/状态写入 store。
*/
import type {
GraphqlRequestData,
HttpRequestData,
HttpSpec,
RequestNode,
} from "@opsxapi/core";
import { replaceVariables } from "@opsxapi/core";
import {
connectMqtt,
connectSocketIo,
openSse,
wsSmart,
type MqttSession,
type SocketIoSession,
type StreamSession,
} from "@opsxapi/transport";
import type { WsSession } from "@opsxapi/core";
import { activeEnvMap, useWorkspace } from "./workspaceStore";
type Session = { close(): void };
const sessions = new Map<string, Session>();
export function closeSession(id: string): void {
sessions.get(id)?.close();
sessions.delete(id);
}
export function hasSession(id: string): boolean {
return sessions.has(id);
}
/** KV 行 → 对象(含变量替换) */
function kvMap(
rows: { key: string; value: string; enabled: boolean }[],
env: Record<string, string>,
): Record<string, string> {
const out: Record<string, string> = {};
for (const r of rows) {
if (!r.enabled || !r.key.trim()) continue;
out[replaceVariables(r.key.trim(), env)] = replaceVariables(r.value, env);
}
return out;
}
function authHeaders(
auth: HttpRequestData["auth"],
env: Record<string, string>,
): { headers: Record<string, string>; query: Record<string, string> } {
const headers: Record<string, string> = {};
const query: Record<string, string> = {};
switch (auth.mode) {
case "bearer":
if (auth.bearer) headers.Authorization = `Bearer ${replaceVariables(auth.bearer, env)}`;
break;
case "basic": {
const raw = `${replaceVariables(auth.basicUser ?? "", env)}:${replaceVariables(auth.basicPass ?? "", env)}`;
try {
headers.Authorization = `Basic ${btoa(raw)}`;
} catch {
/* ignore */
}
break;
}
case "apikey":
if (auth.apiKeyName) {
const value = replaceVariables(auth.apiKeyValue ?? "", env);
if (auth.apiKeyIn === "query") query[auth.apiKeyName] = value;
else headers[auth.apiKeyName] = value;
}
break;
default:
break;
}
return { headers, query };
}
/** 手工构建 multipart/form-data 原始体 */
function buildMultipart(rows: HttpRequestData["bodyForm"], env: Record<string, string>): string {
const boundary = `----OpsxAPI${Math.random().toString(36).slice(2)}`;
const parts: string[] = [];
for (const row of rows) {
if (!row.enabled || !row.key.trim()) continue;
parts.push(
`--${boundary}\r\nContent-Disposition: form-data; name="${row.key}"\r\n\r\n${replaceVariables(row.value, env)}\r\n`,
);
}
parts.push(`--${boundary}--\r\n`);
return parts.join("");
}
export function buildHttpSpec(
data: HttpRequestData,
env: Record<string, string>,
timeoutMs: number,
): HttpSpec {
const { headers: authH, query: authQ } = authHeaders(data.auth, env);
const headers = { ...kvMap(data.headers, env), ...authH };
const cookie = kvMap(data.cookies, env);
const cookieLine = Object.entries(cookie).map(([k, v]) => `${k}=${v}`).join("; ");
if (cookieLine && !("cookie" in headers)) headers.Cookie = cookieLine;
let body: NonNullable<HttpSpec["body"]> = { mode: "none" };
if (data.bodyMode === "urlencoded") {
const encoded = Object.entries(kvMap(data.bodyForm, env))
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
body = { mode: "urlencoded", raw: encoded };
if (!("content-type" in headers)) headers["Content-Type"] = "application/x-www-form-urlencoded";
} else if (data.bodyMode === "form-data") {
const raw = buildMultipart(data.bodyForm, env);
body = { mode: "form-data", raw };
const m = raw.match(/^----\S+/);
headers["Content-Type"] = `multipart/form-data${m ? `; boundary=${m[0]}` : ""}`;
} else if (data.bodyMode !== "none") {
body = { mode: data.bodyMode, raw: replaceVariables(data.bodyRaw, env) };
if (data.bodyMode === "json" && !("content-type" in headers)) {
headers["Content-Type"] = "application/json";
}
if (data.bodyMode === "xml" && !("content-type" in headers)) {
headers["Content-Type"] = "application/xml";
}
}
return {
method: data.method,
url: replaceVariables(data.url, env),
headers,
query: { ...kvMap(data.query, env), ...authQ },
body,
timeoutMs,
};
}
async function getTransport() {
const { detectTransport } = await import("@opsxapi/transport");
const { kind, transport } = detectTransport({ ginBaseUrl: "" });
return { kind, transport };
}
async function sendHttpLike(
id: string,
label: string,
spec: HttpSpec,
url: string,
): Promise<void> {
const store = useWorkspace.getState();
store.setRuntime(id, { loading: true, error: null });
const started = performance.now();
const { transport } = await getTransport();
try {
const result = await transport.http(spec);
useWorkspace.getState().setRuntime(id, { loading: false, result });
useWorkspace.getState().addHistory({
id: crypto.randomUUID(),
at: Date.now(),
kind: "http",
label,
method: spec.method,
url,
status: result.status,
durationMs: result.timeMs,
ok: result.status < 400,
nodeId: id,
});
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
useWorkspace.getState().setRuntime(id, { loading: false, error: message });
useWorkspace.getState().addHistory({
id: crypto.randomUUID(),
at: Date.now(),
kind: "http",
label,
method: spec.method,
url,
ok: false,
nodeId: id,
});
void started;
}
}
export function sendHttpRequest(node: RequestNode & { kind: "http" }): void {
const env = activeEnvMap();
const { timeoutMs } = useWorkspace.getState().settings;
const spec = buildHttpSpec(node.data, env, timeoutMs);
void sendHttpLike(node.id, node.name, spec, node.data.url);
}
export function sendGraphqlRequest(node: RequestNode & { kind: "graphql" }): void {
const env = activeEnvMap();
const { timeoutMs } = useWorkspace.getState().settings;
const data = node.data as GraphqlRequestData;
let variablesRaw: string = "{}";
try {
variablesRaw = JSON.stringify(JSON.parse(replaceVariables(data.variables || "{}", env)));
} catch {
variablesRaw = replaceVariables(data.variables || "{}", env);
}
const payload = JSON.stringify(
{ query: replaceVariables(data.query, env), variables: JSON.parse(variablesRaw) },
null,
2,
);
const headers = kvMap(data.headers, env);
const { headers: authH } = authHeaders(data.auth, env);
Object.assign(headers, authH);
if (!("content-type" in headers)) headers["Content-Type"] = "application/json";
const spec: HttpSpec = {
method: "POST",
url: replaceVariables(data.url, env),
headers,
query: {},
body: { mode: "json", raw: payload },
timeoutMs,
};
void sendHttpLike(node.id, node.name, spec, data.url);
}
/* ---------------- WebSocket ---------------- */
export function connectWs(node: RequestNode & { kind: "websocket" }): void {
const store = useWorkspace.getState();
if (sessions.has(node.id)) {
closeSession(node.id);
return;
}
const env = activeEnvMap();
const data = node.data;
const url = replaceVariables(data.url, env);
const headers = kvMap(data.headers, env);
const protocols = data.protocols
.split(",")
.map((s) => replaceVariables(s.trim(), env))
.filter(Boolean);
store.setRuntime(node.id, { status: "connecting", startedAt: Date.now(), error: null });
store.pushLive(node.id, "system", `正在连接 ${url}${Object.keys(headers).length ? "(自定义 Header 走 Gin 隧道)" : ""}`);
const session = wsSmart({ url, headers, protocols }, {
ginBase: "",
onInfo: (info) => {
if (info.mode === "tunnel") {
store.pushLive(node.id, "system", `已通过 Gin 隧道建立(会话 ${info.sessionId?.slice(0, 8)}…)`);
}
},
});
sessions.set(node.id, session);
session.onStatus((s) => {
useWorkspace.getState().setRuntime(node.id, { status: s });
if (s === "error") useWorkspace.getState().pushLive(node.id, "error", "连接出错");
if (s === "closed") {
useWorkspace.getState().pushLive(node.id, "system", "连接已关闭");
sessions.delete(node.id);
}
});
session.onMessage((msg) => {
useWorkspace.getState().pushLive(node.id, "in", msg);
});
}
export function wsSend(id: string, text: string): void {
const session = sessions.get(id);
if (!session) return;
(session as WsSession).send(text);
useWorkspace.getState().pushLive(id, "out", text);
}
/* ---------------- SSE ---------------- */
export function connectSse(node: RequestNode & { kind: "sse" }): void {
const store = useWorkspace.getState();
if (sessions.has(node.id)) {
closeSession(node.id);
return;
}
const env = activeEnvMap();
const data = node.data;
const url = replaceVariables(data.url, env);
const { headers: authH, query: authQ } = authHeaders(data.auth, env);
const headers = { ...kvMap(data.headers, env), ...authH };
const query = { ...kvMap(data.query, env), ...authQ };
store.setRuntime(node.id, { status: "connecting", startedAt: Date.now(), error: null });
store.pushLive(node.id, "system", `正在连接 ${url}`);
const session = openSse({
url,
headers,
query,
lastEventId: data.lastEventId ? replaceVariables(data.lastEventId, env) : undefined,
ginBase: "",
onEvent: (ev) => {
let text = ev.data;
if (data.dataOnly && ev.event === "message") {
text = tryFormatJson(ev.data);
} else {
text = `event: ${ev.event}\n${tryFormatJson(ev.data)}`;
}
useWorkspace.getState().pushLive(node.id, "in", text, ev.event);
},
onStatus: (s, meta) => {
useWorkspace.getState().setRuntime(node.id, { status: s });
if (s === "open" && meta) {
useWorkspace
.getState()
.pushLive(node.id, "system", `已连接 · HTTP ${meta.status} ${meta.headers["content-type"] ?? ""}`);
}
if (s === "closed") {
useWorkspace.getState().pushLive(node.id, "system", "连接已结束");
}
},
onError: (msg, willFallback) => {
if (willFallback) {
useWorkspace.getState().pushLive(node.id, "system", `直连失败(${msg.slice(0, 120)}),切换 Gin 代理…`);
} else {
useWorkspace.getState().pushLive(node.id, "error", msg);
}
},
});
const wrapper: Session = { close: () => session.abort() };
sessions.set(node.id, wrapper);
}
/* ---------------- Socket.IO ---------------- */
export function connectSocketIoSession(node: RequestNode & { kind: "socketio" }): void {
const store = useWorkspace.getState();
if (sessions.has(node.id)) {
closeSession(node.id);
return;
}
const env = activeEnvMap();
const data = node.data;
const url = replaceVariables(data.url, env);
store.setRuntime(node.id, { status: "connecting", startedAt: Date.now(), error: null });
store.pushLive(node.id, "system", `正在连接 ${url}`);
const session = connectSocketIo({
url,
optionsJson: data.options,
listenEvents: data.listenEvents,
onEvent: (event, args) => {
const text = args.length === 1 ? prettyMaybe(args[0]) : JSON.stringify(args, null, 2);
useWorkspace.getState().pushLive(node.id, "in", text, event);
},
onStatus: (s, detail) => {
useWorkspace.getState().setRuntime(node.id, { status: s });
if (s === "open") useWorkspace.getState().pushLive(node.id, "system", `已连接 ${detail ? `(${detail})` : ""}`);
if (s === "closed") {
useWorkspace.getState().pushLive(node.id, "system", `已断开${detail ? `:${detail}` : ""}`);
sessions.delete(node.id);
}
},
onError: (msg) => useWorkspace.getState().pushLive(node.id, "error", msg),
});
sessions.set(node.id, session as unknown as Session);
}
export function socketIoEmit(id: string, event: string, argsJson: string): boolean {
const session = sessions.get(id);
if (!session) return false;
let args: unknown[] = [];
try {
const parsed = JSON.parse(argsJson || "[]");
args = Array.isArray(parsed) ? parsed : [parsed];
} catch {
useWorkspace.getState().pushLive(id, "error", "参数不是合法 JSON");
return false;
}
(session as SocketIoSession).emit(event, args);
useWorkspace.getState().pushLive(id, "out", JSON.stringify({ event, args }, null, 2), event);
return true;
}
/* ---------------- MQTT ---------------- */
export function connectMqttSession(node: RequestNode & { kind: "mqtt" }): void {
const store = useWorkspace.getState();
if (sessions.has(node.id)) {
closeSession(node.id);
return;
}
const env = activeEnvMap();
const data = node.data;
const url = replaceVariables(data.brokerUrl, env);
const topics = data.subscribeTopics.split(",").map((t) => replaceVariables(t.trim(), env)).filter(Boolean);
store.setRuntime(node.id, { status: "connecting", startedAt: Date.now(), error: null });
store.pushLive(node.id, "system", `正在连接 ${url}${topics.length ? `,订阅 ${topics.join(", ")}` : ""}`);
const session = connectMqtt({
brokerUrl: url,
username: replaceVariables(data.username, env),
password: replaceVariables(data.password, env),
clientId: replaceVariables(data.clientId, env),
topics,
qos: data.qos,
onMessage: (msg) => {
useWorkspace.getState().pushLive(node.id, "in", prettyMaybeText(msg.payload), msg.topic);
},
onStatus: (s, detail) => {
useWorkspace.getState().setRuntime(node.id, { status: s });
if (s === "open") useWorkspace.getState().pushLive(node.id, "system", `已连接 broker${detail ? `:${detail}` : ""}`);
if (s === "reconnecting") useWorkspace.getState().pushLive(node.id, "system", "正在重连…");
if (s === "closed") {
useWorkspace.getState().pushLive(node.id, "system", "连接已关闭");
sessions.delete(node.id);
}
},
onError: (msg) => useWorkspace.getState().pushLive(node.id, "error", msg),
});
sessions.set(node.id, session as unknown as Session);
}
export function mqttPublish(id: string, node: RequestNode & { kind: "mqtt" }): void {
const session = sessions.get(id) as (MqttSession & { close(): void }) | undefined;
if (!session) return;
const env = activeEnvMap();
const topic = replaceVariables(node.data.publishTopic, env);
const payload = replaceVariables(node.data.publishPayload, env);
session.publish(topic, payload, node.data.qos);
useWorkspace.getState().pushLive(id, "out", prettyMaybeText(payload), topic);
}
function tryFormatJson(s: string): string {
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return s;
}
}
function prettyMaybe(v: unknown): string {
try {
return JSON.stringify(v, null, 2);
} catch {
return String(v);
}
}
function prettyMaybeText(s: string): string {
return tryFormatJson(s);
}
export type { StreamSession };
+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 };