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

+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>
);
}