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

@@ -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("[");
}