Files
opsxapi/packages/ui/src/components/editors/ResponsePanel.tsx
T
logan 39f4d5addc 桌面应用图标替换为 B-brackets 设计(深藏青 + 白/蓝花括号)
- icon.svg 取自 opsx-icon-options/api/B-brackets.svg,全套光栅重生成:
  icon.icns(iconutil)、icon.ico(PNG-in-ICO 6 尺寸)、16-1024 PNG、Windows Square*/StoreLogo
- 替换 Tauri 脚手架默认五角星图标;GUI 重启实测 Dock 图标已生效
- 服务全量重启:server 8787(官网/文档 200)+ web 5173 + 桌面 GUI
2026-09-20 00:14:32 +08:00

128 lines
4.5 KiB
TypeScript

/**
* HTTP / GraphQL 响应面板:Pretty / Raw / 响应头 + 状态条。
*/
import { useMemo } from "react";
import { useWorkspace } from "../../workspaceStore";
import { CopyButton, JsonView, cx, formatBytes } from "../common";
import { ProxyErrorBanner } from "../ProxyErrorBanner";
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 className={cx(
"ops-status-pill",
result.status >= 500 ? "ops-status-5xx" : result.status >= 400 ? "ops-status-4xx" : result.status >= 300 ? "ops-status-3xx" : "ops-status-2xx",
)}>
{result.status}
</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 && (
<ProxyErrorBanner
message={error}
onDismiss={() => setRuntime(nodeId, { error: null })}
/>
)}
{!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("[");
}