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:
commit
dec66db5aa
182 files changed
+23377
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user