桌面 GUI 外链修复 + 主题顺序调整(含结构扁平化进行中的工作区基线)

- 集成 tauri-plugin-opener:Cargo/lib.rs/capabilities/npm 四处接线,新增 src/openExternal.ts(桌面走 opener,浏览器回退 window.open,相对路径绝对化)
- 关于页 4 个链接、活动栏官网菜单、代理错误横幅 2 处健康检查链接全部改走 openExternal,桌面端可正常跳转
- 设置→外观 主题顺序调整为 深色/浅色/跟随系统
- release 二进制重编并验证(浏览器实测关于页链接可开文档页)
- 附带工作区中未提交的扁平化重构现状(另一会话进行中)
- homepage 落地页与文档终检通过(8787 / 与 /docs 双 200)
This commit is contained in:
logan committed 2026-10-16 03:03:36 +08:00
1 parent 6452026fd6
commit 2bb4ef7a95
299 files changed
+6059 -3322

No files matched your search

+247
View File
@@ -0,0 +1,247 @@
/**
* 最左活动栏:主导航 + 底部账号。
* 本地/团队工作区只在底部登录/头像菜单切换(设计师线框 B);不再显示顶部「本」字标。
*/
import { useState } from "react";
import { useWorkspace } from "../workspaceStore";
import { openExternal } from "../openExternal";
import { ContextMenu, cx, type MenuItem } from "./common";
import { Icon, type IconName } from "./icons";
export function ActivityRail() {
const view = useWorkspace((s) => s.view);
const setView = useWorkspace((s) => s.setView);
const setSidebarSide = useWorkspace((s) => s.setSidebarSide);
const account = useWorkspace((s) => s.account);
const switchSpace = useWorkspace((s) => s.switchSpace);
const authLogout = useWorkspace((s) => s.authLogout);
const syncNow = useWorkspace((s) => s.syncNow);
const setDialog = useWorkspace((s) => s.setDialog);
const [accountMenu, setAccountMenu] = useState<{ x: number; y: number } | null>(null);
const activeSpace = account.spaces.find((sp) => sp.id === account.activeSpaceId) ?? null;
const isTeam = activeSpace?.teamId != null;
const spaceName = activeSpace
? `${isTeam ? "团队 · " : ""}${activeSpace.name}`
: "本地工作区";
const siteItem: MenuItem = {
label: (
<span className="flex items-center gap-2">
官网与价格
<Icon name="external-link" size={11} className="ops-faint" />
</span>
),
icon: <Icon name="globe" size={14} />,
onClick: () => void openExternal("/home"),
};
const workspaceItems: MenuItem[] = [
{
label: (
<span className="flex min-w-0 items-center gap-2">
<span className="rail-ws-dot local" aria-hidden />
<span className="truncate">本地工作区</span>
{account.activeSpaceId === null && (
<span className="ml-auto pl-3 text-[11px] ops-success">● 当前</span>
)}
</span>
),
onClick: () => void switchSpace(null),
},
...account.spaces.map((sp) => {
const team = sp.teamId != null;
return {
label: (
<span className="flex min-w-0 items-center gap-2">
<span className={cx("rail-ws-dot", team ? "team" : "local")} aria-hidden />
<span className="truncate">{team ? `团队 · ${sp.name}` : sp.name}</span>
{sp.id === account.activeSpaceId ? (
<span className="ml-auto pl-3 text-[11px] ops-success">● 当前</span>
) : (
<span className="ops-faint ml-auto pl-3 text-[11px]">
{new Date(sp.updatedAt * 1000).toLocaleDateString("zh-CN")}
</span>
)}
</span>
),
onClick: () => void switchSpace(sp.id),
};
}),
];
const accountItems: MenuItem[] = account.user
? [
...workspaceItems,
{ type: "sep" as const },
{
label: "环境变量",
icon: <Icon name="settings" size={14} />,
onClick: () => setDialog({ type: "environments" }),
},
{
label: "账号设置",
icon: <Icon name="settings" size={14} />,
onClick: () => setDialog({ type: "settings" }),
},
...(account.activeSpaceId !== null
? [
{
label: account.syncing
? "同步中…"
: account.lastSyncAt
? `已同步 ${new Date(account.lastSyncAt).toLocaleTimeString("zh-CN")}`
: "立即同步到云端",
icon: <Icon name="arrow-left-right" size={14} />,
onClick: () => void syncNow(),
},
]
: []),
{
label: "团队管理…",
icon: <Icon name="library" size={14} />,
onClick: () => setDialog({ type: "team" }),
},
{ type: "sep" as const },
siteItem,
{
label: "退出",
icon: <Icon name="circle-x" size={14} />,
onClick: () => void authLogout(),
},
]
: [
{
label: "登录 / 注册",
icon: <Icon name="shield-check" size={14} />,
onClick: () => setDialog({ type: "auth" }),
},
{ type: "sep" as const },
{
label: "本地工作区",
icon: <Icon name="shield-check" size={14} />,
onClick: () => void switchSpace(null),
},
{
label: "环境变量",
icon: <Icon name="settings" size={14} />,
onClick: () => setDialog({ type: "environments" }),
},
{ type: "sep" as const },
siteItem,
];
const navs: { id: string; icon: IconName; label: string; active: boolean; onClick: () => void; soon?: boolean }[] = [
{
id: "api",
icon: "globe",
label: "接口",
active: view === "api",
onClick: () => {
setView("api");
setSidebarSide("api");
},
},
{
id: "docs",
icon: "library",
label: "文档",
active: view === "docs",
onClick: () => setView("docs"),
},
{
id: "mock",
icon: "flask-conical",
label: "Mock",
active: view === "mock",
onClick: () => setView("mock"),
},
{
id: "testing",
icon: "list-checks",
label: "测试",
active: view === "testing",
onClick: () => setView("testing"),
},
];
const syncTitle = !account.user
? "登录后可切换团队工作区;未登录也可继续用本地"
: `${account.user.name}(${account.user.email})\n当前:${spaceName}${
account.syncing
? "\n同步中…"
: account.syncError
? `\n同步失败:${account.syncError}`
: account.lastSyncAt && account.activeSpaceId !== null
? `\n已同步 ${new Date(account.lastSyncAt).toLocaleTimeString("zh-CN")}`
: ""
}`;
return (
<nav
className="ops-panel flex w-14 shrink-0 flex-col items-center gap-1 py-2"
style={{ borderRight: "1px solid var(--border)" }}
>
{/* 主导航:不再放顶部「本」字标 / 空间切换 */}
{navs.map((n) => (
<button
key={n.id}
className={cx("rail-item", n.active && "active", n.soon && "opacity-35 grayscale")}
title={n.soon ? `${n.label} · 即将推出` : n.label}
onClick={n.onClick}
>
<Icon name={n.icon} size={17} />
<span className="text-[11px]">{n.label}</span>
</button>
))}
<div className="flex-1" />
<button className="rail-item" title="设置" onClick={() => setDialog({ type: "settings" })}>
<Icon name="settings" size={16} />
<span className="text-[11px]">设置</span>
</button>
<button
className={cx("rail-item", !account.user && "rail-login")}
title={syncTitle}
onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setAccountMenu((m) => (m ? null : { x: rect.right + 4, y: Math.max(8, rect.bottom - 240) }));
}}
>
<span className="relative">
{account.user ? (
<span className="ops-user-avatar" style={{ width: 22, height: 22, fontSize: 11 }}>
{account.user.name.slice(0, 1).toUpperCase()}
</span>
) : (
<Icon name="shield-check" size={17} />
)}
{account.user ? (
<span
className={cx(
"rail-ws-badge",
account.activeSpaceId === null ? "local" : isTeam ? "team" : "cloud",
)}
title={spaceName}
/>
) : null}
{account.user && account.activeSpaceId !== null && (
<span
className={cx(
"rail-sync-dot",
account.syncing ? "syncing" : account.syncError ? "error" : "ok",
)}
/>
)}
</span>
<span className="max-w-11 truncate text-[11px]">{account.user ? account.user.name : "登录"}</span>
</button>
<ContextMenu
menu={accountMenu ? { x: accountMenu.x, y: accountMenu.y, items: accountItems } : null}
onClose={() => setAccountMenu(null)}
/>
</nav>
);
}
+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>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import type { Environment } from "../core/model";
import { EnvBadge } from "./EnvBadge";
import { Icon } from "./icons";
export function EnvironmentSwitcher({ environments, activeId, onSelect }: {
environments: Environment[];
activeId: string | null;
onSelect: (id: string | null) => void;
}) {
const [open, setOpen] = useState(false);
const root = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const menu = useRef<HTMLDivElement>(null);
const active = environments.find((env) => env.id === activeId);
useEffect(() => {
if (!open) return;
const selected = menu.current?.querySelector<HTMLElement>('[aria-checked="true"]');
(selected ?? menu.current?.querySelector<HTMLElement>('[role="menuitemradio"]'))?.focus();
const closeOutside = (event: PointerEvent) => {
if (event.target instanceof Node && !root.current?.contains(event.target)) setOpen(false);
};
const closeOnResize = () => setOpen(false);
window.addEventListener("pointerdown", closeOutside, true);
window.addEventListener("resize", closeOnResize);
return () => {
window.removeEventListener("pointerdown", closeOutside, true);
window.removeEventListener("resize", closeOnResize);
};
}, [open]);
const choose = (id: string | null) => {
onSelect(id);
setOpen(false);
trigger.current?.focus();
};
const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
trigger.current?.focus();
return;
}
if (event.key === "Tab") {
setOpen(false);
return;
}
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const options = Array.from(menu.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]') ?? []);
if (!options.length) return;
const current = options.indexOf(document.activeElement as HTMLButtonElement);
const next = event.key === "Home" ? 0 : event.key === "End" ? options.length - 1
: (current + (event.key === "ArrowDown" ? 1 : -1) + options.length) % options.length;
options[next].focus();
};
return <div className="env-switcher" ref={root}>
<button ref={trigger} type="button" className="env-switch-trigger" aria-label={`当前环境:${active?.name ?? "不使用环境"}`}
aria-haspopup="menu" aria-expanded={open} aria-controls={open ? "env-switch-menu" : undefined}
title={active?.baseUrl || "选择请求环境"} onClick={() => setOpen((value) => !value)}>
{active ? <EnvBadge envId={active.id} name={active.name} size={18} /> : <Icon name="circle-slash" size={16} className="ops-faint" />}
<span className="truncate">{active?.name ?? "不使用环境"}</span>
<Icon name="chevron-down" size={14} className="ops-faint ml-auto shrink-0" />
</button>
{open && <div id="env-switch-menu" ref={menu} role="menu" aria-label="切换请求环境" className="env-switch-menu" onKeyDown={onMenuKeyDown}>
<div className="env-switch-heading">请求环境</div>
<button type="button" role="menuitemradio" aria-checked={!active} className="env-switch-option" onClick={() => choose(null)}>
<Icon name="circle-slash" size={17} className="ops-faint shrink-0" />
<span className="min-w-0 flex-1 text-left"><span className="block font-semibold">不使用环境</span><span className="ops-faint block text-[11px]">直接使用请求中填写的完整地址</span></span>
{!active && <Icon name="circle-check" size={15} className="ops-accent shrink-0" />}
</button>
<div className="env-switch-divider" />
<div className="env-switch-options">
{environments.map((env) => <button type="button" role="menuitemradio" aria-checked={env.id === activeId}
key={env.id} className="env-switch-option" onClick={() => choose(env.id)}>
<EnvBadge envId={env.id} name={env.name} size={22} />
<span className="min-w-0 flex-1 text-left"><span className="block truncate font-semibold">{env.name}</span><span className="ops-faint block truncate text-[11px]" title={env.baseUrl || "未设置前置 URL"}>{env.baseUrl || "未设置前置 URL"}</span></span>
{env.id === activeId && <Icon name="circle-check" size={15} className="ops-accent shrink-0" />}
</button>)}
</div>
{environments.length === 0 && <p className="ops-faint px-3 py-2 text-xs">暂无环境,可点击左侧“管理环境”创建。</p>}
</div>}
</div>;
}
+47
View File
@@ -0,0 +1,47 @@
/**
* 协议彩色图标:饱和渐变圆角方块 + 白色图形(对齐 Apifox / Apipost 的新建面板视觉)。
* 新建面板、新建弹窗、侧栏树、标签页共用同一套配色,保证全应用一致。
*/
import type { RequestKind } from "../core/index";
import { Icon, type IconName } from "./icons";
export const KIND_COLORS: Record<RequestKind | "folder", { color: string; icon: IconName; label: string }> = {
http: { color: "#3b82f6", icon: "globe", label: "HTTP" },
graphql: { color: "#ec4899", icon: "hexagon", label: "GraphQL" },
websocket: { color: "#f97316", icon: "zap", label: "WebSocket" },
sse: { color: "#0ea5e9", icon: "radio", label: "SSE" },
socketio: { color: "#14b8a6", icon: "plug-zap", label: "Socket.IO" },
mqtt: { color: "#10b981", icon: "mailbox", label: "MQTT" },
folder: { color: "#8b5cf6", icon: "folder-plus", label: "目录" },
};
export function KindIcon({
kind,
size = 28,
className,
}: {
kind: RequestKind | "folder";
size?: number;
className?: string;
}) {
const meta = KIND_COLORS[kind];
return (
<span
className={className}
aria-hidden="true"
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: size,
height: size,
borderRadius: Math.max(5, Math.round(size * 0.3)),
background: `linear-gradient(135deg, ${meta.color}, color-mix(in srgb, ${meta.color} 72%, #000))`,
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.25), 0 1px 2px rgba(0,0,0,0.18)",
flexShrink: 0,
}}
>
<Icon name={meta.icon} size={Math.round(size * 0.56)} style={{ color: "#fff", strokeWidth: 2.2 }} />
</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>
);
}
+100
View File
@@ -0,0 +1,100 @@
/**
* 「新建」卡片式下拉面板(对齐 Apifox / Apipost):
* 双列网格 + 彩色协议图标 + 分组标题 + 通栏次级行。
*/
import type { RequestKind } from "../core/index";
import { useEffect } from "react";
import { KIND_COLORS, KindIcon } from "./KindIcon";
import { Icon } from "./icons";
const PANEL_KINDS: { kind: RequestKind; desc: string }[] = [
{ kind: "http", desc: "REST 接口" },
{ kind: "graphql", desc: "查询 + 变量" },
{ kind: "websocket", desc: "双向实时" },
{ kind: "sse", desc: "事件流推送" },
{ kind: "socketio", desc: "事件收发" },
{ kind: "mqtt", desc: "订阅 / 发布" },
];
export function NewMenu({
anchor,
onCreate,
onFolder,
onPickLocation,
onClose,
}: {
anchor: { x: number; y: number };
onCreate: (kind: RequestKind) => void;
onFolder: () => void;
onPickLocation: () => void;
onClose: () => void;
}) {
useEffect(() => {
const close = (e: Event) => {
const panel = document.querySelector(".new-panel");
if (panel && e.target instanceof Node && panel.contains(e.target)) return;
onClose();
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("pointerdown", close, true);
window.addEventListener("resize", close);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("pointerdown", close, true);
window.removeEventListener("resize", close);
window.removeEventListener("keydown", onKey);
};
}, [onClose]);
const left = Math.min(anchor.x, window.innerWidth - 320);
const top = Math.min(anchor.y, window.innerHeight - 420);
return (
<div className="new-panel" style={{ left, top }}>
<div className="new-panel-title">新建请求</div>
<div className="new-grid">
{PANEL_KINDS.map((p) => (
<button
key={p.kind}
className="new-card"
onClick={() => {
onCreate(p.kind);
onClose();
}}
>
<KindIcon kind={p.kind} size={26} />
<span className="min-w-0">
<span className="block truncate text-[12.5px] font-semibold">{KIND_COLORS[p.kind].label}</span>
<span className="ops-faint block truncate text-[11px]">{p.desc}</span>
</span>
</button>
))}
</div>
<div className="new-panel-sep" />
<button
className="new-card new-card-full"
onClick={() => {
onFolder();
onClose();
}}
>
<KindIcon kind="folder" size={26} />
<span className="text-[12.5px] font-semibold">新建目录</span>
<span className="ops-faint ml-auto pr-1 text-[11px]">接口分组</span>
</button>
<button
className="new-card new-card-full"
onClick={() => {
onPickLocation();
onClose();
}}
>
<span className="new-card-neutral">
<Icon name="library" size={15} />
</span>
<span className="text-[12.5px] font-semibold">选择位置新建…</span>
<span className="ops-faint ml-auto pr-1 text-[11px]">指定集合 / 目录</span>
</button>
</div>
);
}
+198
View File
@@ -0,0 +1,198 @@
/**
* 代理错误分层条:proxy | target | auth(产品/设计 P0 不砍)
* 线框:中文 LayerTag 芯片「代理 / 目标 / 鉴权」;unknown 中性灰
*/
import { openExternal } from "../openExternal";
import { useWorkspace } from "../workspaceStore";
import { cx } from "./common";
export type ProxyErrorLayer = "proxy" | "target" | "auth" | "unknown";
export type ClassifiedError = {
layer: ProxyErrorLayer;
title: string;
detail: string;
actions: { label: string; onClick: () => void; primary?: boolean }[];
};
const LAYER_LABEL: Record<ProxyErrorLayer, string> = {
proxy: "代理",
target: "目标",
auth: "鉴权",
unknown: "未知",
};
export function classifyRequestError(message: string): ClassifiedError {
const m = message.toLowerCase();
if (
m.includes("proxy") ||
m.includes("econnrefused") ||
m.includes("8787") ||
m.includes("failed to fetch") ||
m.includes("networkerror") ||
m.includes("gin-proxy") ||
m.includes("load failed")
) {
return {
layer: "proxy",
title: "代理层失败",
detail: message,
actions: [
{
label: "检查本地代理",
primary: true,
onClick: () => void openExternal("http://127.0.0.1:8787/health"),
},
{
label: "切换桌面直连",
primary: true,
onClick: () => {
useWorkspace.getState().patchSettings({ transportMode: "tauri-native" });
},
},
{
label: "查看通道说明",
onClick: () => useWorkspace.getState().setDialog({ type: "settings" }),
},
],
};
}
if (
m.includes("401") ||
m.includes("403") ||
m.includes("unauthorized") ||
m.includes("forbidden") ||
m.includes("token") ||
m.includes("鉴权") ||
(m.includes("auth") && !m.includes("author"))
) {
return {
layer: "auth",
title: "鉴权层失败",
detail: message,
actions: [
{
label: "检查 Token / Auth",
primary: true,
onClick: () => {
useWorkspace.getState().setDialog({ type: "environments" });
},
},
],
};
}
if (
m.includes("timeout") ||
m.includes("enotfound") ||
m.includes("404") ||
m.includes("502") ||
m.includes("503") ||
m.includes("ssl") ||
m.includes("certificate") ||
m.includes("target") ||
m.includes("dns")
) {
return {
layer: "target",
title: "目标服务失败",
detail: message,
actions: [
{
label: "检查 BaseURL / VPN",
primary: true,
onClick: () => useWorkspace.getState().setDialog({ type: "environments" }),
},
],
};
}
return {
layer: "unknown",
title: "请求失败(请按层排查)",
detail: message,
actions: [
{
label: "查代理 :8787",
primary: true,
onClick: () => void openExternal("http://127.0.0.1:8787/health"),
},
{
label: "切换桌面直连",
primary: true,
onClick: () => useWorkspace.getState().patchSettings({ transportMode: "tauri-native" }),
},
{
label: "查环境 / BaseURL",
onClick: () => useWorkspace.getState().setDialog({ type: "environments" }),
},
],
};
}
const BANNER_STYLE: Record<ProxyErrorLayer, string> = {
proxy: "border-amber-500/40 bg-amber-500/10 text-amber-50",
target: "border-sky-500/40 bg-sky-500/10 text-sky-50",
auth: "border-rose-500/40 bg-rose-500/10 text-rose-50",
unknown: "border-zinc-500/40 bg-zinc-500/10 text-zinc-100",
};
const TAG_STYLE: Record<ProxyErrorLayer, string> = {
proxy: "bg-amber-500/25 text-amber-100 ring-amber-400/40",
target: "bg-sky-500/25 text-sky-100 ring-sky-400/40",
auth: "bg-rose-500/25 text-rose-100 ring-rose-400/40",
unknown: "bg-zinc-500/30 text-zinc-200 ring-zinc-400/40",
};
function LayerTag({ layer }: { layer: ProxyErrorLayer }) {
return (
<span
className={cx(
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold tracking-wide ring-1 ring-inset",
TAG_STYLE[layer],
)}
>
{LAYER_LABEL[layer]}
</span>
);
}
export function ProxyErrorBanner({ message, onDismiss }: { message: string; onDismiss?: () => void }) {
const info = classifyRequestError(message);
return (
<div className={cx("mb-3 rounded-lg border px-3 py-2.5 text-[13px]", BANNER_STYLE[info.layer])}>
<div className="mb-1.5 flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<LayerTag layer={info.layer} />
<span className="font-semibold">{info.title}</span>
</div>
{onDismiss && (
<button type="button" className="ops-btn ops-btn-ghost px-1 py-0 text-xs opacity-70" onClick={onDismiss}>
✕
</button>
)}
</div>
<div className="whitespace-pre-wrap opacity-90">{info.detail}</div>
<div className="mt-2.5 flex flex-wrap items-center gap-2">
{info.actions.map((a) =>
a.primary ? (
<button key={a.label} type="button" className="ops-btn ops-btn-secondary text-xs" onClick={a.onClick}>
{a.label}
</button>
) : (
<button
key={a.label}
type="button"
className="text-[12px] underline-offset-2 opacity-80 hover:underline hover:opacity-100"
onClick={a.onClick}
>
{a.label}
</button>
),
)}
</div>
</div>
);
}
+336
View File
@@ -0,0 +1,336 @@
import { replayHistory } from "../send";
/**
* 左侧集合树:搜索 / 目录 / 请求 / 右键菜单 / 拖拽移动 / 历史记录。
*/
import type { Collection, CollectionNode } from "../core/index";
import { findNode, searchTree } from "../core/index";
import { useMemo, useState } from "react";
import { useWorkspace } from "../workspaceStore";
import { ContextMenu, cx, formatTime, type MenuItem } from "./common";
import { Icon } from "./icons";
import { KindIcon } from "./KindIcon";
import { Logo } from "./Logo";
import { WorkspaceSidebar } from "./WorkspaceSidebar";
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 side = useWorkspace((s) => s.sidebarSide);
const setSide = useWorkspace((s) => s.setSidebarSide);
const [menu, setMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null);
const hitIds = useMemo(() => searchTree(collections, search), [collections, search]);
return (
<WorkspaceSidebar label="集合">
<div className="space-y-2 p-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
{side === "api" && (
<input
className="ops-input ops-input-sm"
placeholder="搜索接口 / 目录…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
)}
<div className="side-seg">
<button className={cx("side-seg-item", side === "api" && "active")} onClick={() => setSide("api")}>
<Icon name="library" size={12} /> 接口
</button>
<button className={cx("side-seg-item", side === "history" && "active")} onClick={() => setSide("history")}>
<Icon name="clock" size={12} /> 历史
{history.length > 0 && <span className="ops-count">{Math.min(history.length, 99)}</span>}
</button>
</div>
</div>
<div className="ops-scroll min-h-0 flex-1 p-2">
{side === "history" ? (
<>
{history.length === 0 && (
<div className="ops-faint px-2 py-8 text-center text-xs leading-5">
暂无请求记录
<br />
发送请求后自动归档
</div>
)}
{history.slice(0, 50).map((h) => (
<div key={h.id} className="ops-tree-row group flex w-full items-center !py-1 text-left">
<button
className="flex min-w-0 flex-1 items-center gap-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-[11px]">{formatTime(h.at)}</span>
{h.method && <span className={`ops-method-${h.method} w-12 shrink-0 text-[11px] font-bold`}>{h.method}</span>}
<span className="truncate text-[11px]">{h.label}</span>
{h.status !== undefined && (
<span className={cx("ml-auto text-[11px]", h.ok ? "ops-success" : "ops-danger")}>{h.status}</span>
)}
</button>
{h.nodeId && (
<button
className="ops-btn ops-btn-ghost ml-1 shrink-0 px-1.5 py-0.5 text-[11px] opacity-0 group-hover:opacity-100"
title="打开并重放"
onClick={(e) => {
e.stopPropagation();
replayHistory(h);
}}
>
重放
</button>
)}
</div>
))}
{history.length > 0 && (
<button className="ops-btn ops-btn-ghost ops-danger mt-2 w-full py-1 text-[11px]" onClick={clearHistory}>
<Icon name="eraser" size={11} /> 清空历史
</button>
)}
</>
) : (
<>
{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>
<ContextMenu menu={menu} onClose={() => setMenu(null)} />
</WorkspaceSidebar>
);
}
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 dirtyIds = useWorkspace((s) => s.dirtyIds);
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="flex min-w-0 items-center gap-1 truncate">
<span className="truncate">{node.name}</span>
{requestNode && dirtyIds[node.id] ? (
<span
className="inline-block h-2 w-2 shrink-0 rounded-full"
style={{ background: "var(--accent)" }}
title="未保存"
/>
) : null}
</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 ? (
<KindIcon kind={requestNode.kind} size={16} />
) : null}
<span className={cx("min-w-0 flex-1 truncate", isFolder && "font-medium")}>{label}</span>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
type Props = {
direction?: "horizontal" | "vertical";
initialRatio?: number;
storageKey?: string;
minPx?: number;
minSecondPx?: number;
className?: string;
children: [ReactNode, ReactNode];
};
function clamp(n: number, lo: number, hi: number) {
return Math.min(hi, Math.max(lo, n));
}
export function SplitPane({
direction = "vertical",
initialRatio = 0.45,
storageKey,
minPx = 120,
minSecondPx = minPx,
className = "",
children,
}: Props) {
const [a, b] = children;
const rootRef = useRef<HTMLDivElement | null>(null);
const [size, setSize] = useState(0);
const [ratio, setRatio] = useState(() => {
if (storageKey) {
const raw = localStorage.getItem(storageKey);
const n = raw ? Number(raw) : NaN;
if (Number.isFinite(n) && n > 0.1 && n < 0.9) return n;
}
return initialRatio;
});
const dragging = useRef(false);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const measure = () => setSize(direction === "vertical" ? root.clientHeight : root.clientWidth);
const observer = new ResizeObserver(measure);
observer.observe(root);
measure();
return () => observer.disconnect();
}, [direction]);
useEffect(() => {
if (storageKey) localStorage.setItem(storageKey, String(ratio));
}, [ratio, storageKey]);
const onPointerMove = useCallback(
(e: PointerEvent) => {
if (!dragging.current || !rootRef.current) return;
const rect = rootRef.current.getBoundingClientRect();
if (direction === "vertical") {
const y = e.clientY - rect.top;
if (rect.height) setRatio(clamp(y / rect.height, minPx / rect.height, 1 - (minSecondPx + 8) / rect.height));
} else {
const x = e.clientX - rect.left;
if (rect.width) setRatio(clamp(x / rect.width, minPx / rect.width, 1 - (minSecondPx + 8) / rect.width));
}
},
[direction, minPx, minSecondPx],
);
const stop = useCallback(() => {
dragging.current = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
}, []);
useEffect(() => {
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", stop);
return () => {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", stop);
};
}, [onPointerMove, stop]);
const vert = direction === "vertical";
// A stored ratio can become unusable after shrinking the window. Reserve room
// for both panes and the separator at the current measured size.
const firstSize = size ? Math.min(Math.max(size * ratio, Math.min(minPx, size / 2)), Math.max(0, size - minSecondPx - 8)) : undefined;
return (
<div
ref={rootRef}
className={`flex min-h-0 min-w-0 flex-1 ${vert ? "flex-col" : "flex-row"} ${className}`}
>
<div className="flex min-h-0 min-w-0 shrink-0 flex-col overflow-hidden" style={vert ? { height: firstSize ?? `${ratio * 100}%` } : { width: firstSize ?? `${ratio * 100}%` }}>
{a}
</div>
<div
role="separator"
aria-orientation={vert ? "horizontal" : "vertical"}
title="拖动调整;双击复位"
onPointerDown={(e) => {
e.preventDefault();
dragging.current = true;
document.body.style.cursor = vert ? "row-resize" : "col-resize";
document.body.style.userSelect = "none";
}}
onDoubleClick={() => setRatio(initialRatio)}
className={
vert
? "group relative z-10 h-2 shrink-0 cursor-row-resize"
: "group relative z-10 w-2 shrink-0 cursor-col-resize"
}
style={{ background: "transparent" }}
>
<div
className={
vert
? "absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-[var(--border)] transition group-hover:h-1 group-hover:bg-[var(--accent)]"
: "absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-[var(--border)] transition group-hover:w-1 group-hover:bg-[var(--accent)]"
}
/>
</div>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">{b}</div>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
/**
* 请求标签页栏。
*/
import { KIND_LABELS, findNode } from "../core/index";
import { useState } from "react";
import { useWorkspace } from "../workspaceStore";
import { ContextMenu, cx, type MenuItem } from "./common";
import { KindIcon } from "./KindIcon";
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 dirtyIds = useWorkspace((s) => s.dirtyIds);
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]}><KindIcon kind={node.kind} size={14} /></span>
) : null}
<span className="max-w-48 truncate">{node.name}</span>
{dirtyIds[node.id] && (
<span
className="inline-block h-2 w-2 shrink-0 rounded-full"
style={{ background: "var(--accent)" }}
title="未保存"
/>
)}
{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>
);
}
+1
View File
@@ -0,0 +1 @@
export { TestingView } from "../features/testing/TestingView";
+115
View File
@@ -0,0 +1,115 @@
/**
* 顶栏:品牌、新建/导入/导出、环境管理与当前环境切换。
*/
import { useEffect, useState } from "react";
import { useWorkspace } from "../workspaceStore";
import { ContextMenu } from "./common";
import { Icon } from "./icons";
import { Logo } from "./Logo";
import { NewMenu } from "./NewMenu";
import { EnvironmentSwitcher } from "./EnvironmentSwitcher";
export function TopBar() {
const collections = useWorkspace((s) => s.collections);
const environments = useWorkspace((s) => s.environments);
const activeEnvironmentId = useWorkspace((s) => s.activeEnvironmentId);
const setActiveEnvironment = useWorkspace((s) => s.setActiveEnvironment);
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);
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);
};
return (
<header
className="ops-panel ops-divider flex h-12 shrink-0 flex-nowrap items-center gap-3 px-4"
style={{ zIndex: 30 }}
>
<div className="flex shrink-0 items-center gap-2.5" title="OpsxAPI · HTTP / GraphQL / WebSocket / SSE / Socket.IO / MQTT">
<Logo size={26} />
<span className="text-[15px] font-bold tracking-tight">OpsxAPI</span>
</div>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
<div className="min-w-3 flex-1 self-stretch" />
<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-btn ops-btn-secondary"
onClick={() => setDialog({ type: "environments" })}
title="管理正式、本地等环境及其前置 URL 和变量"
>
<Icon name="settings" size={14} /> 管理环境
</button>
<EnvironmentSwitcher environments={environments} activeId={activeEnvironmentId} onSelect={setActiveEnvironment} />
</div>
{newMenu && (
<NewMenu
anchor={newMenu}
onCreate={(kind) => quickCreate(kind)}
onFolder={() => quickCreate("folder")}
onPickLocation={() => setDialog({ type: "new", collectionId: firstCollection?.id ?? "", parentId: null })}
onClose={() => setNewMenu(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>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { environmentPath, resolveRequestUrl } from "../core/requestUrl";
import { activeEnvMap } from "../workspaceStore";
/**
* URL 输入(前置 URL 模式):
* 当前环境配置了前置 URL 且值为相对路径(/ 开头或空)时,
* 展示「前缀块 + 分隔线 + 路径输入」的一体胶囊 —— 借鉴 Apifox 前置 URL。
* 路径输入只编辑相对部分,光标 / 全选不会碰到服务地址。
*/
import type { KeyboardEvent } from "react";
import { useWorkspace } from "../workspaceStore";
import { cx } from "./common";
import { EnvBadge } from "./EnvBadge";
import { VarHintInput } from "./VarHintInput";
export function useEnvBaseUrl(): { baseUrl: string; envId: string | null; envName: string } {
const envId = useWorkspace((s) => s.activeEnvironmentId);
const env = useWorkspace((s) => (envId ? s.environments.find((e) => e.id === envId) : undefined));
return {
baseUrl: env?.baseUrl?.trim() ?? "",
envId: envId ?? null,
envName: env?.name ?? "",
};
}
/** 前置 URL 与相对路径拼接(供编辑器展示「实际地址」与代码生成复用) */
export function joinUrl(baseUrl: string, path: string): string {
return resolveRequestUrl(path, activeEnvMap(), baseUrl);
}
export function UrlPathInput({
value,
onChange,
placeholder,
className,
onKeyDown,
ariaLabel,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
className?: string;
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
ariaLabel?: string;
}) {
const { baseUrl, envId, envName } = useEnvBaseUrl();
const setDialog = useWorkspace((s) => s.setDialog);
const environments = useWorkspace((s) => s.environments);
const relative = environmentPath(value, environments.map((env) => resolveRequestUrl("", activeEnvMap(), env.baseUrl || "")));
const usePrefix = !!baseUrl && (value === "" || (value.trimStart().startsWith("/") && !value.trimStart().startsWith("//")));
if (!usePrefix) {
return (
<div className="flex min-w-0 flex-1 items-center gap-1">
<VarHintInput
className={className}
value={value}
onChange={onChange}
placeholder={placeholder}
onKeyDown={onKeyDown}
ariaLabel={ariaLabel}
/>
{relative !== null && baseUrl && <button type="button" className="ops-btn ops-btn-secondary" title={`改为 ${relative},随后跟随环境前置 URL`} onClick={() => onChange(relative)}>关联环境</button>}
</div>
);
}
// 展示 host(去协议),完整地址放 title
const display = resolveRequestUrl("", activeEnvMap(), baseUrl).replace(/\/+$/, "");
return (
<div className={cx("url-pill", className)}>
<button
type="button"
className="url-pill-prefix"
title={`前置 URL(${envName || "当前环境"}):${baseUrl}\n点击管理环境`}
onClick={() => setDialog({ type: "environments" })}
>
{envId && <EnvBadge envId={envId} name={envName} size={14} />}
<span className="truncate">{display}</span>
</button>
<span className="url-pill-divider" />
<VarHintInput
className="url-pill-input"
value={value}
onChange={onChange}
placeholder={placeholder ?? "/path"}
onKeyDown={onKeyDown}
ariaLabel={ariaLabel}
/>
</div>
);
}
+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>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
const DEFAULT_WIDTH = 256;
const MIN_WIDTH = 200;
const MAX_WIDTH = 420;
const MAIN_MIN_WIDTH = 660;
const STORAGE_KEY = "opsxapi.sidebar.width";
function savedWidth() {
try {
const value = Number(localStorage.getItem(STORAGE_KEY));
return Number.isFinite(value) && value >= MIN_WIDTH && value <= MAX_WIDTH ? value : DEFAULT_WIDTH;
} catch { return DEFAULT_WIDTH; }
}
/** All workspace views share the same sidebar width and resize affordance. */
export function WorkspaceSidebar({ label, className = "", children }: { label: string; className?: string; children: ReactNode }) {
const divider = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(savedWidth);
const [available, setAvailable] = useState(Infinity);
const visibleWidth = Math.min(width, Math.max(MIN_WIDTH, available - MAIN_MIN_WIDTH - 6));
useEffect(() => {
const parent = divider.current?.parentElement;
if (!parent) return;
const observer = new ResizeObserver(() => setAvailable(parent.clientWidth));
observer.observe(parent);
setAvailable(parent.clientWidth);
return () => {
observer.disconnect();
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
}, []);
const setAndSave = (value: number) => {
const next = Math.round(Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, available - MAIN_MIN_WIDTH - 6, value)));
setWidth(next);
try { localStorage.setItem(STORAGE_KEY, String(next)); } catch { /* optional preference */ }
};
const resize = (clientX: number) => {
const parent = divider.current?.parentElement;
if (parent) setAndSave(clientX - parent.getBoundingClientRect().left);
};
const stop = (element: HTMLElement, pointerId: number) => {
if (element.hasPointerCapture(pointerId)) element.releasePointerCapture(pointerId);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
return <>
<aside className={`ops-panel flex min-w-0 shrink-0 flex-col ${className}`} style={{ width: visibleWidth }}>{children}</aside>
<div ref={divider} role="separator" tabIndex={0} aria-label={`调整${label}侧栏宽度`}
aria-orientation="vertical" aria-valuemin={MIN_WIDTH}
aria-valuemax={Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, available - MAIN_MIN_WIDTH - 6))}
aria-valuenow={visibleWidth} title="左右拖动调整侧栏;双击恢复默认宽度"
className="sidebar-resizer"
onPointerDown={(event) => {
event.currentTarget.setPointerCapture(event.pointerId);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}}
onPointerMove={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) resize(event.clientX); }}
onPointerUp={(event) => stop(event.currentTarget, event.pointerId)}
onPointerCancel={(event) => stop(event.currentTarget, event.pointerId)}
onDoubleClick={() => setAndSave(DEFAULT_WIDTH)}
onKeyDown={(event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
setAndSave(visibleWidth + (event.key === "ArrowRight" ? 16 : -16));
}
}} />
</>;
}
+309
View File
@@ -0,0 +1,309 @@
/**
* 基础组件:Modal / 右键菜单 / JSON 高亮 / KV 编辑器 / 复制按钮。
*/
import type { KVRow } from "../core/index";
import { kv } from "../core/index";
import { Icon } from "./icons";
import { jsonLines } from "./jsonLines";
import { VarHintInput } from "./VarHintInput";
import { useEffect, useMemo, 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;
// pointerdown(capture) 关闭:打开菜单的那次交互已完成 pointerdown,
// 不会被自己关掉(click 监听在 WKWebView 等环境存在"即开即关"竞态)
const close = (e: Event) => {
const panel = ref.current;
if (panel && e.target instanceof Node && panel.contains(e.target)) return;
onClose();
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("pointerdown", close, true);
window.addEventListener("resize", close);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("pointerdown", close, true);
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 }) {
const parsed = useMemo(() => { try { return { valid: true as const, value: JSON.parse(text) as unknown }; } catch { return { valid: false as const, value: null }; } }, [text]);
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set());
const lines = useMemo(() => parsed.valid ? jsonLines(parsed.value, collapsed) : [], [parsed, collapsed]);
if (!parsed.valid) return <pre className={cx("ops-code ops-code-no-bg m-0", className)}>{text}</pre>;
return <div className={cx("ops-json-view ops-code-no-bg", className)} style={{ "--json-line-digits": String(lines.length).length } as React.CSSProperties}>
{lines.map((line, index) => <div className="ops-json-line" key={`${line.path}-${line.kind}`}>
<span className="ops-json-line-number" aria-hidden="true">{index + 1}</span>
<span className="ops-json-line-content" style={{ paddingLeft: line.depth * 16 }}>
{line.foldable ? <button className="ops-json-fold" aria-expanded={line.expanded} aria-label={`${line.expanded ? "折叠" : "展开"} JSON ${line.key ?? line.path}`} onClick={() => setCollapsed((previous) => {
const next = new Set(previous);
if (next.has(line.path)) next.delete(line.path); else next.add(line.path);
return next;
})}><Icon name={line.expanded ? "chevron-down" : "chevron-right"} size={12} /></button> : <span className="ops-json-fold-placeholder" />}
{line.key !== undefined && <><span className="ops-json-key">{JSON.stringify(line.key)}</span><span className="ops-json-punctuation">: </span></>}
<span className={line.tone ? `ops-json-${line.tone === "string" ? "str" : line.tone === "number" ? "num" : line.tone === "boolean" ? "bool" : "null"}` : "ops-json-punctuation"}>{line.text.endsWith(",") ? line.text.slice(0, -1) : line.text}</span>
{line.text.endsWith(",") && <span className="ops-json-punctuation">,</span>}
</span>
</div>)}
</div>;
}
/* ---------------- 复制按钮 ---------------- */
export function CopyButton({ text, label = "复制", iconOnly = false }: { text: string; label?: string; iconOnly?: boolean }) {
const [done, setDone] = useState(false);
return (
<button
className={cx("ops-btn ops-btn-secondary", iconOnly && "ops-icon-button")}
aria-label={done ? "已复制" : label}
title={done ? "已复制" : label}
onClick={async () => {
try {
await navigator.clipboard.writeText(text);
setDone(true);
setTimeout(() => setDone(false), 1200);
} catch {
/* ignore */
}
}}
>
{iconOnly ? <Icon name={done ? "circle-check" : "copy"} size={14} className={done ? "ops-success" : undefined} /> : 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,
showAddButton = true,
emptyHint,
}: {
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;
showAddButton?: boolean;
/** 空表时的引导文案(如「添加鉴权 Header」) */
emptyHint?: string;
}) {
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>
))}
{rows.length === 0 && emptyHint && (
<div
className="ops-faint cursor-pointer rounded-md border border-dashed px-3 py-2 text-[12px]"
style={{ borderColor: "var(--border)" }}
onClick={add}
>
{emptyHint}
</div>
)}
{showAddButton && <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")}`;
}
+564
View File
@@ -0,0 +1,564 @@
/**
* 账号弹窗:登录 / 注册;团队管理:建团队、邀请成员、成员擅长标签。
*/
import { fetchTeamMembers, updateMemberSpecialty, type AccountTeamMember } from "../../account";
import { useEffect, useState } from "react";
import { useWorkspace } from "../../workspaceStore";
import { Modal, cx } from "../common";
import { Icon } from "../icons";
/* ---------------- 登录 / 注册 ---------------- */
export function AuthDialog() {
const authLogin = useWorkspace((s) => s.authLogin);
const authRegister = useWorkspace((s) => s.authRegister);
const setDialog = useWorkspace((s) => s.setDialog);
const [mode, setMode] = useState<"login" | "register">("login");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const submit = async () => {
setBusy(true);
setError("");
try {
if (mode === "login") await authLogin(email, password);
else await authRegister(name, email, password);
setDialog(null);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
return (
<Modal
title={mode === "login" ? "登录" : "注册账号"}
onClose={() => setDialog(null)}
width="400px"
footer={
<div className="flex w-full items-center justify-between">
<button
className="ops-btn ops-btn-ghost !px-2 text-[12px]"
onClick={() => {
setMode(mode === "login" ? "register" : "login");
setError("");
}}
>
{mode === "login" ? "没有账号?注册" : "已有账号?登录"}
</button>
<button className="ops-btn ops-btn-primary" disabled={busy} onClick={submit}>
{busy ? "请稍候…" : mode === "login" ? "登录" : "注册并登录"}
</button>
</div>
}
>
<div className="space-y-3">
<div className="ops-hint">
<Icon name="info" size={14} />
<span>数据保存在本机 SQLite,仅用于云端空间同步</span>
</div>
{mode === "register" && (
<div>
<label className="ops-label">昵称</label>
<input
className="ops-input"
placeholder="怎么称呼你"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
)}
<div>
<label className="ops-label">邮箱</label>
<input
className="ops-input"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
autoFocus
/>
</div>
<div>
<label className="ops-label">密码</label>
<input
className="ops-input"
type="password"
placeholder="至少 6 位"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
/>
</div>
{error && <div className="ops-danger text-[12px]">{error}</div>}
</div>
</Modal>
);
}
/* ---------------- 团队管理 ---------------- */
/** 擅长预设(点击即选/取消) */
const SPECIALTY_PRESETS = ["前端", "后端", "全栈", "测试", "运维", "移动端", "数据库", "AI", "接口设计", "安全"];
const SPEC_PALETTE = ["#3b82f6", "#8b5cf6", "#10b981", "#f59e0b", "#f43f5e", "#06b6d4", "#ec4899", "#84cc16"];
function specColor(label: string): string {
let h = 0;
for (let i = 0; i < label.length; i++) h = (h * 31 + label.charCodeAt(i)) >>> 0;
return SPEC_PALETTE[h % SPEC_PALETTE.length];
}
function splitSpecialty(s: string): string[] {
return s
.split(/[\s,,、/]+/)
.map((x) => x.trim())
.filter(Boolean);
}
function SpecChip({ label }: { label: string }) {
const color = specColor(label);
return (
<span
className="spec-chip"
style={{
background: `color-mix(in srgb, ${color} 14%, transparent)`,
color,
borderColor: `color-mix(in srgb, ${color} 38%, transparent)`,
}}
>
{label}
</span>
);
}
/** 擅长编辑器:预设点选 + 自定义输入 */
function SpecialtyEditor({
value,
onChange,
onSave,
onCancel,
busy,
}: {
value: string;
onChange: (v: string) => void;
onSave: () => void;
onCancel: () => void;
busy: boolean;
}) {
const picked = splitSpecialty(value);
const toggle = (p: string) => {
const next = picked.includes(p) ? picked.filter((x) => x !== p) : [...picked, p];
onChange(next.join(" "));
};
return (
<div className="space-y-1.5">
<div className="flex gap-1.5">
<input
className="ops-input ops-input-sm flex-1"
placeholder="如:前端 测试(空格分隔,可自定义)"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onSave()}
autoFocus
/>
<button className="ops-btn ops-btn-secondary !py-1 text-[12px]" disabled={busy} onClick={onSave}>
保存
</button>
<button className="ops-btn ops-btn-ghost !py-1 text-[12px]" onClick={onCancel}>
取消
</button>
</div>
<div className="flex flex-wrap gap-1">
{SPECIALTY_PRESETS.map((p) => (
<button
key={p}
className={cx("spec-preset", picked.includes(p) && "active")}
onClick={() => toggle(p)}
>
{p}
</button>
))}
</div>
</div>
);
}
export function TeamDialog() {
const account = useWorkspace((s) => s.account);
const createTeam = useWorkspace((s) => s.createTeam);
const renameTeam = useWorkspace((s) => s.renameTeam);
const deleteTeam = useWorkspace((s) => s.deleteTeam);
const addTeamMember = useWorkspace((s) => s.addTeamMember);
const setDialog = useWorkspace((s) => s.setDialog);
const [teamName, setTeamName] = useState("");
const [inviteEmail, setInviteEmail] = useState<Record<number, string>>({});
const [inviteSpecialty, setInviteSpecialty] = useState<Record<number, string>>({});
const [members, setMembers] = useState<Record<number, AccountTeamMember[]>>({});
const [membersLoading, setMembersLoading] = useState(false);
const [editing, setEditing] = useState<{ teamId: number; userId: number } | null>(null);
const [editValue, setEditValue] = useState("");
const [renaming, setRenaming] = useState<number | null>(null);
const [renameValue, setRenameValue] = useState("");
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [okMsg, setOkMsg] = useState("");
const token = account.token;
const teamCount = account.teams.length;
const loadMembers = async () => {
if (!token || useWorkspace.getState().account.teams.length === 0) return;
setMembersLoading(true);
try {
const teams = useWorkspace.getState().account.teams;
const next: Record<number, AccountTeamMember[]> = {};
await Promise.all(
teams.map(async (t) => {
next[t.id] = (await fetchTeamMembers(token, t.id)).members ?? [];
}),
);
setMembers(next);
} catch {
/* 拉取失败时仍展示团队概要 */
} finally {
setMembersLoading(false);
}
};
useEffect(() => {
void loadMembers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, teamCount]);
const doCreate = async () => {
if (!teamName.trim()) return;
setBusy(true);
setError("");
try {
await createTeam(teamName);
setTeamName("");
setOkMsg("团队已创建,左侧空间列表可切换到团队空间");
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const doRename = async (teamId: number) => {
if (!renameValue.trim()) return;
setBusy(true);
setError("");
try {
await renameTeam(teamId, renameValue);
setRenaming(null);
setOkMsg("团队已重命名");
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const doDelete = async (teamId: number) => {
setBusy(true);
setError("");
try {
const name = account.teams.find((t) => t.id === teamId)?.name ?? "";
await deleteTeam(teamId);
setConfirmDelete(null);
setMembers((m) => {
const next = { ...m };
delete next[teamId];
return next;
});
setOkMsg(`团队「${name}」已删除(含共享空间与 Mock 数据)`);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const doInvite = async (teamId: number) => {
const email = (inviteEmail[teamId] ?? "").trim();
if (!email) return;
setBusy(true);
setError("");
try {
await addTeamMember(teamId, email, inviteSpecialty[teamId] ?? "");
setInviteEmail((m) => ({ ...m, [teamId]: "" }));
setInviteSpecialty((m) => ({ ...m, [teamId]: "" }));
await loadMembers();
setOkMsg(`已添加成员 ${email}`);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const saveSpecialty = async (teamId: number, userId: number) => {
if (!token) return;
setBusy(true);
setError("");
try {
await updateMemberSpecialty(token, teamId, userId, editValue);
setEditing(null);
await loadMembers();
setOkMsg("擅长已更新");
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const me = account.user;
return (
<Modal
title="团队管理"
onClose={() => setDialog(null)}
width="600px"
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="space-y-4">
<div>
<label className="ops-label">新建团队</label>
<div className="flex gap-2">
<input
className="ops-input flex-1"
placeholder="团队名称,如:支付网关组"
value={teamName}
onChange={(e) => setTeamName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && doCreate()}
/>
<button className="ops-btn ops-btn-secondary" disabled={busy} onClick={doCreate}>
<Icon name="plus" size={13} /> 创建
</button>
</div>
</div>
{account.teams.length === 0 ? (
<div className="ops-faint py-4 text-center text-xs">还没有团队,创建一个即可获得团队共享空间</div>
) : (
<div className="space-y-2">
{account.teams.map((t) => {
const list = members[t.id];
return (
<div key={t.id} className="rounded-xl px-3 py-2.5" style={{ border: "1px solid var(--border)" }}>
<div className="flex items-center gap-2">
{renaming === t.id ? (
<div className="flex flex-1 items-center gap-2">
<input
className="ops-input ops-input-sm flex-1"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && doRename(t.id)}
autoFocus
/>
<button className="ops-btn ops-btn-secondary !py-1 text-[12px]" disabled={busy} onClick={() => doRename(t.id)}>
保存
</button>
<button className="ops-btn ops-btn-ghost !py-1 text-[12px]" onClick={() => setRenaming(null)}>
取消
</button>
</div>
) : (
<>
<span className="grid h-6 w-6 place-items-center rounded-md" style={{ background: "var(--panel-2)" }}>
<Icon name="library" size={13} />
</span>
<span className="text-[13px] font-medium">{t.name}</span>
<span className={cx("ops-chip")} style={{ padding: "1px 8px", fontSize: 11 }}>
{t.role === "owner" ? "创建者" : "成员"}
</span>
<span className="ops-faint ml-auto text-[11px]">
{list ? `${list.length} 名成员 · 创建者 ${t.owner}` : `创建者 ${t.owner}`}
</span>
{t.role === "owner" && (
<>
<button
className="ops-btn ops-btn-ghost !px-1.5 !py-0.5 text-[11px]"
title="重命名团队"
onClick={() => {
setRenaming(t.id);
setRenameValue(t.name);
setConfirmDelete(null);
}}
>
<Icon name="pencil" size={12} />
</button>
<button
className="ops-btn ops-btn-ghost ops-danger !px-1.5 !py-0.5 text-[11px]"
title="删除团队(含共享空间与 Mock 数据)"
onClick={() => setConfirmDelete(confirmDelete === t.id ? null : t.id)}
>
<Icon name="trash" size={12} />
</button>
</>
)}
</>
)}
</div>
{confirmDelete === t.id && (
<div
className="mt-2 rounded-lg px-2.5 py-2"
style={{
background: "color-mix(in srgb, var(--danger) 8%, transparent)",
border: "1px solid color-mix(in srgb, var(--danger) 32%, transparent)",
}}
>
<div className="ops-danger text-[12px]">
将永久删除团队「{t.name}」及其共享空间、成员关系、已发布 Mock 接口,不可恢复。确定删除?
</div>
<div className="mt-1.5 flex justify-end gap-2">
<button className="ops-btn ops-btn-ghost !py-1 text-[12px]" onClick={() => setConfirmDelete(null)}>
取消
</button>
<button
className="ops-btn ops-btn-danger !py-1 text-[12px]"
disabled={busy}
onClick={() => void doDelete(t.id)}
>
确认删除
</button>
</div>
</div>
)}
{/* 成员与擅长 */}
<div className="mt-1.5 space-y-1">
{list === undefined &&
(membersLoading ? (
<div className="ops-faint px-1 py-1 text-[11px]">成员加载中…</div>
) : null)}
{list?.map((m) => {
const canEdit = t.role === "owner" || m.userId === me?.id;
const isEditing = editing?.teamId === t.id && editing.userId === m.userId;
return (
<div key={m.userId} className="member-row">
{isEditing ? (
<div className="flex-1">
<SpecialtyEditor
value={editValue}
onChange={setEditValue}
onSave={() => void saveSpecialty(t.id, m.userId)}
onCancel={() => setEditing(null)}
busy={busy}
/>
</div>
) : (
<>
<span className="ops-user-avatar" style={{ width: 20, height: 20, fontSize: 11 }}>
{m.name.slice(0, 1).toUpperCase()}
</span>
<span className="min-w-0">
<span className="block truncate text-[12px] font-medium">
{m.name}
{m.userId === me?.id && <span className="ops-faint ml-1 text-[11px]">(我)</span>}
</span>
<span className="ops-faint block truncate text-[11px]">{m.email}</span>
</span>
<span className="ops-chip shrink-0" style={{ padding: "1px 7px", fontSize: 11 }}>
{m.role === "owner" ? "创建者" : "成员"}
</span>
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-1 pl-1">
{splitSpecialty(m.specialty).slice(0, 3).map((s) => (
<SpecChip key={s} label={s} />
))}
{splitSpecialty(m.specialty).length > 3 && (
<span className="ops-faint text-[11px]" title={m.specialty}>
+{splitSpecialty(m.specialty).length - 3}
</span>
)}
{splitSpecialty(m.specialty).length === 0 && (
<span className="ops-faint text-[11px]">未设置擅长</span>
)}
</span>
{canEdit && (
<button
className="ops-btn ops-btn-ghost shrink-0 !px-1.5 !py-0.5 text-[11px]"
title={t.role === "owner" ? "设置擅长" : "设置我的擅长"}
onClick={() => {
setEditing({ teamId: t.id, userId: m.userId });
setEditValue(m.specialty);
}}
>
<Icon name="pencil" size={12} /> 擅长
</button>
)}
</>
)}
</div>
);
})}
</div>
{t.role === "owner" && (
<div className="mt-2 space-y-1.5">
<div className="flex gap-2">
<input
className="ops-input ops-input-sm flex-1"
placeholder="成员邮箱(需已注册)"
value={inviteEmail[t.id] ?? ""}
onChange={(e) => setInviteEmail((m) => ({ ...m, [t.id]: e.target.value }))}
onKeyDown={(e) => e.key === "Enter" && doInvite(t.id)}
/>
<button className="ops-btn ops-btn-secondary !py-1 text-[12px]" disabled={busy} onClick={() => doInvite(t.id)}>
添加成员
</button>
</div>
{(inviteEmail[t.id] ?? "").trim() !== "" && (
<div className="flex flex-wrap items-center gap-1">
<span className="ops-faint text-[11px]">擅长:</span>
{SPECIALTY_PRESETS.map((p) => {
const picked = splitSpecialty(inviteSpecialty[t.id] ?? "").includes(p);
return (
<button
key={p}
className={cx("spec-preset", picked && "active")}
onClick={() => {
const cur = splitSpecialty(inviteSpecialty[t.id] ?? "");
const next = picked ? cur.filter((x) => x !== p) : [...cur, p];
setInviteSpecialty((m) => ({ ...m, [t.id]: next.join(" ") }));
}}
>
{p}
</button>
);
})}
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
{error && <div className="ops-danger text-[12px]">{error}</div>}
{okMsg && <div className="ops-success text-[12px]">{okMsg}</div>}
</div>
</Modal>
);
}
+204
View File
@@ -0,0 +1,204 @@
/**
* AI 生成场景用例:选集合 + 要求 → 模型输出步骤编排 → 预览确认保存。
* 需先在「设置 → 大模型」配置模型供应商。
*/
import type { AiCaseDraft } from "../../ai";
import { aiGenerateTestCase } from "../../ai";
import { runTestCase } from "../../testRunner";
import { useWorkspace } from "../../workspaceStore";
import { Modal } from "../common";
import { Icon } from "../icons";
import { useState } from "react";
export function AiCaseDialog({ onClose, onSaved }: { onClose: () => void; onSaved: (caseId: string) => void }) {
const collections = useWorkspace((s) => s.collections);
const ai = useWorkspace((s) => s.settings.ai);
const setDialog = useWorkspace((s) => s.setDialog);
const addTestCase = useWorkspace((s) => s.addTestCase);
const updateTestCase = useWorkspace((s) => s.updateTestCase);
const [collectionId, setCollectionId] = useState(collections[0]?.id ?? "");
const [instruction, setInstruction] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [draft, setDraft] = useState<AiCaseDraft | null>(null);
const provider = ai.providers.find((p) => p.id === ai.activeProviderId && p.enabled) ?? null;
const generate = async () => {
const collection = collections.find((c) => c.id === collectionId);
if (!provider || !collection) return;
setBusy(true);
setError("");
setDraft(null);
try {
setDraft(await aiGenerateTestCase(provider, collection, instruction));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const persistDraft = (): string | null => {
if (!draft) return null;
const id = addTestCase();
updateTestCase(id, { name: draft.name, description: draft.description, steps: draft.steps });
return id;
};
const save = () => {
const id = persistDraft();
if (!id) return;
onSaved(id);
onClose();
};
const saveAndRun = async () => {
const id = persistDraft();
if (!id) return;
setBusy(true);
try {
await runTestCase(id, "ai");
onSaved(id);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
return (
<Modal
title="AI 生成场景用例"
onClose={onClose}
width="680px"
footer={
<div className="flex w-full items-center justify-between">
<span className="ops-faint text-[11px]">生成后可再手动调整步骤与断言</span>
<div className="flex gap-2">
<button className="ops-btn ops-btn-secondary" onClick={onClose}>
取消
</button>
{draft ? (
<>
<button className="ops-btn ops-btn-secondary" disabled={busy} onClick={save}>
<Icon name="circle-check" size={13} /> 仅保存
</button>
<button className="ops-btn ops-btn-primary" disabled={busy} onClick={saveAndRun}>
<Icon name="play" size={13} /> {busy ? "运行中…" : "保存并运行"}
</button>
</>
) : (
<button
className="ops-btn ops-btn-primary"
disabled={!provider || !collectionId || busy}
onClick={generate}
>
<Icon name="bot" size={13} /> {busy ? "生成中…" : "生成"}
</button>
)}
</div>
</div>
}
>
{!provider ? (
<div className="space-y-3">
<div className="ops-hint">
<Icon name="info" size={14} />
<span>尚未启用默认模型。请先在「设置 → 大模型」添加并启用 OpenAI 兼容的供应商。</span>
</div>
<button className="ops-btn ops-btn-secondary" onClick={() => setDialog({ type: "settings", tab: "ai" })}>
<Icon name="settings" size={13} /> 打开设置
</button>
</div>
) : (
<div className="space-y-3.5">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="ops-label">集合</label>
<select className="ops-input" value={collectionId} onChange={(e) => setCollectionId(e.target.value)}>
{collections.map((c) => (
<option key={c.id} value={c.id}>
{c.name}({countHttp(c.children)} 个 HTTP 接口)
</option>
))}
</select>
</div>
<div>
<label className="ops-label">模型</label>
<div className="ops-faint flex h-[34px] items-center gap-1.5 text-[12px]">
<Icon name="bot" size={13} className="ops-accent" />
{provider.name} · {provider.model || "默认模型"}
</div>
</div>
</div>
<div>
<label className="ops-label">要求(可选)</label>
<textarea
className="ops-input min-h-16 w-full resize-y text-[12px]"
placeholder="例:覆盖用户登录 → 查询个人信息 → 修改昵称的完整流程,重点断言返回码与业务字段"
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
/>
</div>
{error && <div className="ops-danger text-[12px]">生成失败:{error}</div>}
{draft && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Icon name="circle-check" size={14} className="ops-success" />
<span className="text-[13px] font-semibold">{draft.name}</span>
<span className="ops-faint text-[11px]">{draft.steps.length} 个步骤</span>
</div>
{draft.description && <div className="ops-faint text-[11px]">{draft.description}</div>}
{draft.notes.map((n, i) => (
<div key={i} className="ops-faint text-[11px]">
· {n}
</div>
))}
<div className="max-h-64 space-y-1.5 overflow-y-auto">
{draft.steps.map((s, i) => (
<div key={s.id} className="flex items-start gap-2 rounded-lg px-2.5 py-1.5" style={{ border: "1px solid var(--border)" }}>
<span className="ops-faint mt-0.5 text-[11px] font-bold">{i + 1}</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[12px] font-medium">{stepName(s.nodeId)}</div>
<div className="ops-faint text-[11px]">
{s.assertions.length} 条断言
{s.extracts.length > 0 && ` · 提取 ${s.extracts.map((e) => e.varName).join(", ")}`}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</Modal>
);
function stepName(nodeId: string): string {
const { collections: cols } = useWorkspace.getState();
let name = nodeId;
const walk = (nodes: { type: string; name?: string; id: string; children?: unknown[] }[]): void => {
for (const n of nodes) {
if (n.id === nodeId && n.type === "request") name = n.name ?? name;
if (Array.isArray(n.children)) walk(n.children as never[]);
}
};
for (const c of cols) walk(c.children as never[]);
return name;
}
}
function countHttp(nodes: { type: string; kind?: string; children?: unknown[] }[]): number {
let n = 0;
for (const node of nodes) {
if (node.type === "request" && node.kind === "http") n++;
if (Array.isArray(node.children)) n += countHttp(node.children as never[]);
}
return n;
}
+134
View File
@@ -0,0 +1,134 @@
/**
* 导出对话框:OpenAPI JSON/YAML、Postman、Markdown、全量备份。
*/
import { countRequests, exportBackup, exportCollectionAs, type ExportFormat } from "../../core/index";
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>
);
}
+307
View File
@@ -0,0 +1,307 @@
/**
* 导入对话框:粘贴 / 文件 / URL(Swagger URL)三种来源 + 格式自动识别。
*/
import type { Collection } from "../../core/index";
import {
FORMAT_LABELS,
detectImportFormat,
importByFormat,
countRequests,
type ImportFormat,
} from "../../core/index";
import { useMemo, useRef, useState } from "react";
import { useWorkspace } from "../../workspaceStore";
import { Modal, cx } from "../common";
import { Icon } from "../icons";
import { isTauriRuntime, tauriNativeTransport } from "../../transport";
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 projects = useWorkspace((s) => s.collections);
const [source, setSource] = useState<Source>("paste");
const [text, setText] = useState("");
const [url, setUrl] = useState("");
const [format, setFormat] = useState<ImportFormat | "auto">("auto");
const [mode, setMode] = useState<"new" | "merge" | "replace">("new");
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 = (content: string): ImportFormat => {
if (format !== "auto") return format;
if (!content.trim()) throw new Error("请先粘贴内容 / 选择文件 / 填写 URL");
const d = detectImportFormat(content);
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(content);
const result = importByFormat(content, fmt);
const requestCount = result.collections.reduce((acc, c) => acc + countRequests(c), 0);
importCollections(result.collections, result.environmentVariables, fmt === "openapi" ? mode : "new");
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");
// 桌面端使用本机 HTTP 传输;浏览器端经 Gin 代理规避 CORS。
let content: string;
if (isTauriRuntime()) {
const result = await tauriNativeTransport.http({ method: "GET", url: url.trim(), headers: { Accept: "application/json, text/yaml, text/plain, */*" }, timeoutMs: 30000 });
if (result.status >= 400) throw new Error(`目标返回 HTTP ${result.status}`);
content = result.body;
} else {
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}`);
}
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); useWorkspace.getState().setView("docs"); }}>
查看接口文档
</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>
{(format === "openapi" || (format === "auto" && detected === "openapi") || source === "url") && <label className="block text-xs">同名项目导入方式
<select className="ops-input mt-1" value={mode} onChange={(e) => setMode(e.target.value as typeof mode)}>
<option value="new">创建新项目(保留现有项目)</option>
<option value="merge">合并同名项目(同方法和路径更新,保留未导入接口)</option>
<option value="replace">覆盖同名项目(未导入接口将移除)</option>
</select>
{mode !== "new" && <span className="ops-faint mt-1 block">当前有 {projects.length} 个项目。导入仅匹配名称完全相同的项目。</span>}
</label>}
{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>
);
}
+113
View File
@@ -0,0 +1,113 @@
/**
* 定时任务管理:为每个用例配置启用状态与运行间隔(应用运行期间生效)。
*/
import { isValidCron } from "../../core/index";
import { nextRunAt } from "../../scheduler";
import { useWorkspace } from "../../workspaceStore";
import { Modal, cx } from "../common";
import { Icon } from "../icons";
const INTERVALS = [5, 15, 30, 60, 360, 1440];
function intervalLabel(m: number): string {
if (m < 60) return `每 ${m} 分钟`;
if (m < 1440) return `每 ${m / 60} 小时`;
return "每天";
}
export function ScheduleDialog({ onClose, focusCaseId }: { onClose: () => void; focusCaseId?: string | null }) {
const testCases = useWorkspace((s) => s.testCases);
const schedules = useWorkspace((s) => s.schedules);
const testRuns = useWorkspace((s) => s.testRuns);
const upsertSchedule = useWorkspace((s) => s.upsertSchedule);
const removeSchedule = useWorkspace((s) => s.removeSchedule);
const lastRunOf = (caseId: string) => testRuns.find((r) => r.caseId === caseId);
return (
<Modal
title="定时任务"
onClose={onClose}
width="640px"
footer={
<div className="flex w-full items-center justify-between">
<span className="ops-faint text-[11px]">应用保持运行时按 Cron 或间隔自动执行;写入运行报告与调度日志;关闭应用后暂停</span>
<button className="ops-btn ops-btn-primary" onClick={onClose}>
完成
</button>
</div>
}
>
{testCases.length === 0 ? (
<div className="ops-faint py-8 text-center text-xs">还没有场景用例,先在左侧新建或用 AI 生成</div>
) : (
<div className="max-h-[52vh] space-y-2 overflow-y-auto">
{[...testCases].sort((a, b) => Number(b.id === focusCaseId) - Number(a.id === focusCaseId)).map((tc) => {
const sc = schedules.find((s) => s.caseId === tc.id);
const last = lastRunOf(tc.id);
return (
<div key={tc.id} className="flex flex-wrap items-center gap-2 rounded-xl px-3 py-2.5" style={{ border: "1px solid var(--border)" }}>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium">{tc.name}</div>
<div className="ops-faint text-[11px]">
{sc?.cron && !isValidCron(sc.cron)
? "Cron 无效,请修正后启用"
: sc?.enabled
? `下次约 ${new Date(nextRunAt(sc.intervalMinutes, sc.lastRunAt, sc.cron)).toLocaleTimeString("zh-CN")} 运行`
: "未启用定时"}
{sc?.lastRunAt ? ` · 上次 ${new Date(sc.lastRunAt).toLocaleString("zh-CN")}` : ""}
{last ? ` · ${last.passed ? "通过" : "失败"}` : ""}
{sc?.runLog?.[0] ? ` · 日志 ${sc.runLog[0].message}` : ""}
</div>
</div>
<input
className="ops-input ops-input-sm !w-36 font-mono text-[11px]"
placeholder="Cron */5 * * * *"
disabled={!sc}
value={sc?.cron ?? ""}
title="5 段 Cron;填写后优先于间隔"
onChange={(e) => {
const v = e.target.value.trim();
upsertSchedule(tc.id, { cron: v || null, ...(v && !isValidCron(v) ? { enabled: false } : {}) });
}}
style={sc?.cron && !isValidCron(sc.cron) ? { borderColor: "var(--danger)" } : undefined}
/>
<select
className="ops-input ops-input-sm !w-28"
value={sc?.intervalMinutes ?? 30}
disabled={!sc || Boolean(sc?.cron?.trim())}
onChange={(e) => upsertSchedule(tc.id, { intervalMinutes: Number(e.target.value), cron: null })}
>
{INTERVALS.map((m) => (
<option key={m} value={m}>
{intervalLabel(m)}
</option>
))}
</select>
{sc ? (
<>
<button
className={cx("ops-btn", sc.enabled ? "ops-btn-secondary" : "ops-btn-primary", "!px-2")}
title={sc.enabled ? "暂停" : "启用"}
disabled={!sc.enabled && !!sc.cron && !isValidCron(sc.cron)}
onClick={() => upsertSchedule(tc.id, { enabled: !sc.enabled })}
>
<Icon name={sc.enabled ? "circle-x" : "play"} size={13} />
</button>
<button className="ops-btn ops-btn-ghost ops-danger px-1.5" title="删除定时" onClick={() => removeSchedule(tc.id)}>
<Icon name="trash" size={13} />
</button>
</>
) : (
<button className="ops-btn ops-btn-secondary !px-2" title="添加定时" onClick={() => upsertSchedule(tc.id, {})}>
<Icon name="plus" size={13} />
</button>
)}
</div>
);
})}
</div>
)}
</Modal>
);
}
+827
View File
@@ -0,0 +1,827 @@
import { ProxySettingsPanel } from "../../features/settings/ProxySettingsPanel";
import { MockSharingPanel } from "../../features/mock/MockSharingPanel";
import { AiProviderSettings } from "../../features/settings/AiProviderSettings";
/**
* 工作区对话框:新建项 / 重命名 / 环境管理 / 设置。
*/
import type { RequestKind } from "../../core/index";
import { generateMock, KIND_LABELS, MOCK_CHEATSHEET, walk } from "../../core/index";
import { useState } from "react";
import { useWorkspace } from "../../workspaceStore";
import { KVEditor, Modal, cx } from "../common";
import { EnvBadge } from "../EnvBadge";
import { Icon, type IconName } from "../icons";
import { KindIcon } from "../KindIcon";
import { Logo } from "../Logo";
import { WECHAT_GROUP_URL } from "../../links";
import { openExternal } from "../../openExternal";
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="flex justify-center"><KindIcon kind={k.kind} size={30} /></div>
<div className="mt-1.5 text-xs font-semibold">
{k.kind === "folder" ? "目录" : KIND_LABELS[k.kind]}
</div>
<div className="ops-faint mt-0.5 text-[11px]">{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="750px"
footer={
<div className="flex w-full items-center justify-between gap-3">
<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="env-dialog-heading">环境详情 <span className="ops-faint font-normal">· {isActive ? "当前使用" : "未启用"}</span></div>
<div className="env-dialog-name-row">
<EnvBadge envId={env.id} name={env.name} size={26} />
<input
className="ops-input min-w-0 flex-1"
aria-label="环境名称"
value={env.name}
onChange={(e) => updateEnvironment(env.id, { name: e.target.value })}
placeholder="环境名称"
/>
{isActive ? (
<button className="ops-btn ops-btn-secondary" onClick={() => setActiveEnvironment(null)} title="当前环境已用于请求;点击后不使用环境前置 URL">
取消使用
</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 ops-icon-button"
title="复制环境"
aria-label="复制环境"
onClick={() => setSelected(duplicateEnvironment(env.id))}
>
<Icon name="copy" size={14} />
</button>
<button
className="ops-btn ops-btn-ghost ops-danger ops-icon-button"
title="删除环境"
aria-label="删除环境"
onClick={() => {
removeEnvironment(env.id);
setSelected(useWorkspace.getState().environments[0]?.id ?? "");
}}
>
<Icon name="trash" size={14} />
</button>
</div>
<div className="mt-4">
<label className="ops-label">前置 URL</label>
<input
className="ops-input font-mono"
aria-label="当前环境的前置 URL"
value={env.baseUrl ?? ""}
onChange={(e) => updateEnvironment(env.id, { baseUrl: e.target.value })}
placeholder="https://api.example.com(请求路径以 / 开头时自动拼接)"
/>
<p className="ops-faint mt-1.5 text-[11px] leading-[1.45]">相对路径(如 /graphql)会随环境切换前缀;完整 URL 不受影响。<a href="/docs/env" target="_blank" rel="noopener noreferrer" className="ops-link">了解环境变量</a></p>
</div>
<div className="env-dialog-vars-head">
<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-36 min-w-0"
aria-label="搜索环境变量"
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">
暂无变量,可添加 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
compact
showAddButton={false}
/>
)}
{search && filteredVars.length === 0 && env.variables.length > 0 && (
<div className="ops-faint py-6 text-center text-xs">没有匹配的变量</div>
)}
</div>
<p className="ops-faint mt-2 text-[11px]">在请求中使用 {"{{变量名}}"} 引用变量,切换环境时自动替换。</p>
</>
) : (
<div className="ops-faint grid h-full place-items-center text-sm">
{environments.length === 0 ? "左侧点击「新建环境」开始" : "选择左侧环境"}
</div>
)}
</div>
</div>
</Modal>
);
}
/* ---------------- 设置弹窗(左侧导航 + 分区) ---------------- */
type SettingsTab = "appearance" | "network" | "mock" | "mockdata" | "ai" | "data" | "about";
const SETTINGS_TABS: { id: SettingsTab; label: string; desc: string; icon: IconName }[] = [
{ id: "appearance", label: "外观", desc: "主题与界面", icon: "palette" },
{ id: "network", label: "网络", desc: "请求与超时", icon: "network" },
{ id: "mock", label: "Mock 服务", desc: "服务开关与访问地址", icon: "flask-conical" },
{ id: "ai", label: "大模型", desc: "供应商与默认模型", icon: "bot" },
{ id: "data", label: "数据管理", desc: "工作区与备份", icon: "database" },
{ id: "about", label: "关于", desc: "版本与链接", icon: "info" },
];
function SettingRow({
label,
desc,
children,
}: {
label: string;
desc?: React.ReactNode;
children?: React.ReactNode;
}) {
return (
<div className="set-row">
<div className="min-w-0 flex-1">
<div className="text-[13px] font-medium">{label}</div>
{desc ? <div className="ops-faint mt-0.5 text-[11px] leading-5">{desc}</div> : null}
</div>
<div className="set-row-control">{children}</div>
</div>
);
}
function SettingGroup({ title, children }: { title?: string; children: React.ReactNode }) {
return (
<section className="set-group">
{title ? <div className="set-group-title">{title}</div> : null}
{children}
</section>
);
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
className={cx("set-toggle", checked && "on")}
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
>
<span className="set-toggle-dot" />
</button>
);
}
export function SettingsDialog({ tab }: { tab?: SettingsTab }) {
const settings = useWorkspace((s) => s.settings);
const environments = useWorkspace((s) => s.environments);
const collections = useWorkspace((s) => s.collections);
const setTheme = useWorkspace((s) => s.setTheme);
const setTimeoutMs = useWorkspace((s) => s.setTimeoutMs);
const setMockOption = useWorkspace((s) => s.setMockOption);
const updateMockRule = useWorkspace((s) => s.updateMockRule);
const addMockRule = useWorkspace((s) => s.addMockRule);
const removeMockRule = useWorkspace((s) => s.removeMockRule);
const resetMockRules = useWorkspace((s) => s.resetMockRules);
const resetWorkspace = useWorkspace((s) => s.resetWorkspace);
const setDialog = useWorkspace((s) => s.setDialog);
const [active, setActive] = useState<SettingsTab>(tab ?? "appearance");
const [ruleSearch, setRuleSearch] = useState("");
const [previewRule, setPreviewRule] = useState("");
const mock = settings.mock;
const filteredRules = mock.rules.filter((r) => {
const q = ruleSearch.trim().toLowerCase();
if (!q) return true;
return (
r.pattern.toLowerCase().includes(q) ||
r.rule.toLowerCase().includes(q) ||
(r.note ?? "").toLowerCase().includes(q)
);
});
const requestCount = collections.reduce(
(acc, c) => acc + (c.children.filter((n) => n.type === "request").length ? 1 : 0),
0,
);
let totalRequests = 0;
for (const c of collections) {
walk(c.children, (n) => {
if (n.type === "request") totalRequests++;
});
}
void requestCount;
const currentMeta = SETTINGS_TABS.find((t) => t.id === active) ?? { label: "请求数据生成" };
return (
<Modal
title="设置"
onClose={() => setDialog(null)}
width="860px"
footer={<button className="ops-btn ops-btn-primary" onClick={() => setDialog(null)}>完成</button>}
>
<div className="set-dialog">
<aside className="set-side">
<div className="set-side-head">设置</div>
{SETTINGS_TABS.map((t) => (
<button
key={t.id}
className={cx("set-side-item", (active === t.id || (active === "mockdata" && t.id === "network")) && "active")}
onClick={() => setActive(t.id)}
>
<span className={cx("set-side-icon", active === t.id && "active")}>
<Icon name={t.icon} size={15} />
</span>
<span className="min-w-0 flex-1 text-left">
<span className="block truncate text-[13px] font-medium leading-5">{t.label}</span>
<span className="ops-faint block truncate text-[11px] leading-4">{t.desc}</span>
</span>
</button>
))}
</aside>
<div className="set-main">
<div className="set-main-head">
<div className="flex items-center gap-2 text-[16px] font-semibold">
{active === "mockdata" && <button type="button" className="ops-btn ops-btn-ghost !px-1" aria-label="返回网络设置" title="返回网络设置" onClick={() => setActive("network")}><Icon name="chevron-right" size={14} className="rotate-180" /></button>}
{currentMeta.label}
</div>
</div>
<div className="set-main-body">
{active === "appearance" && (
<>
<SettingGroup title="主题">
<SettingRow label="界面主题">
<div className="flex gap-1.5">
{(["dark", "light", "system"] as const).map((t) => (
<button
key={t}
className={cx(
"ops-btn ops-btn-secondary",
settings.theme === t && "ops-chip-active",
)}
onClick={() => setTheme(t)}
>
{t !== "system" && <Icon name={t === "dark" ? "moon" : "sun"} size={13} />}
{t === "system" ? "跟随系统" : t === "dark" ? "深色" : "浅色"}
</button>
))}
</div>
</SettingRow>
</SettingGroup>
<SettingGroup title="界面">
<SettingRow
label="环境徽标颜色"
desc="每个环境按名称生成固定色块,便于在顶栏快速识别"
>
<div className="flex items-center gap-1">
{environments.slice(0, 6).map((e) => (
<EnvBadge key={e.id} envId={e.id} name={e.name} size={22} />
))}
</div>
</SettingRow>
</SettingGroup>
</>
)}
{active === "network" && (
<>
<SettingGroup title="请求">
<SettingRow label="请求超时(毫秒)" desc="HTTP / GraphQL 请求的超时上限,超时自动中断">
<input
type="number"
className="ops-input ops-input-sm w-32"
value={settings.timeoutMs}
min={1000}
step={1000}
onChange={(e) => setTimeoutMs(Number(e.target.value) || 30000)}
/>
</SettingRow>
</SettingGroup>
<ProxySettingsPanel />
<SettingGroup title="可选工具">
<SettingRow label="请求数据生成" desc="发送请求时使用 @ 表达式与字段匹配规则;与内网 Mock 服务独立">
<button type="button" className="ops-btn ops-btn-secondary" onClick={() => setActive("mockdata")}>管理规则 <Icon name="chevron-right" size={13} /></button>
</SettingRow>
</SettingGroup>
<SettingGroup title="实时连接">
<SettingRow
label="WebSocket / SSE / Socket.IO / MQTT"
desc="关闭标签页自动断开连接;会话不落盘,刷新后重新连接"
>
<span className="ops-faint text-[11px]">自动管理</span>
</SettingRow>
</SettingGroup>
</>
)}
{active === "mock" && <MockSharingPanel />}
{active === "mockdata" && (
<>
<SettingGroup>
<SettingRow
label="启用请求数据生成"
desc="发送请求时解析参数与 Body 中的 @ 表达式(如 @cname、@integer(1,100));不改变内网 Mock 服务的返回数据"
>
<Toggle checked={mock.enabled} onChange={(v) => setMockOption("enabled", v)} />
</SettingRow>
<SettingRow
label="智能字段匹配"
desc="JSON Body 中字段值未写 Mock 规则时,按字段名自动匹配下方规则生成"
>
<Toggle checked={mock.smart} onChange={(v) => setMockOption("smart", v)} />
</SettingRow>
</SettingGroup>
<SettingGroup title="智能匹配规则">
<div className="ops-hint">
<Icon name="info" size={14} />
<span>
按从上到下的顺序匹配,先命中先用;<b>* 通配任意字符</b>,类型需与 JSON 字段一致。
<a
href="/docs/mock"
target="_blank"
rel="noopener noreferrer"
className="ops-link ml-1"
>
语法速查 →
</a>
</span>
</div>
<div className="mb-2 flex items-center gap-2">
<input
className="ops-input ops-input-sm w-48"
placeholder="搜索规则 / 表达式…"
value={ruleSearch}
onChange={(e) => setRuleSearch(e.target.value)}
/>
<span className="ops-faint text-[11px]">{mock.rules.length} 条</span>
<div className="ml-auto flex gap-1.5">
<button className="ops-btn ops-btn-secondary" onClick={() => addMockRule()}>
<Icon name="plus" size={13} /> 新增规则
</button>
<button
className="ops-btn ops-btn-secondary"
title="恢复内置默认规则(会覆盖自定义规则)"
onClick={() => resetMockRules()}
>
<Icon name="eraser" size={13} /> 恢复默认
</button>
</div>
</div>
<div className="mock-rules">
<div className="mock-rule-head">
<span>数据类型</span>
<span>匹配方式</span>
<span>匹配规则</span>
<span>Mock 表达式</span>
<span>备注</span>
<span>启用</span>
<span />
</div>
{filteredRules.map((r) => (
<div key={r.id} className="mock-rule-row">
<select
className="ops-input ops-input-sm"
value={r.type}
onChange={(e) => updateMockRule(r.id, { type: e.target.value as never })}
>
<option value="string">string</option>
<option value="number">number</option>
<option value="integer">integer</option>
<option value="boolean">boolean</option>
</select>
<select
className="ops-input ops-input-sm"
value={r.match}
onChange={(e) => updateMockRule(r.id, { match: e.target.value as never })}
>
<option value="wildcard">通配符</option>
<option value="exact">完全匹配</option>
<option value="regex">正则</option>
</select>
<input
className="ops-input ops-input-sm mock-rule-pattern"
value={r.pattern}
placeholder="*name*"
onChange={(e) => updateMockRule(r.id, { pattern: e.target.value })}
/>
<input
className="ops-input ops-input-sm mock-rule-expr"
value={r.rule}
placeholder="@cname"
onChange={(e) => updateMockRule(r.id, { rule: e.target.value })}
/>
<input
className="ops-input ops-input-sm mock-rule-note"
value={r.note ?? ""}
placeholder="备注"
onChange={(e) => updateMockRule(r.id, { note: e.target.value })}
/>
<Toggle
checked={r.enabled}
onChange={(v) => updateMockRule(r.id, { enabled: v })}
/>
<button
className="ops-btn ops-btn-ghost ops-danger px-1.5"
title="删除规则"
onClick={() => removeMockRule(r.id)}
>
<Icon name="trash" size={13} />
</button>
</div>
))}
{filteredRules.length === 0 && (
<div className="ops-faint py-6 text-center text-xs">没有匹配的规则</div>
)}
</div>
</SettingGroup>
<SettingGroup title="表达式预览">
<div className="flex items-center gap-2">
<input
className="ops-input ops-input-sm w-56"
placeholder="输入表达式预览,如 @email"
value={previewRule}
onChange={(e) => setPreviewRule(e.target.value)}
/>
{previewRule.trim().startsWith("@") && (
<span className="mock-preview-value">{generateMock(previewRule.trim())}</span>
)}
</div>
<div className="mock-cheatsheet">
{MOCK_CHEATSHEET.map((c) => (
<button
key={c.expr}
className="mock-cheat-item"
title="点击试一试"
onClick={() => setPreviewRule(c.expr)}
>
<code>{c.expr}</code>
<span className="ops-faint">{c.desc}</span>
<span className="mock-cheat-gen">{generateMock(c.expr)}</span>
</button>
))}
</div>
</SettingGroup>
</>
)}
{active === "ai" && <AiProviderSettings />}
{active === "data" && (
<>
<SettingGroup title="工作区数据">
<SettingRow
label="本地存储"
desc="集合、环境与设置保存在本地 localStorage,不经过任何服务器"
>
<span className="ops-faint text-[11px]">浏览器本地</span>
</SettingRow>
<SettingRow label="数据概览">
<span className="ops-faint text-[11px]">
{collections.length} 个集合 · {totalRequests} 个接口 · {environments.length} 个环境
</span>
</SettingRow>
</SettingGroup>
<SettingGroup title="备份与恢复">
<SettingRow
label="导出全量备份"
desc="包含全部集合、环境与设置,可在其他设备导入恢复"
>
<button
className="ops-btn ops-btn-secondary"
onClick={() => setDialog({ type: "export", collectionId: collections[0]?.id ?? "" })}
>
<Icon name="download" size={13} /> 打开导出
</button>
</SettingRow>
<SettingRow
label="重置工作区"
desc="恢复到初始示例数据,当前数据不可恢复,建议先导出备份"
>
<button
className="ops-btn ops-btn-danger"
onClick={() => {
if (confirm("确定重置工作区?将恢复到初始示例数据(建议先导出备份)")) {
resetWorkspace();
setDialog(null);
}
}}
>
<Icon name="trash" size={13} /> 重置
</button>
</SettingRow>
</SettingGroup>
</>
)}
{active === "about" && (
<>
<p className="ops-faint px-1 text-[11px]">
OpsxAPI · 专业接口调试工作台 · HTTP · GraphQL · WebSocket · SSE · Socket.IO · MQTT
</p>
<SettingGroup title="资源链接">
<SettingRow label="微信群" desc="扫码加入交流群">
<button
type="button"
className="ops-btn ops-btn-secondary"
title="打开微信群"
onClick={() => void openExternal(WECHAT_GROUP_URL)}
>
<Icon name="external-link" size={13} />
</button>
</SettingRow>
<SettingRow label="用户文档" desc="功能说明、协议调试与最佳实践">
<button type="button" className="ops-btn ops-btn-secondary" title="打开用户文档" onClick={() => void openExternal("/docs")}>
<Icon name="book-open" size={13} />
</button>
</SettingRow>
<SettingRow label="官网与价格" desc="产品介绍、版本对比与购买方案">
<button type="button" className="ops-btn ops-btn-secondary" title="打开官网" onClick={() => void openExternal("/home")}>
<Icon name="external-link" size={13} />
</button>
</SettingRow>
<SettingRow label="Mock 语法速查" desc="内置 Mock.js 风格表达式与智能匹配说明">
<button type="button" className="ops-btn ops-btn-secondary" title="查看 Mock 速查" onClick={() => void openExternal("/docs/mock")}>
<Icon name="flask-conical" size={13} />
</button>
</SettingRow>
</SettingGroup>
<p className="ops-faint px-1 text-[11px]">
MIT License · Rust
</p>
</>
)}
</div>
</div>
</div>
</Modal>
);
}
+104
View File
@@ -0,0 +1,104 @@
/**
* GraphQL 编辑器:Query / Variables / Headers / Auth + 响应。
*/
import type { GraphqlApiNode, GraphqlRequestData, KVRow } from "../../core/index";
import { useState } from "react";
import { sendGraphqlRequest } from "../../send";
import { useWorkspace } from "../../workspaceStore";
import { KVEditor, cx } from "../common";
import { Icon } from "../icons";
import { UrlPathInput } from "../UrlPathInput";
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>
<UrlPathInput
className="ops-input flex-1 font-mono text-[13px]"
placeholder="https://api.example.com/graphql 或 /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>
);
}
+425
View File
@@ -0,0 +1,425 @@
import { createMockFromRequest } from "../../features/mock/mockStore";
/**
* HTTP 请求编辑器:方法/URL/Send + Params/Body/Headers/Cookies/Auth/说明 + 代码生成。
*/
import type { HttpApiNode, HttpRequestData, KVRow } from "../../core/index";
import {
applyMockToJson,
applyMockToValue,
BODY_MODE_LABELS,
RAW_BODY_MODES,
REQUEST_METHODS,
exportCurl,
generateCode,
type CodeTarget,
} from "../../core/index";
import { replaceVariables, smartFillJson } from "../../core/index";
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 { UrlPathInput, joinUrl, useEnvBaseUrl } from "../UrlPathInput";
import { SplitPane } from "../SplitPane";
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 saveRequest = useWorkspace((s) => s.saveRequest);
const dirty = useWorkspace((s) => Boolean(s.dirtyIds[node.id]));
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 { baseUrl: envBase } = useEnvBaseUrl();
const resolvedUrl = joinUrl(envBase, 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, url: resolvedUrl }) }),
}));
return (
<div className="flex min-h-0 flex-1 flex-col">
{/* URL 栏 */}
<div className="flex min-w-0 shrink-0 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>
<UrlPathInput
className="ops-input min-w-0 flex-1 font-mono text-[13px]"
ariaLabel="请求 URL"
placeholder="https://api.example.com/v1/resource 或 /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>
</div>
<div className="flex min-w-0 shrink-0 items-center gap-2 px-3 py-1.5" style={{ borderBottom: "1px solid var(--border)" }}>
<div className="ops-faint min-w-0 flex-1 truncate text-[11px]" title={resolvedUrl}>
{envBase && /^https?:\/\//.test(data.url) ? "固定地址:" : "请求地址:"}<span className="ops-accent font-mono">{resolvedUrl}</span>
</div>
<button
className="ops-btn ops-btn-secondary ops-icon-button"
disabled={!dirty}
onClick={() => saveRequest(node.id)}
title={dirty ? "保存请求到集合树" : "请求已保存"}
aria-label={dirty ? "保存请求" : "请求已保存"}
>
<Icon name="file-down" size={15} />
</button>
<button
className="ops-btn ops-btn-secondary ops-icon-button"
onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setCodeMenu({ x: rect.left, y: rect.bottom + 4 });
}}
title="生成请求代码"
aria-label="生成请求代码"
>
<Icon name="code" size={15} />
</button>
<button
className="ops-btn ops-btn-secondary ops-icon-button"
onClick={() => { try { createMockFromRequest(node); } catch (e) { useWorkspace.getState().showToast(String(e), "error"); } }}
title="复制此接口的方法与路径到 Mock,独立编辑响应"
aria-label="创建 Mock"
>
<Icon name="flask-conical" size={15} />
</button>
{runtime?.result && <button className="ops-btn ops-btn-secondary ops-icon-button" title="用本次响应创建 Mock" aria-label="用本次响应创建 Mock" onClick={() => { try { createMockFromRequest(node, runtime.result ?? undefined); } catch (e) { useWorkspace.getState().showToast(String(e), "error"); } }}><Icon name="file-plus" size={15} /></button>}
</div>
<SplitPane direction="vertical" initialRatio={0.45} storageKey={`opsxapi.http.split.${node.id}`} minPx={100} minSecondPx={180}>
<div className="flex min-h-0 flex-1 flex-col">
<div className="ops-toolbar 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 min-h-0 flex-1 p-3.5">
{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>
</div>
<ResponsePanel nodeId={node.id} />
</SplitPane>
<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>
)}
{auth.mode !== "none" && <AuthPreview auth={auth} />}
</div>
);
}
function AuthPreview({ auth }: { auth: HttpRequestData["auth"] }) {
const env = activeEnvMap();
let line = "";
if (auth.mode === "bearer" && auth.bearer) {
const token = replaceVariables(auth.bearer, env);
const masked = token.length <= 8 ? "••••" : `${token.slice(0, 4)}…${token.slice(-4)}`;
line = `Authorization: Bearer ${masked}`;
} else if (auth.mode === "basic") {
const user = replaceVariables(auth.basicUser ?? "", env);
line = `Authorization: Basic ${user}:••••`;
} else if (auth.mode === "apikey" && auth.apiKeyName) {
const value = replaceVariables(auth.apiKeyValue ?? "", env);
const masked = value.length <= 6 ? "••••" : `${value.slice(0, 3)}…`;
line =
auth.apiKeyIn === "query"
? `?${auth.apiKeyName}=${masked}`
: `${auth.apiKeyName}: ${masked}`;
}
if (!line) return null;
return (
<div className="ops-faint rounded-md border border-dashed px-2.5 py-2 font-mono text-[11px]" style={{ borderColor: "var(--border)" }}>
<div className="mb-0.5 text-[11px] uppercase tracking-wide opacity-70">鉴权预览(已套环境变量)</div>
<div className="text-[12px] text-[var(--text)]">{line}</div>
</div>
);
}
/* ---------------- Body ---------------- */
function BodyEditor({
data,
patch,
}: {
data: HttpRequestData;
patch: (p: Partial<HttpRequestData>) => void;
}) {
const mode = data.bodyMode as BodyTab;
const mock = useWorkspace((s) => s.settings.mock);
const setDialog = useWorkspace((s) => s.setDialog);
const [mockPreview, setMockPreview] = useState<string | null>(null);
const smartFill = () => {
const { text, filled } = smartFillJson(data.bodyRaw, mock);
if (filled === 0) {
alert("未命中任何智能规则:请检查 JSON 字段名,或打开「请求数据生成」调整规则");
return;
}
patch({ bodyRaw: text });
};
const previewMock = () => {
const raw = RAW_BODY_MODES.includes(mode) ? data.bodyRaw : "";
if (mode === "json") setMockPreview(applyMockToJson(raw, mock));
else if (mode === "urlencoded" || mode === "form-data") {
const lines = data.bodyForm
.filter((r) => r.enabled && r.key.trim())
.map((r) => `${r.key}: ${applyMockToValue(r.value, mock, r.key)}`);
setMockPreview(lines.join("\n") || "(空)");
} else setMockPreview(applyMockToValue(raw, mock) || "(空)");
};
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>
)}
{mode !== "none" && (
<div className="ml-auto flex items-center gap-1.5">
<button
className="ops-btn ops-btn-ghost"
title="按智能规则把字段值替换为 @mock 表达式(可再编辑)"
onClick={smartFill}
disabled={!mock.enabled && !mock.smart}
>
<Icon name="wand-sparkles" size={13} /> 智能填充
</button>
<button className="ops-btn ops-btn-ghost" title="预览发送请求时生成的数据" onClick={previewMock}>
<Icon name="flask-conical" size={13} /> 数据预览
</button>
<button className="ops-btn ops-btn-ghost" title="配置请求数据生成表达式与智能匹配规则" onClick={() => setDialog({ type: "settings", tab: "mockdata" })}>管理规则</button>
</div>
)}
</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 "username": "@cname",\n "email": "@email",\n "age": "@integer(18, 60)"\n}'
: mode === "xml"
? "<root>\n <item>value</item>\n</root>"
: "原始文本内容(可用 @cname 等 Mock 表达式)"
}
value={data.bodyRaw}
onChange={(e) => patch({ bodyRaw: e.target.value })}
/>
{mock.enabled && (
<div className="ops-faint text-[11px]">
值以 <code className="ops-accent">@</code> 开头即按 Mock 表达式生成
{mock.smart && ";开启智能匹配后字段名命中规则也会自动生成"},发送前可用「数据预览」查看。
</div>
)}
</>
)}
{(mode === "urlencoded" || mode === "form-data") && (
<KVEditor
rows={data.bodyForm}
onChange={(rows) => patch({ bodyForm: rows })}
keyPlaceholder="字段名"
valuePlaceholder="值(可填 @cname 等 Mock 表达式)"
/>
)}
{mode === "form-data" && (
<div className="ops-faint text-[11px]">当前支持文本字段;文件上传请用 cURL / 代码生成。</div>
)}
{mockPreview !== null && (
<CodeViewModal title="请求数据预览" code={mockPreview} onClose={() => setMockPreview(null)} />
)}
</div>
);
}
export { exportCurl };
+134
View File
@@ -0,0 +1,134 @@
/**
* MQTT 工作台:broker 配置 + 订阅 + 发布 + 消息日志。
*/
import type { MqttApiNode, MqttRequestData } from "../../core/index";
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>
);
}
+140
View File
@@ -0,0 +1,140 @@
/**
* HTTP / GraphQL 响应面板:Pretty / Raw / 响应头 + 状态条。
*/
import { useEffect, useState } from "react";
import { useWorkspace } from "../../workspaceStore";
import { findNode } from "../../core";
import { CopyButton, JsonView, cx, formatBytes } from "../common";
import { Icon } from "../icons";
import { ProxyErrorBanner } from "../ProxyErrorBanner";
import { canSaveResponseExample, responseContentType, responseIsJson } from "./responseFormat";
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 canSaveExample = useWorkspace((s) => {
const node = findNode(s.collections, nodeId)?.node;
return node?.type === "request" && node.kind === "http";
});
const [wrap, setWrap] = useState(() => { try { return localStorage.getItem("opsxapi.response.wrap") !== "off"; } catch { return true; } });
const [showLarge, setShowLarge] = useState(false);
useEffect(() => setShowLarge(false), [result]);
const contentType = responseContentType(result?.headers ?? {});
const isJson = responseIsJson(contentType, result?.body ?? "");
const largeBody = (result?.body.length ?? 0) > 1024 * 1024;
const canSaveBody = !!result && canSaveResponseExample(result.status, contentType);
const saveExample = () => {
if (!result || !canSaveBody) return;
const state = useWorkspace.getState();
const node = findNode(state.collections, nodeId)?.node;
if (!node || node.type !== "request" || node.kind !== "http") return;
const documentation = node.documentation ?? { tags: [], parameters: [], responses: [] };
const index = documentation.responses.findIndex((entry) => entry.status === String(result.status));
if (index >= 0 && !window.confirm(`文档中已有 HTTP ${result.status} 响应示例,确定覆盖?`)) return;
const example = {
...(documentation.responses[index] ?? { status: String(result.status), description: "" }),
contentType: contentType || (isJson ? "application/json" : "text/plain"),
example: result.body,
};
const responses = [...documentation.responses];
if (index < 0) responses.push(example); else responses[index] = example;
state.updateApiDocumentation(nodeId, { ...documentation, responses });
state.showToast(`HTTP ${result.status} 响应已保存到接口文档`, "success");
};
return (
<section className="response-panel flex min-h-0 flex-1 flex-col" style={{ borderTop: "1px solid var(--border)" }}>
<div
className="response-toolbar flex shrink-0 flex-wrap items-center justify-between gap-x-3 px-3"
style={{ borderBottom: "1px solid var(--border)" }}
>
<div className="flex">
{(
[
["body", isJson ? "Pretty JSON" : "响应体"],
["raw", "Raw"],
["headers", `响应头${result ? ` ${Object.keys(result.headers).length}` : ""}`],
] 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="response-metrics flex min-w-0 items-center gap-2 py-1 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 whitespace-nowrap" title="响应耗时">
{result.timeMs} ms
</span>
<span className="ops-dim whitespace-nowrap" title="响应大小">
{formatBytes(result.size)}
</span>
{contentType && (
<span className="ops-faint response-content-type truncate" title={contentType}>
{contentType}
</span>
)}
{tab !== "headers" && <button className={cx("ops-btn ops-btn-ghost ops-icon-button", wrap && "ops-accent")} aria-label={wrap ? "关闭自动换行" : "开启自动换行"} aria-pressed={wrap} title={wrap ? "关闭自动换行" : "开启自动换行"} onClick={() => { setWrap(!wrap); try { localStorage.setItem("opsxapi.response.wrap", wrap ? "off" : "on"); } catch { /* optional preference */ } }}><Icon name="wrap-text" size={14} /></button>}
{canSaveExample && <button className="ops-btn ops-btn-ghost ops-icon-button" disabled={!canSaveBody} aria-label={`将 HTTP ${result.status} 响应保存到接口文档`} title={canSaveBody ? `将 HTTP ${result.status} 响应保存到接口文档;同状态码会确认覆盖` : `HTTP ${result.status} 返回 HTML 错误页,请先检查请求地址和环境`} onClick={saveExample}><Icon name="book-open" size={14} /></button>}
<CopyButton text={result.body} label="复制响应体" iconOnly />
</>
) : null}
</div>
</div>
<div className="ops-scroll min-h-0 flex-1 p-3">
{!loading && result?.status === 404 && <p className="ops-notice mb-3 text-xs" role="status">HTTP 404:服务器找不到这个路径,请核对上方完整请求地址和当前环境。</p>}
{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 && largeBody && !showLarge && tab !== "headers" && <div className="ops-empty-state h-full gap-3"><p>响应体超过 1 MB,展开可能影响窗口流畅度。</p><button className="ops-btn ops-btn-secondary" onClick={() => setShowLarge(true)}>显示完整响应</button></div>}
{!loading && result && (!largeBody || showLarge) && tab === "body" && (
<div className={cx("ops-code min-w-0", wrap ? "response-wrap" : "response-nowrap")}>
{isJson ? <JsonView key={result.body} text={result.body} /> : <pre className={cx("m-0", wrap ? "whitespace-pre-wrap break-all" : "whitespace-pre")}>{result.body}</pre>}
</div>
)}
{!loading && result && (!largeBody || showLarge) && tab === "raw" && (
<pre className={cx("ops-code m-0 min-w-0", wrap ? "whitespace-pre-wrap break-all" : "whitespace-pre")}>{result.body}</pre>
)}
{!loading && result && tab === "headers" && (
Object.entries(result.headers).length ? <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> : <p className="ops-faint px-2 py-6 text-center text-xs">响应中没有 Header</p>
)}
</div>
</section>
);
}
+127
View File
@@ -0,0 +1,127 @@
/**
* Socket.IO 工作台:连接配置 + 事件监听 + emit 发送。
*/
import type { SocketIoApiNode, SocketIoRequestData } from "../../core/index";
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>
);
}
+121
View File
@@ -0,0 +1,121 @@
/**
* SSE 工作台:连接配置 + 事件流日志。
*/
import type { KVRow, SseApiNode, SseRequestData } from "../../core/index";
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 { UrlPathInput } from "../UrlPathInput";
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>
<UrlPathInput
className="ops-input flex-1 font-mono text-[13px]"
placeholder="https://stream.example.com/events 或 /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 };
+159
View File
@@ -0,0 +1,159 @@
/**
* WebSocket 工作台:左鉴权/命令 · 右发送/日志(设计师 P0)
*/
import type { KVRow, WebsocketApiNode, WebsocketRequestData } from "../../core/index";
import { useMemo, useState } from "react";
import { connectWs, wsSend } from "../../send";
import { useWorkspace } from "../../workspaceStore";
import { SplitPane } from "../SplitPane";
import { KVEditor, cx } from "../common";
import { Icon } from "../icons";
import { VarHintInput } from "../VarHintInput";
import { Composer, LiveLog, LiveStatus } from "./liveCommon";
const COMMANDS = [
{ id: "ping", label: "Ping", body: "{\n \"op\": \"ping\"\n}" },
{ id: "auth", label: "Auth", body: "{\n \"op\": \"auth\",\n \"token\": \"{{token}}\"\n}" },
{ id: "sub", label: "Subscribe", body: "{\n \"op\": \"subscribe\",\n \"args\": [\"topic\"]\n}" },
{ id: "unsub", label: "Unsubscribe", body: "{\n \"op\": \"unsubscribe\",\n \"args\": [\"topic\"]\n}" },
];
export function WebsocketEditor({ node }: { node: WebsocketApiNode }) {
const data = node.data;
const updateRequestData = useWorkspace((s) => s.updateRequestData);
const saveRequest = useWorkspace((s) => s.saveRequest);
const dirty = useWorkspace((s) => Boolean(s.dirtyIds[node.id]));
const runtime = useWorkspace((s) => s.runtime[node.id]);
const updateNodeDescription = useWorkspace((s) => s.updateNodeDescription);
const [input, setInput] = useState("{\n \"op\": \"ping\"\n}");
const [showAdvanced, setShowAdvanced] = useState(false);
const [showDesc, setShowDesc] = useState(false);
const patch = (p: Partial<WebsocketRequestData>) => updateRequestData(node.id, p);
const connected = runtime?.status === "open";
const websocat = useMemo(() => {
const headers = (data.headers ?? [])
.filter((r) => r.enabled && r.key.trim())
.map((r) => `-H ${JSON.stringify(`${r.key}: ${r.value}`)}`)
.join(" ");
return `websocat ${JSON.stringify(data.url || "wss://")}${headers ? ` ${headers}` : ""}`;
}, [data.url, data.headers]);
const applyCmd = (body: string, direct: boolean) => {
setInput(body);
if (direct && connected) wsSend(node.id, body);
};
return (
<div className="flex min-h-0 flex-1 flex-col">
<div
className="ops-toolbar flex shrink-0 flex-wrap items-center gap-2 px-3 py-2"
style={{ borderBottom: "1px solid var(--border)" }}
>
<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.org"
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>
<button
className="ops-btn ops-btn-secondary min-w-[72px]"
disabled={!dirty}
onClick={() => saveRequest(node.id)}
title={dirty ? "保存到集合树" : "已保存"}
>
{dirty ? "● 保存" : "已保存"}
</button>
<LiveStatus nodeId={node.id} />
<button className="ops-btn ops-btn-ghost text-xs" onClick={() => setShowDesc((v) => !v)}>
说明
</button>
</div>
{showDesc && (
<textarea
className="ops-code mx-3 mt-2 min-h-20 shrink-0 resize-y"
placeholder="接口说明(支持 Markdown)"
value={node.description ?? ""}
onChange={(e) => updateNodeDescription(node.id, e.target.value)}
/>
)}
<SplitPane direction="horizontal" initialRatio={0.36} storageKey={`opsxapi.ws.split.h.${node.id}`} minPx={220}>
<aside className="ops-scroll flex h-full flex-col gap-3 p-3" style={{ background: "var(--panel)" }}>
<div>
<div className="ops-label mb-1.5">鉴权 / 握手 Headers</div>
<KVEditor rows={data.headers ?? []} onChange={(rows: KVRow[]) => patch({ headers: rows })} emptyHint="点击添加鉴权 Header,例如 Authorization: Bearer {{token}}" />
</div>
<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>
<div className="ops-label mb-1.5">快捷命令</div>
<div className="flex flex-col gap-1">
{COMMANDS.map((c) => (
<button
key={c.id}
className="ops-btn ops-btn-secondary justify-start text-left text-xs"
title="点击填入;Shift+点击直接发送"
onClick={(e) => applyCmd(c.body, e.shiftKey)}
>
{c.label}
</button>
))}
</div>
<div className="ops-faint mt-1 text-[11px]">点击填入发送区 · Shift+点击直发</div>
</div>
<div>
<button className="ops-btn ops-btn-ghost px-0 text-xs" onClick={() => setShowAdvanced((v) => !v)}>
{showAdvanced ? "收起高级" : "高级 · websocat 命令"}
</button>
{showAdvanced && (
<div className="mt-1.5 space-y-1.5">
<textarea className="ops-code min-h-20 w-full resize-y text-[11px]" readOnly value={websocat} />
<button
className="ops-btn ops-btn-secondary text-xs"
onClick={() => navigator.clipboard.writeText(websocat)}
>
复制命令
</button>
</div>
)}
</div>
</aside>
<SplitPane direction="vertical" initialRatio={0.38} storageKey={`opsxapi.ws.split.v.${node.id}`} minPx={100}>
<div className="flex h-full min-h-0 flex-col p-2">
<Composer
value={input}
onChange={setInput}
disabled={!connected} disabledHint="连接后可发送"
onSend={() => wsSend(node.id, input)}
sendLabel="发送消息"
/>
</div>
<div className="flex h-full min-h-0 flex-col p-2 pt-0">
<LiveLog nodeId={node.id} />
</div>
</SplitPane>
</SplitPane>
</div>
);
}
+201
View File
@@ -0,0 +1,201 @@
/**
* 实时协议编辑器共用件:消息日志 / 发送器 / 状态栏。
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useWorkspace } from "../../workspaceStore";
import { StatusDot, cx, formatTime } from "../common";
const EMPTY_MESSAGES: never[] = [];
type Filter = "all" | "out" | "in" | "sys" | "error";
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);
const [filter, setFilter] = useState<Filter>("all");
const [pinned, setPinned] = useState(false);
const filtered = useMemo(() => {
if (filter === "all") return messages;
if (filter === "sys") return messages.filter((m) => m.dir !== "in" && m.dir !== "out" && m.dir !== "error");
return messages.filter((m) => m.dir === filter);
}, [messages, filter]);
useEffect(() => {
if (!pinned && ref.current) ref.current.scrollTop = ref.current.scrollHeight;
}, [filtered.length, pinned]);
const onScroll = () => {
const el = ref.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
setPinned(!atBottom);
};
return (
<div className="ops-card relative flex min-h-0 flex-1 flex-col overflow-hidden">
<div
className="flex shrink-0 flex-wrap items-center justify-between gap-2 px-3 py-2"
style={{ borderBottom: "1px solid var(--border)" }}
>
<div className="text-xs font-semibold">消息日志 · {filtered.length}/{messages.length}</div>
<div className="flex flex-wrap items-center gap-1.5">
{(
[
["all", "全部"],
["out", "发送"],
["in", "接收"],
["sys", "系统"],
["error", "错误"],
] as const
).map(([k, label]) => (
<button
key={k}
className={cx("ops-filter-chip", filter === k && "ops-filter-chip-active")}
onClick={() => setFilter(k)}
>
{label}
</button>
))}
<button className="ops-btn ops-btn-ghost py-0.5" onClick={() => clearLive(nodeId)}>
清空
</button>
</div>
</div>
<div
ref={ref}
onScroll={onScroll}
className="ops-scroll min-h-0 flex-1 space-y-1.5 p-3"
style={{ background: "var(--code-bg)" }}
>
{filtered.length === 0 && (
<div className="ops-faint grid h-full place-items-center text-xs">连接后收发消息会显示在这里</div>
)}
{filtered.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-[11px] 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>
{pinned && messages.length > 0 && (
<button
className="ops-btn ops-btn-primary absolute bottom-3 right-3 text-xs shadow"
onClick={() => {
setPinned(false);
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
}}
>
回到底部
</button>
)}
</div>
);
}
export function Composer({
value,
onChange,
onSend,
disabled,
placeholder = "消息内容(JSON / 文本)",
sendLabel = "发送",
disabledHint = "连接后可发送",
extra,
}: {
value: string;
onChange: (v: string) => void;
onSend: () => void;
disabled?: boolean;
placeholder?: string;
sendLabel?: string;
disabledHint?: string;
extra?: React.ReactNode;
}) {
return (
<div className="ops-card flex h-full min-h-0 shrink-0 flex-col overflow-hidden">
{extra}
<div className="min-h-0 flex-1 p-2.5">
<textarea
className="ops-code h-full min-h-24 w-full resize-none"
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]">
{disabled && disabledHint ? disabledHint : "⌘/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`;
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { canSaveResponseExample, responseContentType, responseIsJson } from "./responseFormat";
describe("response display format", () => {
it("finds content type independently of header capitalization", () => {
expect(responseContentType({ "Content-Type": "application/problem+json; charset=utf-8" })).toBe("application/problem+json; charset=utf-8");
expect(responseIsJson("application/problem+json; charset=utf-8", "{}")).toBe(true);
});
it("uses body sniffing only when no content type was sent", () => {
expect(responseIsJson("", " [1,2]")).toBe(true);
expect(responseIsJson("text/plain", "{not json}")).toBe(false);
});
it("keeps documented JSON errors while rejecting HTML error pages", () => {
expect(canSaveResponseExample(404, "text/html; charset=utf-8")).toBe(false);
expect(canSaveResponseExample(404, "application/problem+json")).toBe(true);
expect(canSaveResponseExample(200, "text/html")).toBe(true);
});
});
+16
View File
@@ -0,0 +1,16 @@
/** HTTP header names are case insensitive; transports do not all normalize them. */
export function responseContentType(headers: Record<string, string>): string {
return Object.entries(headers).find(([name]) => name.toLowerCase() === "content-type")?.[1] ?? "";
}
export function responseIsJson(contentType: string, body: string): boolean {
if (/\b(?:json|\w+\+json)\b/i.test(contentType)) return true;
if (contentType.trim()) return false;
const trimmed = body.trimStart();
return trimmed.startsWith("{") || trimmed.startsWith("[");
}
/** A generic HTML error page is almost never a useful API example. */
export function canSaveResponseExample(status: number, contentType: string): boolean {
return status < 400 || !/^text\/html\b/i.test(contentType.trim());
}
+153
View File
@@ -0,0 +1,153 @@
/**
* 内置 SVG 图标(源自 lucide,ISC License:https://lucide.dev)
* 统一 24 网格 / currentColor 描边,尺寸由 size 控制。
*/
import type { CSSProperties } from "react";
export type IconName =
| "palette"
| "network"
| "flask-conical"
| "wand-sparkles"
| "database"
| "monitor"
| "external-link"
| "sparkles"
| "circle-check"
| "circle-x"
| "timer"
| "list-checks"
| "calendar-clock"
| "bot"
| "rotate-cw"
| "file-down"
| "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"
| "wrap-text"
| "play"
| "unplug"
| "eraser"
| "code"
| "clock"
| "settings"
| "arrow-left-right"
| "shield-check"
| "search"
| "send"
| "circle-slash"
| "info";
const ICONS: Record<IconName, string> = {
"circle-check": "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <path d=\"m16 9-5.5 5.5L8 12\" />",
"circle-x": "<circle cx=\"12\" cy=\"12\" r=\"10\" /> <path d=\"m15 9-6 6\" /> <path d=\"m9 9 6 6\" />",
"timer": "<line x1=\"10\" x2=\"14\" y1=\"2\" y2=\"2\" /> <line x1=\"12\" x2=\"15\" y1=\"14\" y2=\"11\" /> <circle cx=\"12\" cy=\"14\" r=\"8\" />",
"list-checks": "<path d=\"M13 5h8\" /> <path d=\"M13 12h8\" /> <path d=\"M13 19h8\" /> <path d=\"m3 17 2 2 4-4\" /> <path d=\"m3 7 2 2 4-4\" />",
"calendar-clock": "<path d=\"M16 14v2.2l1.6 1\" /> <path d=\"M16 2v3\" /> <path d=\"M21 7.338V5a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2h2.338\" /> <path d=\"M3 9h5.859\" /> <path d=\"M8 2v3\" /> <circle cx=\"16\" cy=\"16\" r=\"6\" />",
"bot": "<path d=\"M12 8V4H8\" /> <rect width=\"16\" height=\"12\" x=\"4\" y=\"8\" rx=\"2\" /> <path d=\"M2 14h2\" /> <path d=\"M20 14h2\" /> <path d=\"M15 13v2\" /> <path d=\"M9 13v2\" />",
"rotate-cw": "<path d=\"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8\" /> <path d=\"M21 3v5h-5\" />",
"file-down": "<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=\"M12 18v-6\" /> <path d=\"m9 15 3 3 3-3\" />",
"palette": "<path d=\"M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z\" /> <circle cx=\"13.5\" cy=\"6.5\" r=\".5\" fill=\"currentColor\" /> <circle cx=\"17.5\" cy=\"10.5\" r=\".5\" fill=\"currentColor\" /> <circle cx=\"6.5\" cy=\"12.5\" r=\".5\" fill=\"currentColor\" /> <circle cx=\"8.5\" cy=\"7.5\" r=\".5\" fill=\"currentColor\" />",
"network": "<rect x=\"16\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\" /> <rect x=\"2\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\" /> <rect x=\"9\" y=\"2\" width=\"6\" height=\"6\" rx=\"1\" /> <path d=\"M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3\" /> <path d=\"M12 12V8\" />",
"flask-conical": "<path d=\"M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2\" /> <path d=\"M6.453 15h11.094\" /> <path d=\"M8.5 2h7\" />",
"wand-sparkles": "<path d=\"m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72\" /> <path d=\"m14 7 3 3\" /> <path d=\"M5 6v4\" /> <path d=\"M19 14v4\" /> <path d=\"M10 2v2\" /> <path d=\"M7 8H3\" /> <path d=\"M21 16h-4\" /> <path d=\"M11 3H9\" />",
"database": "<ellipse cx=\"12\" cy=\"5\" rx=\"9\" ry=\"3\" /> <path d=\"M3 5V19A9 3 0 0 0 21 19V5\" /> <path d=\"M3 12A9 3 0 0 0 21 12\" />",
"monitor": "<rect width=\"20\" height=\"14\" x=\"2\" y=\"3\" rx=\"2\" /> <line x1=\"8\" x2=\"16\" y1=\"21\" y2=\"21\" /> <line x1=\"12\" x2=\"12\" y1=\"17\" y2=\"21\" />",
"external-link": "<path d=\"M15 3h6v6\" /> <path d=\"M10 14 21 3\" /> <path d=\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\" />",
"sparkles": "<path d=\"M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z\" /> <path d=\"M20 2v4\" /> <path d=\"M22 4h-4\" /> <circle cx=\"4\" cy=\"20\" r=\"2\" />",
"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\" />",
"wrap-text": "<path d=\"M4 6h16\" /> <path d=\"M4 10h16\" /> <path d=\"M4 14h12a3 3 0 0 1 0 6h-4\" /> <path d=\"m14 18-2 2 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] }}
/>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { jsonLines } from "./jsonLines";
describe("Pretty JSON lines", () => {
const input = { name: "demo", items: [{ id: 1 }, null], enabled: true };
it("keeps valid JSON separators and syntax while expanding", () => {
const lines = jsonLines(input);
const pretty = lines.map((line) => `${" ".repeat(line.depth)}${line.key === undefined ? "" : `${JSON.stringify(line.key)}: `}${line.text}`).join("\n");
expect(JSON.parse(pretty)).toEqual(input);
expect(lines.find((line) => line.key === "name")?.tone).toBe("string");
expect(lines.find((line) => line.key === "enabled")?.tone).toBe("boolean");
});
it("collapses only the selected branch and retains its trailing comma", () => {
const lines = jsonLines(input, new Set(["$/items"]));
expect(lines.find((line) => line.path === "$/items")?.text).toBe("[…],");
expect(lines.some((line) => line.key === "id")).toBe(false);
expect(lines.find((line) => line.key === "enabled")?.text).toBe("true");
});
});
+42
View File
@@ -0,0 +1,42 @@
export type JsonLine = {
path: string;
depth: number;
key?: string;
kind: "open" | "close" | "value";
text: string;
tone?: "string" | "number" | "boolean" | "null";
foldable?: boolean;
expanded?: boolean;
};
/** Pretty JSON lines retain separators and paths, including after collapsing branches. */
export function jsonLines(value: unknown, collapsed: ReadonlySet<string> = new Set()): JsonLine[] {
const lines: JsonLine[] = [];
function visit(current: unknown, path: string, depth: number, key: string | undefined, last: boolean) {
const comma = last ? "" : ",";
if (current !== null && typeof current === "object") {
const array = Array.isArray(current);
const entries: [string, unknown][] = array
? current.map((item, index) => [String(index), item])
: Object.entries(current);
const open = array ? "[" : "{";
const close = array ? "]" : "}";
if (entries.length === 0) {
lines.push({ path, depth, key, kind: "value", text: `${open}${close}${comma}` });
} else if (collapsed.has(path)) {
lines.push({ path, depth, key, kind: "open", text: `${open}…${close}${comma}`, foldable: true, expanded: false });
} else {
lines.push({ path, depth, key, kind: "open", text: open, foldable: true, expanded: true });
entries.forEach(([childKey, childValue], index) => {
visit(childValue, `${path}/${childKey.replace(/~/g, "~0").replace(/\//g, "~1")}`, depth + 1, array ? undefined : childKey, index === entries.length - 1);
});
lines.push({ path, depth, kind: "close", text: `${close}${comma}` });
}
return;
}
const tone = current === null ? "null" : typeof current as "string" | "number" | "boolean";
lines.push({ path, depth, key, kind: "value", tone, text: `${JSON.stringify(current)}${comma}` });
}
visit(value, "$", 0, undefined, true);
return lines;
}
+23
View File
@@ -0,0 +1,23 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { cx } from "./common";
export function Button({
variant = "secondary", busy = false, disabled, className, children, ...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "ghost" | "danger";
busy?: boolean;
}) {
return <button {...props} type={props.type ?? "button"}
className={cx("ops-btn", `ops-btn-${variant}`, className)}
disabled={disabled || busy} aria-busy={busy || undefined}>
{busy && <span className="ops-spinner" aria-hidden="true" />}
{children}
</button>;
}
export function EmptyState({ title, children }: { title: string; children?: ReactNode }) {
return <div className="ops-empty-state" role="status">
<div className="ops-dim font-semibold">{title}</div>
{children && <div className="ops-faint mt-1">{children}</div>}
</div>;
}