桌面 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

+85
View File
@@ -0,0 +1,85 @@
import { MockView } from "./features/mock/MockView";
import { DocsView } from "./features/docs/DocsView";
import { useWorkspaceLifecycle } from "./features/workspace/useWorkspaceLifecycle";
/**
* OpsxAPI 工作台入口:组装顶栏 / 侧栏 / 标签页 / 编辑器 / 对话框。
*/
import { findNode } from "./core/index";
import { useWorkspace } from "./workspaceStore";
import { ActivityRail } from "./components/ActivityRail";
import { Sidebar } from "./components/Sidebar";
import { TestingView } from "./features/testing/TestingView";
import { TabBar } from "./components/TabBar";
import { TopBar } from "./components/TopBar";
import { GraphqlEditor } from "./components/editors/GraphqlEditor";
import { HttpEditor } from "./components/editors/HttpEditor";
import { MqttEditor } from "./components/editors/MqttEditor";
import { SocketIoEditor } from "./components/editors/SocketIoEditor";
import { SseEditor } from "./components/editors/SseEditor";
import { WebsocketEditor } from "./components/editors/WebsocketEditor";
import { WorkspaceDialogs } from "./features/workspace/WorkspaceDialogs";
import { EmptyState } from "./features/workspace/EmptyState";
export function OpsxWorkspace() {
useWorkspaceLifecycle();
const inited = useWorkspace((s) => s.collections.length > 0 || s.environments.length > 0);
const collections = useWorkspace((s) => s.collections);
const activeTabId = useWorkspace((s) => s.activeTabId);
const toast = useWorkspace((s) => s.toast);
const clearToast = useWorkspace((s) => s.clearToast);
const view = useWorkspace((s) => s.view);
const activeRef = activeTabId ? findNode(collections, activeTabId) : null;
const activeNode = activeRef?.node;
return (
<div className="flex h-screen flex-col overflow-hidden">
<TopBar />
<div className="flex min-h-0 min-w-0 flex-1">
<ActivityRail />
{view === "docs" ? <DocsView /> : view === "mock" ? <MockView /> : view === "testing" ? (
<div className="flex min-h-0 min-w-0 flex-1">
<TestingView />
</div>
) : (
<div className="flex min-h-0 min-w-0 flex-1">
<Sidebar />
<main className="flex min-w-0 flex-1 flex-col">
<TabBar />
{inited && activeNode === undefined && (
<EmptyState />
)}
{activeNode && activeNode.type === "request" && (
<>
{activeNode.kind === "http" && <HttpEditor node={activeNode} />}
{activeNode.kind === "graphql" && <GraphqlEditor node={activeNode} />}
{activeNode.kind === "websocket" && <WebsocketEditor node={activeNode} />}
{activeNode.kind === "sse" && <SseEditor node={activeNode} />}
{activeNode.kind === "socketio" && <SocketIoEditor node={activeNode} />}
{activeNode.kind === "mqtt" && <MqttEditor node={activeNode} />}
</>
)}
{activeNode && activeNode.type === "folder" && (
<EmptyState />
)}
</main>
</div>
)}
</div>
<WorkspaceDialogs />
{toast && (
<div
className="ops-toast"
role="status"
data-tone={toast.tone}
onClick={() => clearToast()}
>
{toast.message}
</div>
)}
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
/**
* 账号 / 团队 / 云端空间客户端(同源 /api,经 Vite 代理或桌面直连本地服务)。
*/
export type AccountUser = { id: number; name: string; email: string };
export type AccountTeam = { id: number; name: string; role: string; owner: string };
export type AccountWorkspace = {
id: number;
name: string;
teamId: number | null;
ownerId: number;
updatedAt: number;
/** Mock 服务随机地址片段(不可枚举) */
mockToken?: string;
};
class ApiError extends Error {
constructor(message: string, public status: number) {
super(message);
}
}
async function call<T>(path: string, token: string | null, init?: RequestInit): Promise<T> {
const res = await fetch(`/api${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init?.headers ?? {}),
},
});
const text = await res.text();
const data = text ? (JSON.parse(text) as T & { error?: string }) : ({} as T & { error?: string });
if (!res.ok) throw new ApiError(data.error || `请求失败(${res.status})`, res.status);
return data as T;
}
export function authRegister(name: string, email: string, password: string) {
return call<{ token: string; user: AccountUser }>("/auth/register", null, {
method: "POST",
body: JSON.stringify({ name, email, password }),
});
}
export function authLogin(email: string, password: string) {
return call<{ token: string; user: AccountUser }>("/auth/login", null, {
method: "POST",
body: JSON.stringify({ email, password }),
});
}
export function authLogout(token: string) {
return call<{ ok: boolean }>("/auth/logout", token, { method: "POST" });
}
export function fetchMe(token: string) {
return call<{ user: AccountUser; teams: AccountTeam[]; workspaces: AccountWorkspace[] }>("/me", token);
}
export function createTeam(token: string, name: string) {
return call<AccountTeam>("/teams", token, { method: "POST", body: JSON.stringify({ name }) });
}
export function renameTeam(token: string, teamId: number, name: string) {
return call<{ ok: boolean }>(`/teams/${teamId}`, token, { method: "PATCH", body: JSON.stringify({ name }) });
}
export function deleteTeam(token: string, teamId: number) {
return call<{ ok: boolean }>(`/teams/${teamId}`, token, { method: "DELETE" });
}
export function addTeamMember(token: string, teamId: number, email: string, specialty?: string) {
return call<{ ok: boolean; member: AccountUser }>(`/teams/${teamId}/members`, token, {
method: "POST",
body: JSON.stringify({ email, specialty: specialty ?? "" }),
});
}
/** 团队成员(specialty:擅长标签,空格或顿号分隔) */
export type AccountTeamMember = {
userId: number;
name: string;
email: string;
role: string;
specialty: string;
};
export function fetchTeamMembers(token: string, teamId: number) {
return call<{ members: AccountTeamMember[] }>(`/teams/${teamId}/members`, token);
}
export function updateMemberSpecialty(token: string, teamId: number, userId: number, specialty: string) {
return call<{ ok: boolean }>(`/teams/${teamId}/members/${userId}`, token, {
method: "PUT",
body: JSON.stringify({ specialty }),
});
}
export function fetchWorkspace(token: string, id: number) {
return call<{ id: number; name: string; teamId: number | null; data: string; updatedAt: number }>(
`/workspaces/${id}`,
token,
);
}
export type MockEndpointPayload = {
method: string;
path: string;
status: number;
contentType: string;
body: string;
delayMs: number;
};
export function publishMock(
token: string,
workspaceId: number,
endpoints: MockEndpointPayload[],
ttlSeconds = 0,
) {
return call<{ ok: boolean; count: number; base: string; expiresAt: number }>(
`/workspaces/${workspaceId}/mock`,
token,
{
method: "PUT",
body: JSON.stringify({ endpoints, ttlSeconds }),
},
);
}
/** 本机联调发布:不绑账号,仅本机 Gin 接受。 */
export function publishLocalMock(endpoints: MockEndpointPayload[], ttlSeconds = 0) {
return call<{ ok: boolean; count: number; base: string; token: string; expiresAt: number }>("/mock/local", null, {
method: "PUT",
body: JSON.stringify({ endpoints, ttlSeconds }),
});
}
export function revokeMock(token: string, workspaceId: number) {
return call<{ ok: boolean }>(`/workspaces/${workspaceId}/mock`, token, { method: "DELETE" });
}
export function revokeLocalMock() {
return call<{ ok: boolean }>("/mock/local", null, { method: "DELETE" });
}
export function saveWorkspace(token: string, id: number, data: string) {
return call<{ updatedAt: number }>(`/workspaces/${id}`, token, {
method: "PUT",
body: JSON.stringify({ data }),
});
}
/* ---------------- 本地令牌存储 ---------------- */
const AUTH_KEY = "opsxapi.auth.v1";
export function loadToken(): string | null {
try {
const raw = localStorage.getItem(AUTH_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { token?: string };
return parsed.token ?? null;
} catch {
return null;
}
}
export function saveToken(token: string | null): void {
try {
if (token) localStorage.setItem(AUTH_KEY, JSON.stringify({ token }));
else localStorage.removeItem(AUTH_KEY);
} catch {
/* ignore */
}
}
+228
View File
@@ -0,0 +1,228 @@
/**
* AI 助手:OpenAI 兼容 Chat Completions 客户端 + 用例生成。
* 供应商配置保存在工作区设置;团队工作区可能同步到工作区服务。
*/
import type {
AiProvider,
Collection,
HttpRequestData,
RequestNode,
TestAssertion,
TestCase,
TestStep,
} from "./core/index";
import { newTestCase, suggestAssertions } from "./core/index";
/** 常见供应商预设(均为 OpenAI 兼容协议) */
export const AI_PROVIDER_PRESETS: { name: string; baseUrl: string; model: string; hint: string }[] = [
{ name: "DeepSeek", baseUrl: "https://api.deepseek.com/v1", model: "deepseek-chat", hint: "性价比高,推荐" },
{ name: "智谱 GLM", baseUrl: "https://open.bigmodel.cn/api/paas/v4", model: "glm-4-flash", hint: "新用户有免费额度" },
{ name: "Moonshot Kimi", baseUrl: "https://api.moonshot.cn/v1", model: "moonshot-v1-8k", hint: "" },
{ name: "OpenAI", baseUrl: "https://api.openai.com/v1", model: "gpt-4o-mini", hint: "" },
{ name: "Ollama 本地", baseUrl: "http://127.0.0.1:11434/v1", model: "qwen2.5:7b", hint: "无需 API Key" },
];
export function newAiProvider(preset?: (typeof AI_PROVIDER_PRESETS)[number]): AiProvider {
return {
id: `ai-${Math.random().toString(36).slice(2, 10)}`,
name: preset?.name ?? "自定义供应商",
baseUrl: preset?.baseUrl ?? "",
apiKey: "",
model: preset?.model ?? "",
enabled: true,
};
}
type ChatMessage = { role: "system" | "user" | "assistant"; content: string };
/** 调用 Chat Completions(非流式),失败抛 Error */
export async function chatComplete(provider: AiProvider, messages: ChatMessage[], timeoutMs = 60000): Promise<string> {
if (!provider.baseUrl.trim()) throw new Error("未配置 Base URL");
const url = `${provider.baseUrl.trim().replace(/\/+$/, "")}/chat/completions`;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(provider.apiKey.trim() ? { Authorization: `Bearer ${provider.apiKey.trim()}` } : {}),
},
body: JSON.stringify({
model: provider.model.trim() || "gpt-4o-mini",
messages,
temperature: 0.2,
stream: false,
}),
signal: ctrl.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status}${text ? `:${text.slice(0, 200)}` : ""}`);
}
const data = (await res.json()) as {
choices?: { message?: { content?: string } }[];
};
const content = data.choices?.[0]?.message?.content;
if (!content) throw new Error("模型未返回内容");
return content;
} catch (e) {
if (e instanceof DOMException && e.name === "AbortError") throw new Error("请求超时");
throw e instanceof Error ? e : new Error(String(e));
}
}
/** 连接测试:问一句并要求简答 */
export async function testProviderConnection(provider: AiProvider): Promise<string> {
const reply = await chatComplete(
provider,
[
{ role: "system", content: "你是连通性测试助手,只输出:OK" },
{ role: "user", content: "ping" },
],
20000,
);
return reply.trim().slice(0, 60) || "OK";
}
/* ---------------- AI 生成场景用例 ---------------- */
/** 汇总集合中的 HTTP 接口给模型看 */
function summarizeNodes(nodes: { node: RequestNode; path: string }[]): string {
return nodes
.map(({ node, path }) => {
if (node.kind !== "http") return null;
const d = node.data as HttpRequestData;
const lines = [
`- id: ${node.id} | ${d.method} ${d.url} | 名称: ${node.name}${path ? ` | 目录: ${path}` : ""}`,
];
if (d.bodyMode === "json" && d.bodyRaw.trim()) {
lines.push(` body 示例: ${d.bodyRaw.replace(/\s+/g, " ").slice(0, 160)}`);
}
return lines.join("\n");
})
.filter(Boolean)
.join("\n");
}
const GEN_SYSTEM_PROMPT = `你是 API 自动化测试专家。根据给定的接口清单设计一个可执行的"场景用例"(按业务顺序编排多个接口步骤)。
只输出一个 JSON 代码块(\`\`\`json ... \`\`\`),不要输出其他解释文字。JSON 结构:
{
"name": "用例名称(中文,简短)",
"description": "一句话说明场景",
"steps": [
{
"nodeId": "必须使用清单中的接口 id",
"reason": "为什么这一步",
"assertions": [
{ "target": "status|timeMs|header|body", "locator": "header名或JSONPath(其余为空)", "op": "eq|neq|lt|gt|contains|not_contains|exists|not_exists|jsonpath_eq|jsonpath_contains", "expected": "期望值" }
],
"extracts": [ { "jsonpath": "$.data.id", "varName": "petId" } ]
}
]
}
要求:步骤顺序符合业务依赖(如 先创建 → 再查询 → 再删除);每步至少 1 条断言;跨步骤传值用 extracts 提取变量并在后续 URL/Body 用 {{变量名}}(如果原接口里已有 {{xxx}} 占位则沿用);断言 4-8 条为宜,聚焦关键业务字段。`;
export type AiCaseDraft = { name: string; description?: string; steps: TestStep[]; notes: string[] };
/** 从模型回复中提取 JSON(容错:代码块 / 首尾杂文) */
export function extractJsonBlock(text: string): unknown {
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
const raw = fenced ? fenced[1] : text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1);
return JSON.parse(raw.trim());
}
export async function aiGenerateTestCase(
provider: AiProvider,
collection: Collection,
instruction?: string,
): Promise<AiCaseDraft> {
const nodes: { node: RequestNode; path: string }[] = [];
const walk = (children: Collection["children"], path: string): void => {
for (const n of children) {
if (n.type === "folder") walk(n.children, path ? `${path}/${n.name}` : n.name);
else if (n.kind === "http") nodes.push({ node: n, path });
}
};
walk(collection.children, "");
if (nodes.length === 0) throw new Error("集合中没有 HTTP 接口可供编排");
const user = [
`接口清单(共 ${nodes.length} 个):`,
summarizeNodes(nodes),
instruction?.trim() ? `\n用户要求:${instruction.trim()}` : "",
"\n请输出场景用例 JSON。",
]
.filter(Boolean)
.join("\n");
const reply = await chatComplete(provider, [
{ role: "system", content: GEN_SYSTEM_PROMPT },
{ role: "user", content: user },
]);
const parsed = extractJsonBlock(reply) as {
name?: string;
description?: string;
steps?: {
nodeId?: string;
reason?: string;
assertions?: { target?: string; locator?: string; op?: string; expected?: string }[];
extracts?: { jsonpath?: string; varName?: string }[];
}[];
};
const notes: string[] = [];
const steps: TestStep[] = [];
const validIds = new Set(nodes.map((n) => n.node.id));
const nodeById = new Map(nodes.map((n) => [n.node.id, n.node]));
for (const s of parsed.steps ?? []) {
if (!s.nodeId) continue;
if (!validIds.has(s.nodeId)) {
notes.push(`已跳过未知接口:${s.nodeId}`);
continue;
}
const node = nodeById.get(s.nodeId)!;
const assertions: TestAssertion[] = (s.assertions ?? []).map((a) => ({
id: `as-${Math.random().toString(36).slice(2, 10)}`,
enabled: true,
target: (["status", "timeMs", "header", "body"].includes(a.target ?? "") ? a.target : "status") as TestAssertion["target"],
locator: a.locator ?? "",
op: ([
"eq", "neq", "lt", "gt", "contains", "not_contains", "exists", "not_exists", "jsonpath_eq", "jsonpath_contains",
].includes(a.op ?? "") ? a.op : "eq") as TestAssertion["op"],
expected: a.expected ?? "",
}));
if (assertions.length === 0) {
assertions.push(...suggestAssertions(node.data as HttpRequestData));
if (s.reason) notes.push(`「${node.name}」未给断言,已补默认断言`);
}
steps.push({
id: `st-${Math.random().toString(36).slice(2, 10)}`,
enabled: true,
nodeId: s.nodeId,
assertions,
extracts: (s.extracts ?? [])
.filter((e) => e.jsonpath && e.varName)
.map((e) => ({ id: `ex-${Math.random().toString(36).slice(2, 10)}`, jsonpath: e.jsonpath!, varName: e.varName! })),
});
}
if (steps.length === 0) throw new Error("模型未生成有效步骤,请重试或补充要求");
return {
name: parsed.name?.trim() || `${collection.name} · AI 用例`,
description: parsed.description?.trim(),
steps,
notes,
};
}
/** 把 AI 草稿转成正式用例 */
export function draftToTestCase(draft: AiCaseDraft): TestCase {
const tc = newTestCase(draft.name);
tc.description = draft.description;
tc.steps = draft.steps;
return tc;
}
+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>;
}
+84
View File
@@ -0,0 +1,84 @@
/**
* 极简 5 段 Cron 匹配(分 时 日 月 周),本地 Date。
* 支持:*、数字、逗号列表、A-B 范围、* /n 步长。不支持 L W # 与英文月名。
* 自研实现,非抄竞品调度器。
*/
function parseField(field: string, min: number, max: number): Set<number> | null {
const out = new Set<number>();
for (const part of field.split(",")) {
const stepMatch = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(part.trim());
if (!stepMatch) return null;
const [, base, stepStr] = stepMatch;
const step = stepStr ? Number(stepStr) : 1;
if (!Number.isSafeInteger(step) || step < 1) return null;
let start = min;
let end = max;
if (base !== "*") {
if (base.includes("-")) {
const [a, b] = base.split("-").map(Number);
start = a;
end = b;
} else {
start = end = Number(base);
}
}
if (start < min || end > max || start > end) return null;
for (let i = start; i <= end; i += step) out.add(i);
}
return out.size ? out : null;
}
/** 校验表达式粗格式 */
export function isValidCron(expr: string): boolean {
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) return false;
return parts.every((p, index) => parseField(p, [0, 0, 1, 1, 0][index], [59, 23, 31, 12, 7][index]) !== null);
}
/** date 是否命中 cron(精确到分钟) */
export function cronMatches(expr: string, date: Date = new Date()): boolean {
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) return false;
const [minF, hourF, domF, monF, dowF] = parts;
const minute = date.getMinutes();
const hour = date.getHours();
const dom = date.getDate();
const mon = date.getMonth() + 1;
// JS: 0=Sun..6=Sat;Cron 常 0=Sun;也接受 7=Sun
let dow = date.getDay();
const mins = parseField(minF, 0, 59);
const hours = parseField(hourF, 0, 23);
const doms = parseField(domF, 1, 31);
const mons = parseField(monF, 1, 12);
const dows = parseField(dowF, 0, 7);
if (!mins || !hours || !doms || !mons || !dows) return false;
if (dows.has(7)) dows.add(0);
return mins.has(minute) && hours.has(hour) && doms.has(dom) && mons.has(mon) && dows.has(dow);
}
/**
* 距 lastRunAt 是否应在「当前分钟」再跑:
* - 有 cron:表达式命中当前分钟,且上次不在同一分钟
* - 无 cron:沿用间隔分钟
*/
export function scheduleIsDue(
schedule: { intervalMinutes: number; cron?: string | null; lastRunAt: number | null },
now: Date = new Date(),
): boolean {
const cron = schedule.cron?.trim();
if (cron) {
if (!isValidCron(cron) || !cronMatches(cron, now)) return false;
if (schedule.lastRunAt == null) return true;
const last = new Date(schedule.lastRunAt);
return !(
last.getFullYear() === now.getFullYear() &&
last.getMonth() === now.getMonth() &&
last.getDate() === now.getDate() &&
last.getHours() === now.getHours() &&
last.getMinutes() === now.getMinutes()
);
}
const intervalMs = Math.max(1, schedule.intervalMinutes) * 60_000;
return schedule.lastRunAt === null || now.getTime() - schedule.lastRunAt >= intervalMs;
}
+184
View File
@@ -0,0 +1,184 @@
/**
* 代码生成:JavaScript fetch / Python requests / Go net/http。
* 变量保留 {{var}} 原样(用户可自行替换)。
*/
import type { GraphqlRequestData, HttpRequestData } from "../model";
import { authHeader, buildUrlWithQuery, exportCurl } from "./curl";
export type CodeTarget = "curl" | "fetch" | "python" | "go";
export function generateCode(
target: CodeTarget,
data: HttpRequestData | GraphqlRequestData,
): string {
if (data.kind === "graphql") return generateGraphql(target, data);
switch (target) {
case "curl":
return generateCurlHttp(data);
case "fetch":
return generateFetch(data);
case "python":
return generatePython(data);
case "go":
return generateGo(data);
}
}
/* ---------------- HTTP ---------------- */
function commonHeaders(data: HttpRequestData): [string, string][] {
const out: [string, string][] = [];
const auth = authHeader(data);
if (auth) out.push([auth.key, auth.value]);
if (data.auth.mode === "apikey" && data.auth.apiKeyName && data.auth.apiKeyValue && data.auth.apiKeyIn !== "query") {
out.push([data.auth.apiKeyName, data.auth.apiKeyValue]);
}
for (const h of data.headers) {
if (!h.enabled || !h.key) continue;
if (["authorization", "cookie"].includes(h.key.toLowerCase())) continue;
out.push([h.key, h.value]);
}
const cookie = data.cookies.filter((c) => c.enabled && c.key).map((c) => `${c.key}=${c.value}`).join("; ");
if (cookie) out.push(["Cookie", cookie]);
if (data.bodyMode === "json" && !out.some(([k]) => k.toLowerCase() === "content-type")) {
out.push(["Content-Type", "application/json"]);
}
if (data.bodyMode === "urlencoded" && !out.some(([k]) => k.toLowerCase() === "content-type")) {
out.push(["Content-Type", "application/x-www-form-urlencoded"]);
}
return out;
}
function bodyPayload(data: HttpRequestData): { mode: "raw"; raw: string } | { mode: "urlencoded"; pairs: [string, string][] } | { mode: "form"; pairs: [string, string][] } | null {
if (data.bodyMode === "urlencoded") {
return { mode: "urlencoded", pairs: data.bodyForm.filter((f) => f.enabled && f.key).map((f) => [f.key, f.value]) };
}
if (data.bodyMode === "form-data") {
return { mode: "form", pairs: data.bodyForm.filter((f) => f.enabled && f.key).map((f) => [f.key, f.value]) };
}
if (data.bodyMode !== "none" && data.bodyRaw) return { mode: "raw", raw: data.bodyRaw };
return null;
}
function generateCurlHttp(data: HttpRequestData): string {
return exportCurl(data);
}
function generateFetch(data: HttpRequestData): string {
const headers = commonHeaders(data);
const body = bodyPayload(data);
let bodyExpr = "";
if (body?.mode === "raw") bodyExpr = ` body: ${JSON.stringify(body.raw)},\n`;
else if (body?.mode === "urlencoded") bodyExpr = ` body: new URLSearchParams(${JSON.stringify(Object.fromEntries(body.pairs))}).toString(),\n`;
else if (body?.mode === "form") bodyExpr = ` body: new FormData(${JSON.stringify(Object.fromEntries(body.pairs))} as any),\n`;
return `const response = await fetch(${JSON.stringify(buildUrlWithQuery(data))}, {
method: ${JSON.stringify(data.method)},
headers: ${JSON.stringify(Object.fromEntries(headers), null, 2).replace(/\n/g, "\n ")},
${bodyExpr}});
if (!response.ok) throw new Error(\`HTTP \${response.status}\`);
const data = await response.json();
console.log(data);
`;
}
function generatePython(data: HttpRequestData): string {
const headers = commonHeaders(data);
const body = bodyPayload(data);
let bodyLine = "";
if (body?.mode === "raw") {
const asJson = data.bodyMode === "json";
bodyLine = asJson ? `data = json.dumps(${body.raw})` : `data = ${JSON.stringify(body.raw)}`;
} else if (body?.mode === "urlencoded") {
bodyLine = `data = ${JSON.stringify(Object.fromEntries(body.pairs))}`;
} else if (body?.mode === "form") {
bodyLine = `data = ${JSON.stringify(Object.fromEntries(body.pairs))}`;
}
const needsJson = data.bodyMode === "json";
return `import requests${needsJson ? "\nimport json" : ""}
url = ${JSON.stringify(buildUrlWithQuery(data))}
headers = ${JSON.stringify(Object.fromEntries(headers), null, 4).replace(/\n/g, "\n")}
${bodyLine ? bodyLine + "\n" : ""}response = requests.${pyMethod(data.method)}(url, headers=headers${bodyLine ? ", data=data" : ""})
print(response.status_code)
print(response.text)
`;
}
function pyMethod(method: string): string {
return method.toLowerCase();
}
function generateGo(data: HttpRequestData): string {
const headers = commonHeaders(data);
const body = bodyPayload(data);
const bodyLiteral = body?.mode === "raw" ? body.raw : body?.mode === "urlencoded" || body?.mode === "form" ? new URLSearchParams(body.pairs).toString() : "";
const payload = bodyLiteral ? JSON.stringify(bodyLiteral) : `""`;
const headerLines = headers.map(([k, v]) => `\treq.Header.Set(${JSON.stringify(k)}, ${JSON.stringify(v)})`).join("\n");
return `package main
import (
"fmt"
"io"
"net/http"
"strings"
)
func main() {
payload := strings.NewReader(${payload})
req, _ := http.NewRequest(${JSON.stringify(data.method)}, ${JSON.stringify(buildUrlWithQuery(data))}, payload)
${headerLines}
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res.Status)
fmt.Println(string(body))
}
`;
}
/* ---------------- GraphQL ---------------- */
function generateGraphql(target: CodeTarget, data: GraphqlRequestData): string {
const gqlBody = JSON.stringify({ query: data.query, variables: safeParse(data.variables) }, null, 2);
const httpLike: HttpRequestData = {
kind: "http",
method: "POST",
url: data.url,
query: [],
headers: [...data.headers.filter((h) => h.enabled && h.key).map((h) => ({ ...h })), { id: "ct", key: "Content-Type", value: "application/json", enabled: true }],
cookies: [],
auth: data.auth,
bodyMode: "json",
bodyRaw: gqlBody,
bodyForm: [],
};
switch (target) {
case "curl":
return generateCurlHttp(httpLike);
case "fetch": {
const code = generateFetch(httpLike);
return `// GraphQL → POST { query, variables }\n${code}`;
}
case "python": {
const code = generatePython(httpLike);
return `# GraphQL → POST { query, variables }\n${code}`;
}
case "go":
return generateGo(httpLike);
}
}
function safeParse(s: string): unknown {
try {
return JSON.parse(s);
} catch {
return {};
}
}
+76
View File
@@ -0,0 +1,76 @@
/**
* cURL 命令导出(保留 {{var}} 变量原样)。
*/
import type { HttpRequestData } from "../model";
export function shellQuote(s: string): string {
if (/^[A-Za-z0-9_\-:/.@%^=+,?&]+$/.test(s) && !s.includes(" ")) return s;
return `'${s.replaceAll("'", "'\\''")}'`;
}
export function buildUrlWithQuery(data: HttpRequestData): string {
const enabled = data.query.filter((q) => q.enabled && q.key);
if (!enabled.length) return data.url;
const qs = enabled.map((q) => `${encodeURIComponent(q.key)}=${encodeURIComponent(q.value)}`).join("&");
return data.url.includes("?") ? `${data.url}&${qs}` : `${data.url}?${qs}`;
}
export function authHeader(data: HttpRequestData): { key: string; value: string } | null {
if (data.auth.mode === "bearer" && data.auth.bearer) {
return { key: "Authorization", value: `Bearer ${data.auth.bearer}` };
}
if (data.auth.mode === "basic") {
const raw = `${data.auth.basicUser ?? ""}:${data.auth.basicPass ?? ""}`;
try {
return { key: "Authorization", value: `Basic ${btoa(raw)}` };
} catch {
return null;
}
}
return null;
}
export function exportCurl(data: HttpRequestData, name?: string): string {
const lines: string[] = [];
const url = buildUrlWithQuery(data);
const parts = [`curl -X ${data.method} ${shellQuote(url)}`];
if (data.auth.mode === "apikey" && data.auth.apiKeyName && data.auth.apiKeyValue) {
if (data.auth.apiKeyIn === "query") {
parts[0] = parts[0].replace(shellQuote(url), shellQuote(`${url}${url.includes("?") ? "&" : "?"}${encodeURIComponent(data.auth.apiKeyName)}=${encodeURIComponent(data.auth.apiKeyValue)}`));
} else {
parts.push(` -H ${shellQuote(`${data.auth.apiKeyName}: ${data.auth.apiKeyValue}`)}`);
}
}
const auth = authHeader(data);
if (auth) parts.push(` -H ${shellQuote(`${auth.key}: ${auth.value}`)}`);
for (const h of data.headers) {
if (!h.enabled || !h.key) continue;
if (["authorization", "cookie"].includes(h.key.toLowerCase())) continue;
parts.push(` -H ${shellQuote(`${h.key}: ${h.value}`)}`);
}
const cookie = data.cookies.filter((c) => c.enabled && c.key).map((c) => `${c.key}=${c.value}`).join("; ");
if (cookie) parts.push(` -b ${shellQuote(cookie)}`);
const hasJsonHeader = data.headers.some((h) => h.enabled && h.key.toLowerCase() === "content-type" && h.value.includes("json"));
if (data.bodyMode === "urlencoded") {
if (!hasJsonHeader) parts.push(` -H ${shellQuote("Content-Type: application/x-www-form-urlencoded")}`);
const body = data.bodyForm.filter((b) => b.enabled && b.key).map((b) => `${encodeURIComponent(b.key)}=${encodeURIComponent(b.value)}`).join("&");
if (body) parts.push(` --data-raw ${shellQuote(body)}`);
} else if (data.bodyMode === "form-data") {
if (!hasJsonHeader) parts.push(` -H ${shellQuote("Content-Type: multipart/form-data")}`);
for (const f of data.bodyForm) {
if (f.enabled && f.key) parts.push(` -F ${shellQuote(`${f.key}=${f.value}`)}`);
}
} else if (data.bodyMode !== "none" && data.bodyRaw) {
if (data.bodyMode === "json" && !hasJsonHeader) parts.push(` -H ${shellQuote("Content-Type: application/json")}`);
if (data.bodyMode === "xml" && !hasJsonHeader) parts.push(` -H ${shellQuote("Content-Type: application/xml")}`);
parts.push(` --data-raw ${shellQuote(data.bodyRaw)}`);
}
lines.push(parts.join(" \\\n"));
return lines.join("\n");
}
+61
View File
@@ -0,0 +1,61 @@
/**
* 导出入口 + 备份序列化。
*/
import { dump as yamlDump } from "js-yaml";
import type { Collection, Environment, WorkspaceData } from "../model";
import { exportOpenApiWithFolders, type OpenApiExportOptions } from "./openapi";
import { exportPostman } from "./postman";
import { exportMarkdown } from "./markdown";
export type ExportFormat = "openapi-json" | "openapi-yaml" | "postman" | "markdown" | "single-curl" | "backup";
export function exportCollectionAs(
format: ExportFormat,
collection: Collection,
options: OpenApiExportOptions = {},
): string {
switch (format) {
case "openapi-json":
return JSON.stringify(exportOpenApiWithFolders(collection, options), null, 2);
case "openapi-yaml":
return yamlDump(exportOpenApiWithFolders(collection, options), { lineWidth: 120 });
case "postman":
return JSON.stringify(exportPostman(collection), null, 2);
case "markdown":
return exportMarkdown(collection);
default:
throw new Error(`格式 ${format} 不适用于集合导出`);
}
}
export function downloadText(filename: string, text: string, mime = "application/octet-stream"): void {
const blob = new Blob([text], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
export function exportBackup(
collections: Collection[],
environments: Environment[],
): string {
return JSON.stringify(
{
__opsxapi__: "backup",
version: 2,
exportedAt: new Date().toISOString(),
collections,
environments,
},
null,
2,
);
}
export { exportOpenApiWithFolders, exportPostman, exportMarkdown };
export type { OpenApiExportOptions } from "./openapi";
+95
View File
@@ -0,0 +1,95 @@
/**
* 集合 → Markdown 接口文档。
*/
import type { Collection, CollectionNode, HttpRequestData } from "../model";
import { buildUrlWithQuery } from "./curl";
export function exportMarkdown(collection: Collection): string {
const lines: string[] = [];
lines.push(`# ${collection.name}`, "");
if (collection.description) lines.push(collection.description, "");
lines.push("> 由 OpsxAPI 导出 · " + new Date().toLocaleString("zh-CN"), "");
const toc: string[] = [];
const visitToc = (nodes: CollectionNode[], prefix: string) => {
for (const node of nodes) {
if (node.type === "folder") {
toc.push(`${prefix}- 📁 ${node.name}`);
visitToc(node.children, `${prefix} `);
} else {
toc.push(`${prefix}- [${node.name}](#${anchor(node.name)})`);
}
}
};
visitToc(collection.children, "");
if (toc.length) lines.push("## 目录", "", ...toc, "");
const visit = (nodes: CollectionNode[], depth: number) => {
for (const node of nodes) {
const hashes = "#".repeat(Math.min(depth + 1, 6));
if (node.type === "folder") {
lines.push(`${hashes} ${node.name}`, "");
if (node.description) lines.push(node.description, "");
visit(node.children, depth + 1);
continue;
}
lines.push(`${hashes} ${node.name}`, "");
if (node.description) lines.push(node.description, "");
if (node.kind === "http") {
const d: HttpRequestData = node.data;
lines.push(
"",
`- **Method**:\`${d.method}\``,
`- **URL**:\`${buildUrlWithQuery(d)}\``,
);
if (d.auth.mode !== "none") {
lines.push(`- **鉴权**:\`${d.auth.mode}\``);
}
if (d.query.filter((q) => q.enabled && q.key).length) {
lines.push("", "**Query 参数**", "", "| 参数 | 示例值 | 说明 |", "| --- | --- | --- |");
for (const q of d.query) {
if (!q.enabled || !q.key) continue;
lines.push(`| \`${q.key}\` | \`${escapeTable(q.value)}\` | ${escapeTable(q.description ?? "")} |`);
}
}
if (d.headers.filter((h) => h.enabled && h.key).length) {
lines.push("", "**Headers**", "", "| Header | 示例值 | 说明 |", "| --- | --- | --- |");
for (const h of d.headers) {
if (!h.enabled || !h.key) continue;
lines.push(`| \`${h.key}\` | \`${escapeTable(h.value)}\` | ${escapeTable(h.description ?? "")} |`);
}
}
if (d.bodyMode !== "none") {
const lang = d.bodyMode === "json" ? "json" : d.bodyMode === "xml" ? "xml" : "";
if (d.bodyMode === "urlencoded" || d.bodyMode === "form-data") {
lines.push("", `**请求体(${d.bodyMode})**`, "", "| 字段 | 示例值 |", "| --- | --- |");
for (const f of d.bodyForm) {
if (!f.enabled || !f.key) continue;
lines.push(`| \`${f.key}\` | \`${escapeTable(f.value)}\` |`);
}
} else if (d.bodyRaw) {
lines.push("", `**请求体(${d.bodyMode})**`, "", "```" + lang, d.bodyRaw, "```");
}
}
lines.push("");
} else {
const d = node.data as { url?: string; brokerUrl?: string };
const url = "url" in d ? d.url : d.brokerUrl ?? "";
lines.push("", `- **协议**:\`${node.kind}\``, `- **地址**:\`${url}\``);
lines.push("");
}
}
};
visit(collection.children, 1);
return lines.join("\n");
}
function escapeTable(s: string): string {
return s.replaceAll("|", "\\|").replaceAll("\n", " ");
}
function anchor(name: string): string {
return name.toLowerCase().replace(/[^\p{L}\p{N}\s-]/gu, "").trim().replace(/\s+/g, "-");
}
+265
View File
@@ -0,0 +1,265 @@
/**
* 集合 → OpenAPI 3.0.3 导出。
*/
import type { Collection, CollectionNode, FolderNode, HttpApiNode, HttpRequestData, RequestNode } from "../model";
import { walk } from "../tree";
type Spec = Record<string, unknown>;
type Op = Record<string, unknown>;
export type OpenApiExportOptions = {
title?: string;
version?: string;
yaml?: boolean;
};
export function exportOpenApi(
collection: Collection,
options: OpenApiExportOptions = {},
): Spec {
const requests: HttpApiNode[] = [];
const skipped: RequestNode[] = [];
walk(collection.children, (node) => {
if (node.type !== "request") return;
if (node.kind === "http") requests.push(node);
else skipped.push(node);
});
// server 推断:{{baseUrl}} 前缀或首个绝对 URL 的 origin
const serverUrl = inferServerUrl(requests.map((r) => r.data.url));
const paths: Spec = {};
const usedPathParams = new Map<string, Set<string>>();
const securities = new Set<string>();
for (const req of requests) {
const data = req.data;
const path = toOpenApiPath(stripServer(data.url, serverUrl));
const paramsInPath = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
const method = data.method.toLowerCase();
if (method === "head" || method === "options") continue; // OpenAPI 常规导出不含这两个
const op: Op = {
summary: req.name,
operationId: slugify(req.name),
};
if (req.description) op.description = req.description;
const parameters: Spec[] = [];
for (const p of paramsInPath) {
if (!usedPathParams.has(path)) usedPathParams.set(path, new Set());
usedPathParams.get(path)!.add(p);
parameters.push({ name: p, in: "path", required: true, schema: { type: "string" } });
}
for (const q of data.query) {
if (!q.enabled || !q.key) continue;
parameters.push({
name: q.key,
in: "query",
required: false,
schema: { type: "string", example: q.value },
...(q.description ? { description: q.description } : {}),
});
}
for (const h of data.headers) {
if (!h.enabled || !h.key) continue;
if (["authorization", "content-type", "cookie"].includes(h.key.toLowerCase())) continue;
parameters.push({
name: h.key,
in: "header",
required: false,
schema: { type: "string", example: h.value },
});
}
if (parameters.length) op.parameters = parameters;
// body
if (data.bodyMode !== "none" && method !== "get" && method !== "head") {
op.requestBody = buildRequestBody(data);
}
// security
if (data.auth.mode === "bearer" && data.auth.bearer) {
securities.add("bearerAuth");
op.security = [{ bearerAuth: [] }];
} else if (data.auth.mode === "basic") {
securities.add("basicAuth");
op.security = [{ basicAuth: [] }];
} else if (data.auth.mode === "apikey" && data.auth.apiKeyName) {
securities.add("apiKeyAuth");
op.security = [{ apiKeyAuth: [] }];
}
if (!paths[path]) paths[path] = {};
(paths[path] as Spec)[method] = op;
}
const components: Spec = {};
const schemes: Spec = {};
if (securities.has("bearerAuth")) schemes.bearerAuth = { type: "http", scheme: "bearer", bearerFormat: "JWT" };
if (securities.has("basicAuth")) schemes.basicAuth = { type: "http", scheme: "basic" };
if (securities.has("apiKeyAuth"))
schemes.apiKeyAuth = {
type: "apiKey",
name: findApiKeyHeader(requests) ?? "X-API-Key",
in: "header",
};
if (Object.keys(schemes).length) components.securitySchemes = schemes;
const spec: Spec = {
openapi: "3.0.3",
info: {
title: options.title ?? collection.name,
version: options.version ?? "1.0.0",
...(collection.description ? { description: collection.description } : {}),
},
servers: [{ url: serverUrl }],
paths,
...(Object.keys(components).length ? { components } : {}),
};
if (skipped.length) {
(spec.info as Spec).description = `${str((spec.info as Spec).description)}\n\n> 注:${skipped.length} 个非 HTTP 节点(GraphQL/WS/SSE/Socket.IO/MQTT)不参与 OpenAPI 导出。`.trim();
}
return spec;
}
function str(v: unknown): string {
return typeof v === "string" ? v : "";
}
function inferServerUrl(urls: string[]): string {
for (const u of urls) {
if (u.includes("{{baseUrl}}")) return "http://localhost";
}
for (const u of urls) {
try {
return new URL(u).origin;
} catch {
/* next */
}
}
return "http://localhost";
}
function stripServer(url: string, serverUrl: string): string {
let u = url;
if (u.includes("{{baseUrl}}")) {
u = u.split("{{baseUrl}}")[1] || "/";
} else if (serverUrl && u.startsWith(serverUrl)) {
u = u.slice(serverUrl.length) || "/";
}
if (!u.startsWith("/")) u = `/${u}`;
return u.split("?")[0];
}
function toOpenApiPath(path: string): string {
// {{var}} → {var}
return path.replace(/\{\{([^}]+)\}\}/g, "{$1}");
}
function buildRequestBody(data: HttpRequestData): Spec {
if (data.bodyMode === "urlencoded") {
const props: Spec = {};
for (const f of data.bodyForm) {
if (f.key) props[f.key] = { type: "string", example: f.value };
}
return {
required: true,
content: {
"application/x-www-form-urlencoded": {
schema: { type: "object", properties: props },
},
},
};
}
if (data.bodyMode === "form-data") {
const props: Spec = {};
for (const f of data.bodyForm) {
if (f.key) props[f.key] = { type: "string", example: f.value };
}
return {
required: true,
content: {
"multipart/form-data": {
schema: { type: "object", properties: props },
},
},
};
}
const mime =
data.bodyMode === "json"
? "application/json"
: data.bodyMode === "xml"
? "application/xml"
: data.bodyMode === "html"
? "text/html"
: "text/plain";
let example: unknown = data.bodyRaw;
if (data.bodyMode === "json") {
try {
example = JSON.parse(data.bodyRaw);
} catch {
/* keep raw */
}
}
return {
required: true,
content: { [mime]: { example } },
};
}
function findApiKeyHeader(requests: HttpApiNode[]): string | null {
for (const r of requests) {
if (r.data.auth.mode === "apikey" && r.data.auth.apiKeyName) return r.data.auth.apiKeyName;
}
return null;
}
function slugify(name: string): string {
const cleaned = name
.replace(/[^\p{L}\p{N}\s-]/gu, "")
.trim()
.replace(/\s+/g, "_");
return cleaned || "operation";
}
/** 导出包含 tag 分组(目录名 = tag)的 spec */
export function exportOpenApiWithFolders(
collection: Collection,
options: OpenApiExportOptions = {},
): Spec {
const spec = exportOpenApi(collection, options);
// 目录名 → tag:重新走一遍树,为每个请求打 tag
const paths = spec.paths as Spec;
const tags: { name: string }[] = [];
const tagByPath = new Map<string, string>();
const visit = (nodes: CollectionNode[], tag: string | null) => {
for (const node of nodes) {
if (node.type === "folder") {
visit(node.children, node.name);
} else if (node.kind === "http" && tag) {
const path = toOpenApiPath(stripServer(node.data.url, "http://localhost"));
tagByPath.set(`${path}:${node.data.method.toLowerCase()}`, tag);
}
}
};
visit(collection.children, null);
for (const t of tagByPath.values()) {
if (!tags.some((x) => x.name === t)) tags.push({ name: t });
}
if (tags.length) {
spec.tags = tags;
for (const [path, methods] of Object.entries(paths)) {
for (const [method, opRaw] of Object.entries(methods as Spec)) {
const tag = tagByPath.get(`${path}:${method}`);
if (tag) (opRaw as Op).tags = [tag];
}
}
}
return spec;
}
export type { FolderNode };
+146
View File
@@ -0,0 +1,146 @@
/**
* 集合 → Postman Collection v2.1 导出。
*/
import type { Collection, CollectionNode, GraphqlRequestData, HttpRequestData, RequestNode } from "../model";
type Obj = Record<string, unknown>;
export function exportPostman(collection: Collection): Obj {
return {
info: {
name: collection.name,
_postman_id: `opsxapi-${collection.id}`,
description: collection.description || undefined,
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
},
item: convertNodes(collection.children),
};
}
function convertNodes(nodes: CollectionNode[]): Obj[] {
const out: Obj[] = [];
for (const node of nodes) {
if (node.type === "folder") {
out.push({
name: node.name,
description: node.description || undefined,
item: convertNodes(node.children),
});
} else if (node.kind === "http" || node.kind === "graphql") {
out.push(convertRequest(node));
}
}
return out;
}
function convertRequest(node: RequestNode): Obj {
const data = node.data as HttpRequestData | GraphqlRequestData;
const header = data.headers
.filter((h) => h.enabled && h.key)
.map((h) => ({ key: h.key, value: h.value, type: "text" }));
const authObj = postmanAuth(data.auth);
if (data.kind === "graphql") {
return {
name: node.name,
request: {
method: "POST",
header: [{ key: "Content-Type", value: "application/json", type: "text" }, ...header],
body: {
mode: "graphql",
graphql: { query: data.query, variables: data.variables },
},
url: { raw: data.url, host: splitHost(data.url) },
...(authObj ? { auth: authObj } : {}),
...(node.description ? { description: node.description } : {}),
},
};
}
const query = data.query
.filter((q) => q.enabled && q.key)
.map((q) => ({ key: q.key, value: q.value, disabled: false }));
const raw = buildRaw(data);
let body: Obj | undefined;
if (data.bodyMode === "json" || data.bodyMode === "xml" || data.bodyMode === "text" || data.bodyMode === "html") {
body = {
mode: "raw",
raw: data.bodyRaw,
options: { raw: { language: data.bodyMode === "json" ? "json" : data.bodyMode === "xml" ? "xml" : "text" } },
};
} else if (data.bodyMode === "urlencoded") {
body = {
mode: "urlencoded",
urlencoded: data.bodyForm.filter((f) => f.key).map((f) => ({ key: f.key, value: f.value, disabled: !f.enabled })),
};
} else if (data.bodyMode === "form-data") {
body = {
mode: "formdata",
formdata: data.bodyForm.filter((f) => f.key).map((f) => ({ key: f.key, value: f.value, type: "text", disabled: !f.enabled })),
};
}
const cookie = data.cookies.filter((c) => c.enabled && c.key).map((c) => `${c.key}=${c.value}`).join("; ");
return {
name: node.name,
request: {
method: data.method,
header: header.length ? header : undefined,
body,
...(cookie ? { header: [...(header ?? []), { key: "Cookie", value: cookie, type: "text" }] } : {}),
url: {
raw,
host: splitHost(raw),
query: query.length ? query : undefined,
},
...(authObj ? { auth: authObj } : {}),
...(node.description ? { description: node.description } : {}),
},
};
}
function buildRaw(data: HttpRequestData): string {
const enabled = data.query.filter((q) => q.enabled && q.key);
if (!enabled.length) return data.url;
const qs = enabled.map((q) => `${encodeURIComponent(q.key)}=${encodeURIComponent(q.value)}`).join("&");
return data.url.includes("?") ? `${data.url}&${qs}` : `${data.url}?${qs}`;
}
function splitHost(url: string): string[] {
try {
const u = new URL(url.includes("://") ? url : `http://${url}`);
return [u.host];
} catch {
return [url];
}
}
function postmanAuth(auth: HttpRequestData["auth"]): Obj | undefined {
if (auth.mode === "bearer" && auth.bearer) {
return { type: "bearer", bearer: [{ key: "token", value: auth.bearer, type: "string" }] };
}
if (auth.mode === "basic") {
return {
type: "basic",
basic: [
{ key: "username", value: auth.basicUser ?? "", type: "string" },
{ key: "password", value: auth.basicPass ?? "", type: "string" },
],
};
}
if (auth.mode === "apikey" && auth.apiKeyName) {
return {
type: "apikey",
apikey: [
{ key: "key", value: auth.apiKeyName, type: "string" },
{ key: "value", value: auth.apiKeyValue ?? "", type: "string" },
{ key: "in", value: auth.apiKeyIn ?? "header", type: "string" },
],
};
}
return undefined;
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { createSeedWorkspace } from "./factory";
describe("createSeedWorkspace HTTP8 regression", () => {
it("seeds eight echo steps bound to real node ids", () => {
const ws = createSeedWorkspace();
const tc = ws.testCases.find((c) => c.id === "tc-opsx-http8-regression");
expect(tc).toBeTruthy();
expect(tc!.steps).toHaveLength(8);
const ids = new Set<string>();
const walk = (nodes: { type: string; id: string; children?: unknown[] }[]) => {
for (const n of nodes) {
ids.add(n.id);
if (n.type === "folder" && Array.isArray(n.children)) walk(n.children as typeof nodes);
}
};
for (const c of ws.collections) walk(c.children as { type: string; id: string; children?: unknown[] }[]);
for (const s of tc!.steps) {
expect(ids.has(s.nodeId)).toBe(true);
}
expect(ws.activeEnvironmentId).toBe(ws.environments.find((e) => e.name === "本地")?.id);
});
});
+387
View File
@@ -0,0 +1,387 @@
/**
* 节点 / 集合 / 环境的默认工厂。
*/
import { normalizeMockSettings } from "./mock";
import type {
AuthConfig,
Collection,
CollectionNode,
Environment,
FolderNode,
GraphqlRequestData,
HttpRequestData,
KVRow,
MqttRequestData,
RequestKind,
RequestNode,
RequestDataOf,
SocketIoRequestData,
SseRequestData,
WebsocketRequestData,
WorkspaceData,
TestCase,
TestStep,
} from "./model";
import { newId } from "./tree";
export function kv(
key = "",
value = "",
enabled = true,
description?: string,
): KVRow {
return { id: newId(), key, value, enabled, description };
}
export const noAuth: AuthConfig = { mode: "none" };
export function defaultRequestData(kind: RequestKind) {
switch (kind) {
case "http":
return {
kind,
method: "GET",
url: "/",
query: [],
headers: [],
cookies: [],
auth: { ...noAuth },
bodyMode: "none",
bodyRaw: "",
bodyForm: [],
} satisfies HttpRequestData;
case "graphql":
return {
kind,
url: "/graphql",
headers: [kv("Content-Type", "application/json")],
auth: { ...noAuth },
query: "query {\n \n}",
variables: "{}",
} satisfies GraphqlRequestData;
case "websocket":
return {
kind,
url: "wss://echo.websocket.org",
headers: [],
protocols: "",
} satisfies WebsocketRequestData;
case "sse":
return {
kind,
url: "/events",
headers: [kv("Accept", "text/event-stream")],
query: [],
auth: { ...noAuth },
lastEventId: "",
dataOnly: true,
} satisfies SseRequestData;
case "socketio":
return {
kind,
url: "http://127.0.0.1:3000",
options: '{\n "path": "/socket.io",\n "transports": ["websocket"]\n}',
listenEvents: ["message"],
sendEvent: "message",
sendArgs: '[\n "hello"\n]',
} satisfies SocketIoRequestData;
case "mqtt":
return {
kind,
brokerUrl: "wss://broker.emqx.io:8084/mqtt",
username: "",
password: "",
clientId: `opsxapi-${Math.random().toString(16).slice(2, 10)}`,
subscribeTopics: "opsxapi/demo",
publishTopic: "opsxapi/demo",
publishPayload: '{\n "hello": "opsxapi"\n}',
qos: 0,
} satisfies MqttRequestData;
}
}
export function createRequest<K extends RequestKind>(
kind: K,
name?: string,
partial?: { data?: Partial<RequestDataOf<K>>; description?: string },
): Extract<RequestNode, { kind: K }> {
const data = {
...defaultRequestData(kind),
...(partial?.data ?? {}),
kind,
} as RequestDataOf<K>;
const base = {
id: newId(),
type: "request" as const,
name: name ?? defaultName(kind),
description: partial?.description,
createdAt: Date.now(),
updatedAt: Date.now(),
};
return { ...base, kind, data } as unknown as Extract<RequestNode, { kind: K }>;
}
export function defaultName(kind: RequestKind): string {
switch (kind) {
case "http":
return "新建 HTTP 接口";
case "graphql":
return "新建 GraphQL 查询";
case "websocket":
return "新建 WebSocket";
case "sse":
return "新建 SSE";
case "socketio":
return "新建 Socket.IO";
case "mqtt":
return "新建 MQTT";
}
}
export function createFolder(name = "新建目录", children: CollectionNode[] = []): FolderNode {
return {
id: newId(),
type: "folder",
name,
children,
createdAt: Date.now(),
updatedAt: Date.now(),
};
}
export function createCollection(
name: string,
children: CollectionNode[] = [],
): Collection {
return {
id: newId(),
type: "collection",
name,
children,
createdAt: Date.now(),
updatedAt: Date.now(),
};
}
export function createEnvironment(
name: string,
vars: { key: string; value: string }[] = [],
baseUrl = "",
): Environment {
return { id: newId(), name, baseUrl, variables: vars.map((v) => kv(v.key, v.value)) };
}
/** 首次启动的演示集合:覆盖全部协议,开箱即用 */
export function createSeedWorkspace(): WorkspaceData {
const http = (
name: string,
method: HttpRequestData["method"],
url: string,
extra?: Partial<HttpRequestData>,
description?: string,
): RequestNode =>
createRequest("http", name, {
data: { method, url, ...extra },
description,
});
const localBase = "http://127.0.0.1:8787/v1/echo";
// 固定引用,供种子场景用例 steps.nodeId 绑定
const echoGet = http(
"回归 · GET Query",
"GET",
`${localBase}/get?ping=opsx`,
{ query: [kv("ping", "opsx")] },
"本地回显:Query 参数。需 Gin :8787。",
);
const echoJson = http(
"回归 · POST JSON",
"POST",
`${localBase}/json`,
{
bodyMode: "json",
bodyRaw: '{\n "name": "opsxapi",\n "lang": "zh-CN"\n}',
headers: [kv("Content-Type", "application/json")],
},
);
const echoForm = http(
"回归 · POST urlencoded",
"POST",
`${localBase}/form`,
{
bodyMode: "urlencoded",
bodyForm: [kv("username", "logan"), kv("role", "ops")],
},
);
const echoMultipart = http(
"回归 · POST form-data",
"POST",
`${localBase}/multipart`,
{
bodyMode: "form-data",
bodyForm: [kv("fileMeta", "demo"), kv("env", "local")],
},
);
const echoHeaders = http(
"回归 · 自定义 Header",
"GET",
`${localBase}/headers`,
{ headers: [kv("X-Opsx-Probe", "ok")] },
);
const echoCookies = http(
"回归 · Cookie",
"GET",
`${localBase}/cookies`,
{ headers: [kv("X-Opsx-Cookie", "opsx_session=demo-cookie")] },
);
const echoBearer = http(
"回归 · Bearer",
"GET",
`${localBase}/bearer`,
{ auth: { mode: "bearer", bearer: "{{token}}" } },
);
const echoBasic = http(
"回归 · Basic Auth",
"GET",
`${localBase}/basic`,
{ auth: { mode: "basic", basicUser: "opsx", basicPass: "demo" } },
);
const demo = createCollection("示例集合", [
createFolder("HTTP · 本地回归(P0)", [
echoGet,
echoJson,
echoForm,
echoMultipart,
echoHeaders,
echoCookies,
echoBearer,
echoBasic,
]),
createFolder("HTTP · httpbin", [
http("获取请求回显", "GET", "https://httpbin.org/get", undefined, "经典回显接口,返回请求头 / IP / 参数。"),
http(
"提交 JSON",
"POST",
"https://httpbin.org/post",
{
bodyMode: "json",
bodyRaw: '{\n "name": "opsxapi",\n "lang": "zh-CN"\n}',
headers: [kv("Content-Type", "application/json")],
},
"POST 一个 JSON 体,观察 Pretty 视图。",
),
http(
"表单提交",
"POST",
"https://httpbin.org/post",
{
bodyMode: "urlencoded",
bodyForm: [kv("username", "logan"), kv("role", "ops")],
},
),
http(
"Bearer 鉴权示例",
"GET",
"https://httpbin.org/bearer",
{
auth: { mode: "bearer", bearer: "{{token}}" },
},
"在 Auth 标签填 Token;环境变量 token 默认 demo-token。",
),
http("状态码 · 404", "GET", "https://httpbin.org/status/404"),
]),
createFolder("GraphQL", [
createRequest("graphql", "国家查询 · countries", {
data: {
url: "https://countries.trevorblades.com/graphql",
query:
"query ($code: ID!) {\n country(code: $code) {\n name\n capital\n currency\n }\n}",
variables: '{\n "code": "CN"\n}',
},
description: "公共 GraphQL 服务,无需鉴权。",
}),
]),
createFolder("实时协议", [
createRequest("websocket", "WebSocket · echo", {
data: { url: "wss://echo.websocket.org" },
description: "发送什么就回什么。",
}),
createRequest("sse", "SSE · 维基媒体变更流", {
data: {
url: "https://stream.wikimedia.org/v2/stream/recentchange",
headers: [kv("Accept", "text/event-stream")],
},
description: "维基百科实时编辑事件流。",
}),
createRequest("mqtt", "MQTT · EMQX 公共 Broker", {
data: {
subscribeTopics: "opsxapi/demo",
publishTopic: "opsxapi/demo",
},
description: "wss 公共 broker;两个窗口同时打开可互相收发。",
}),
createRequest("socketio", "Socket.IO · 本地服务", {
description: "连你的本地 socket.io 服务,或改 URL 到任意 demo 站点。",
}),
]),
]);
const step = (nodeId: string, assertions: TestStep["assertions"], extracts: TestStep["extracts"] = []): TestStep => ({
id: newId(),
enabled: true,
nodeId,
assertions,
extracts,
});
const as = (
target: "status" | "timeMs" | "header" | "body",
op: TestStep["assertions"][number]["op"],
expected: string,
locator = "",
) => ({
id: newId(),
enabled: true,
target,
locator,
op,
expected,
});
const regressionCase: TestCase = {
id: "tc-opsx-http8-regression",
name: "HTTP 八组合回归(本地 echo)",
description: "对 Gin /v1/echo 的 8 种请求组合:Query / JSON / urlencoded / form-data / Header / Cookie / Bearer / Basic。请先 ./run.sh web 并切换环境到「本地」。",
continueOnFailure: true,
steps: [
step(echoGet.id, [as("status", "eq", "200"), as("body", "jsonpath_eq", "true", "$.ok"), as("body", "jsonpath_eq", "opsx", "$.query.ping")]),
step(echoJson.id, [as("status", "eq", "200"), as("body", "contains", "opsxapi")]),
step(echoForm.id, [as("status", "eq", "200"), as("body", "jsonpath_eq", "logan", "$.form.username")]),
step(echoMultipart.id, [as("status", "eq", "200"), as("body", "jsonpath_eq", "demo", "$.form.fileMeta")]),
step(echoHeaders.id, [as("status", "eq", "200"), as("body", "jsonpath_eq", "ok", "$.xOpsxProbe")]),
step(echoCookies.id, [as("status", "eq", "200"), as("body", "jsonpath_eq", "demo-cookie", "$.cookies.opsx_session")]),
step(echoBearer.id, [as("status", "eq", "200"), as("body", "jsonpath_eq", "true", "$.authenticated")]),
step(echoBasic.id, [as("status", "eq", "200"), as("body", "jsonpath_eq", "opsx", "$.user")]),
],
createdAt: Date.now(),
updatedAt: Date.now(),
};
const envs = [
createEnvironment("正式", [{ key: "token", value: "demo-token" }], "https://httpbin.org"),
createEnvironment("本地", [{ key: "token", value: "demo-token" }], "http://127.0.0.1:8787"),
];
return {
version: 2,
collections: [demo],
environments: envs,
activeEnvironmentId: envs[1]?.id ?? envs[0]?.id ?? null,
history: [],
settings: { theme: "dark", timeoutMs: 30000, mock: normalizeMockSettings(null), ai: { providers: [], activeProviderId: null } },
testCases: [regressionCase],
testRuns: [],
schedules: [],
};
}
+506
View File
@@ -0,0 +1,506 @@
import { describe, expect, it } from "vitest";
import {
detectImportFormat,
importAuto,
importCurl,
importOpenApi,
parseCurl,
} from "./importers";
import { importPostman } from "./importers/postman";
import { importHar } from "./importers/har";
import { importInsomnia } from "./importers/insomnia";
import { importHoppscotch } from "./importers/hoppscotch";
import { importApipost } from "./importers/apipost";
import { importMarkdown } from "./importers/markdown";
import { findNode, insertIntoCollection, moveNode, removeNode } from "./tree";
import { createSeedWorkspace } from "./factory";
import { exportCollectionAs, exportBackup } from "./exporters";
import { exportCurl } from "./exporters/curl";
const openapi3 = JSON.stringify({
openapi: "3.0.3",
info: { title: "Petstore", version: "1.0.0" },
servers: [{ url: "https://petstore.example.com/v1" }],
tags: [{ name: "pets" }],
paths: {
"/pets": {
get: {
tags: ["pets"],
summary: "列出宠物",
operationId: "listPets",
parameters: [
{ name: "limit", in: "query", schema: { type: "integer" }, example: 10 },
{ name: "X-Trace", in: "header", schema: { type: "string" }, example: "abc" },
],
responses: {},
},
post: {
tags: ["pets"],
summary: "新增宠物",
operationId: "createPet",
security: [{ petstoreAuth: [] }],
requestBody: {
content: {
"application/json": {
schema: {
type: "object",
properties: { name: { type: "string" }, age: { type: "integer" } },
},
},
},
},
responses: {},
},
},
"/pets/{petId}": {
get: {
summary: "查询单个宠物",
parameters: [{ name: "petId", in: "path", required: true, schema: { type: "string" }, example: "42" }],
responses: {},
},
},
},
components: {
securitySchemes: { petstoreAuth: { type: "http", scheme: "bearer" } },
},
});
const openapi2 = `
swagger: "2.0"
info:
title: Old API
version: "1.0"
host: old.example.com
basePath: /api
schemes: [https]
paths:
/users:
get:
summary: 用户列表
parameters:
- name: page
in: query
type: integer
example: 1
responses:
200:
description: ok
post:
summary: 创建用户
consumes: [application/json]
parameters:
- name: body
in: body
schema:
type: object
properties:
username:
type: string
responses:
201:
description: created
`;
const postmanV21 = JSON.stringify({
info: { name: "Demo", schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" },
item: [
{
name: "目录A",
item: [
{
name: "获取用户",
request: {
method: "GET",
url: { raw: "https://api.example.com/users?page=2", query: [{ key: "page", value: "2" }] },
header: [{ key: "Accept", value: "application/json" }],
},
},
{
name: "登录",
request: {
method: "POST",
url: { raw: "{{baseUrl}}/login" },
header: [{ key: "Content-Type", value: "application/json" }],
body: { mode: "raw", raw: "{\n \"user\": \"a\",\n \"pass\": \"b\"\n}", options: { raw: { language: "json" } } },
auth: { type: "bearer", bearer: [{ key: "token", value: "xyz" }] },
},
},
],
},
],
variable: [{ key: "baseUrl", value: "https://api.example.com" }],
});
const harDoc = JSON.stringify({
log: {
entries: [
{
request: {
method: "GET",
url: "https://api.example.com/items?size=5",
headers: [{ name: "Accept", value: "application/json" }],
queryString: [{ name: "size", value: "5" }],
},
},
{
request: {
method: "POST",
url: "https://api.example.com/items",
headers: [{ name: "Content-Type", value: "application/json" }],
postData: { mimeType: "application/json", text: '{"a":1}' },
},
},
],
},
});
const insomniaDoc = JSON.stringify({
_type: "export",
__export_format: 4,
resources: [
{ _id: "wrk_1", _type: "workspace", name: "我的服务" },
{ _id: "fld_1", _type: "request_group", parentId: "wrk_1", name: "用户" },
{
_id: "req_1",
_type: "request",
parentId: "fld_1",
name: "资料",
method: "GET",
url: "https://api.example.com/me",
headers: [{ name: "Authorization", value: "Bearer t" }],
parameters: [],
authentication: { type: "bearer", token: "t" },
body: {},
},
],
});
const hoppscotchDoc = JSON.stringify([
{
name: "SC",
folders: [{ name: "子", requests: [] }],
requests: [
{
name: "列表",
method: "GET",
url: "https://api.example.com/list",
headers: [{ key: "Accept", value: "application/json", active: true }],
params: [],
bodyMode: "none",
},
],
},
]);
const apipostDoc = JSON.stringify({
name: "Apipost 项目",
api_collection: [
{ target_id: "f1", parent_id: "0", name: "订单", children: [] },
{
target_id: "r1",
parent_id: "f1",
name: "下单",
method: "POST",
url: "https://api.example.com/orders",
request: {
headers: { "Content-Type": "application/json" },
body: { mode: "json", raw: '{"sku":1}' },
},
},
],
});
const curlCmd = `curl -X POST 'https://api.example.com/v1/users?dry=1' \\
-H 'Content-Type: application/json' \\
-H 'Authorization: Bearer tok' \\
-d '{"name":"logan","tags":["a","b"]}'`;
describe("格式识别", () => {
it("识别各格式", () => {
expect(detectImportFormat(openapi3)).toBe("openapi");
expect(detectImportFormat(openapi2)).toBe("openapi");
expect(detectImportFormat(postmanV21)).toBe("postman");
expect(detectImportFormat(harDoc)).toBe("har");
expect(detectImportFormat(insomniaDoc)).toBe("insomnia");
expect(detectImportFormat(hoppscotchDoc)).toBe("hoppscotch");
expect(detectImportFormat(apipostDoc)).toBe("apipost");
expect(detectImportFormat(curlCmd)).toBe("curl");
expect(detectImportFormat("# 文档\n\n```curl\ncurl http://a.com\n```")).toBe("markdown");
});
});
describe("cURL", () => {
it("解析方法/URL/头/体", () => {
const parsed = parseCurl(curlCmd);
expect(parsed.method).toBe("POST");
expect(parsed.url).toBe("https://api.example.com/v1/users");
expect(parsed.query).toEqual([{ key: "dry", value: "1", enabled: true }]);
expect(parsed.bodyMode).toBe("json");
expect(JSON.parse(parsed.bodyRaw)).toEqual({ name: "logan", tags: ["a", "b"] });
});
it("表单与 basic auth", () => {
const parsed = parseCurl("curl -u tom:pw -F file=@x.txt -F name=x https://a.io/up");
expect(parsed.auth.mode).toBe("basic");
expect(parsed.auth.basicUser).toBe("tom");
expect(parsed.bodyMode).toBe("form-data");
expect(parsed.bodyForm.some((f) => f.key === "name" && f.value === "x")).toBe(true);
});
it("urlencoded 推断", () => {
const parsed = parseCurl(`curl -d 'a=1&b=2' http://x.io/f`);
expect(parsed.method).toBe("POST");
expect(parsed.bodyMode).toBe("urlencoded");
expect(parsed.bodyForm.length).toBe(2);
});
it("导入为集合", () => {
const c = importCurl(curlCmd);
expect(c.children.length).toBe(1);
expect(c.children[0].type).toBe("request");
});
});
describe("OpenAPI", () => {
it("保留响应示例与 schema 字段作为项目文档", () => {
const spec = {
openapi: "3.1.0", info: { title: "Catalog" },
paths: {
"/items/{id}": {
get: {
tags: ["items"],
parameters: [{ in: "path", name: "id", required: true, schema: { type: "integer", example: 7 } }],
responses: { "200": { description: "found", content: { "application/json": {
schema: { type: "object", properties: { id: { type: "integer", example: 7 } } },
} } } },
},
},
},
};
const result = importOpenApi(JSON.stringify(spec));
const folder = result.collections[0].children[0];
if (folder.type !== "folder") throw new Error("missing tag folder");
const node = folder.children[0];
if (node.type !== "request" || node.kind !== "http") throw new Error("missing HTTP node");
expect(node.documentation?.parameters[0]).toMatchObject({ name: "id", required: true, example: "7" });
expect(JSON.parse(node.documentation?.responses[0].example || "")).toEqual({ id: 7 });
});
it("解析 components 中引用的参数、请求体和响应", () => {
const spec = `
openapi: 3.0.3
info: { title: Ref }
components:
parameters:
Id: { name: id, in: path, required: true, schema: { type: integer, example: 9 } }
requestBodies:
Payload:
content:
application/json:
example: { enabled: true }
responses:
Success:
description: ok
content:
application/json:
schema: { type: object, properties: { ok: { type: boolean } } }
paths:
/items/{id}:
post:
parameters: [{ $ref: '#/components/parameters/Id' }]
requestBody: { $ref: '#/components/requestBodies/Payload' }
responses: { '200': { $ref: '#/components/responses/Success' } }
`;
const node = importOpenApi(spec).collections[0].children[0];
if (node.type !== "request" || node.kind !== "http") throw new Error("missing node");
expect(node.data.url).toContain("{{id}}");
expect(node.data.bodyRaw).toContain("enabled");
expect(node.documentation?.parameters[0].example).toBe("9");
expect(JSON.parse(node.documentation?.responses[0].example ?? "")).toEqual({ ok: true });
});
it("导入 v3(tag 目录、query/header、bearer、path 参数)", () => {
const result = importOpenApi(openapi3);
const c = result.collections[0];
expect(c.name).toBe("Petstore");
expect(c.children.length).toBe(2); // pets 目录 + 根级 /pets/{petId}
const folder = c.children[0];
expect(folder.type).toBe("folder");
if (folder.type !== "folder") return;
const list = folder.children[0];
if (list.type !== "request" || list.kind !== "http") return;
expect(list.data.method).toBe("GET");
expect(list.data.url).toBe("{{baseUrl}}/pets");
expect(result.environmentVariables.find((v) => v.key === "baseUrl")?.value).toBe(
"https://petstore.example.com/v1",
);
expect(list.data.query[0].key).toBe("limit");
expect(list.data.query[0].value).toBe("10");
expect(list.data.headers.some((h) => h.key === "X-Trace")).toBe(true);
const single = c.children[1];
if (single.type !== "request" || single.kind !== "http") return;
expect(single.data.url).toContain("{{petId}}");
expect(result.environmentVariables.find((v) => v.key === "petId")?.value).toBe("42");
});
it("导入 v2 YAML(basePath、body schema)", () => {
const result = importOpenApi(openapi2);
const c = result.collections[0];
expect(result.environmentVariables.find((v) => v.key === "baseUrl")?.value).toBe("https://old.example.com/api");
const post = c.children.find((n) => n.type === "request" && n.kind === "http" && n.data.method === "POST");
expect(post).toBeTruthy();
if (post && post.type === "request" && post.kind === "http") {
expect(post.data.bodyMode).toBe("json");
expect(post.data.bodyRaw).toContain("username");
}
});
});
describe("Postman", () => {
it("导入 v2.1(目录/鉴权/变量)", () => {
const result = importPostman(postmanV21);
const c = result.collections[0];
expect(c.name).toBe("Demo");
const folder = c.children[0];
if (folder.type !== "folder") throw new Error("应当是目录");
const login = folder.children[1];
if (login.type !== "request" || login.kind !== "http") throw new Error("应当是请求");
expect(login.data.auth.mode).toBe("bearer");
expect(login.data.bodyMode).toBe("json");
expect(result.environmentVariables.find((v) => v.key === "baseUrl")?.value).toBe("https://api.example.com");
const getUser = folder.children[0];
if (getUser.type !== "request" || getUser.kind !== "http") throw new Error("应当是请求");
expect(getUser.data.query[0].key).toBe("page");
});
});
describe("HAR", () => {
it("按域名分组导入", () => {
const result = importHar(harDoc);
const c = result.collections[0];
expect(c.children.length).toBe(1);
const hostFolder = c.children[0];
if (hostFolder.type !== "folder") throw new Error("应当是目录");
expect(hostFolder.name).toBe("api.example.com");
expect(hostFolder.children.length).toBe(2);
});
});
describe("Insomnia", () => {
it("导入 workspace/group/request 层级", () => {
const result = importInsomnia(insomniaDoc);
const c = result.collections[0];
expect(c.name).toBe("我的服务");
const folder = c.children[0];
if (folder.type !== "folder") throw new Error("应当是目录");
expect(folder.name).toBe("用户");
const req = folder.children[0];
if (req.type !== "request" || req.kind !== "http") throw new Error("应当是请求");
expect(req.data.url).toBe("https://api.example.com/me");
expect(req.data.auth.mode).toBe("bearer");
});
});
describe("Hoppscotch", () => {
it("导入集合数组", () => {
const result = importHoppscotch(hoppscotchDoc);
const c = result.collections[0];
expect(c.name).toBe("SC");
expect(c.children.length).toBe(2); // 子目录 + 列表
});
});
describe("Apipost", () => {
it("导入 api_collection(目录 + 请求)", () => {
const result = importApipost(apipostDoc);
const c = result.collections[0];
expect(c.name).toBe("Apipost 项目");
const folder = c.children[0];
if (folder.type !== "folder") throw new Error("应当是目录");
expect(folder.name).toBe("订单");
const req = folder.children[0];
if (req.type !== "request" || req.kind !== "http") throw new Error("应当是请求");
expect(req.data.method).toBe("POST");
expect(req.data.bodyMode).toBe("json");
});
});
describe("Markdown", () => {
it("提取 curl 代码块", () => {
const md = `# 用户文档
## 登录
\`\`\`curl
curl -X POST https://a.io/login -d '{"u":1}'
\`\`\`
`;
const result = importMarkdown(md);
const c = result.collections[0];
expect(c.name).toBe("用户文档");
expect(c.children.length).toBe(1);
});
});
describe("树操作", () => {
it("插入/移动/删除/查找", () => {
const ws = createSeedWorkspace();
const collections = ws.collections;
const demo = collections[0];
const folder = demo.children[0];
if (folder.type !== "folder") throw new Error("seed 第一个子节点应为目录");
const req = folder.children[0];
const found = findNode(collections, req.id);
expect(found?.node.id).toBe(req.id);
// 移动到顶层
const moved = moveNode(collections, req.id, demo.id, null);
const movedRef = findNode(moved, req.id);
expect(movedRef?.parent).toBeNull();
// 删除
const removed = removeNode(moved, req.id);
expect(findNode(removed, req.id)).toBeNull();
// 插入顶层
const newNode = { ...req, id: "new-1" };
const inserted = insertIntoCollection(removed, demo.id, null, newNode);
expect(findNode(inserted, "new-1")).toBeTruthy();
});
});
describe("导出", () => {
it("OpenAPI / Postman / Markdown 往返", () => {
const imported = importAuto(openapi3);
const c = imported.collections[0];
const openapiJson = exportCollectionAs("openapi-json", c);
const reparsed = JSON.parse(openapiJson);
expect(reparsed.openapi).toBe("3.0.3");
expect(reparsed.paths["/pets"].get).toBeTruthy();
const postmanJson = exportCollectionAs("postman", c);
const reparsedPostman = JSON.parse(postmanJson);
expect(reparsedPostman.info.schema).toContain("v2.1.0");
const md = exportCollectionAs("markdown", c);
expect(md).toContain("# Petstore");
expect(md).toContain("GET");
});
it("cURL 导出", () => {
const c = importCurl(curlCmd);
const req = c.children[0];
if (req.type !== "request" || req.kind !== "http") throw new Error("应当是请求");
const exported = exportCurl(req.data);
expect(exported).toContain("-X POST");
expect(exported).toContain("--data-raw");
expect(exported).toContain("api.example.com/v1/users");
});
it("备份导出", () => {
const ws = createSeedWorkspace();
const backup = exportBackup(ws.collections, ws.environments);
const parsed = JSON.parse(backup);
expect(parsed.collections.length).toBeGreaterThan(0);
expect(detectImportFormat(backup)).toBe("backup");
});
});
+209
View File
@@ -0,0 +1,209 @@
/**
* Apipost 项目导出文件导入器(宽容解析:api_collection / parent_id / children 均支持)。
* Apidoc / Apizza / Eolink / YApi 建议先导出为 OpenAPI 或 Postman 格式再导入。
*/
import type { Collection, CollectionNode, FolderNode, HttpRequestData } from "../model";
import { createCollection, createFolder, createRequest, kv } from "../factory";
import type { ImportResult } from "../model";
import { normalizeMethod } from "./curl";
type Obj = Record<string, unknown>;
const asObj = (v: unknown): Obj => (v && typeof v === "object" ? (v as Obj) : {});
const asArr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
export function isApipostExport(doc: Obj): boolean {
if (Array.isArray(doc.api_collection)) return true;
if (Array.isArray(asObj(doc.data).api_collection)) return true;
// apipost v7 导出:{"name":..., "api_collection":[...]} 或数组包裹
if (Array.isArray(doc.item_list)) return true;
return false;
}
export function importApipost(text: string): ImportResult {
const doc = asObj(JSON.parse(text));
if (!isApipostExport(doc)) throw new Error("不是 Apipost 导出文件(缺少 api_collection)");
const warnings: string[] = [];
const rawItems = asArr(doc.api_collection ?? asObj(doc.data).api_collection ?? doc.item_list);
const items = rawItems.map(asObj);
// 取出文件夹(target_type/name+children 或 parent_id 结构)
const requestItems = items.filter((it) => !isFolderItem(it));
const folderItems = items.filter(isFolderItem);
const byParent = new Map<string, Obj[]>();
const parentKeyOf = (it: Obj): string => {
const parent = str(it.parent_id) || str(it.parentId);
if (!parent || parent === "0" || parent === "null" || parent === "root") return "__root__";
return parent;
};
for (const it of items) {
const parent = parentKeyOf(it);
if (!byParent.has(parent)) byParent.set(parent, []);
byParent.get(parent)!.push(it);
}
const envVars: { key: string; value: string }[] = [];
const build = (parentKey: string): CollectionNode[] => {
const out: CollectionNode[] = [];
for (const it of byParent.get(parentKey) ?? []) {
if (isFolderItem(it)) {
const name = str(it.name) || str(it.title) || "目录";
const folder: FolderNode = createFolder(name, build(str(it.target_id) || str(it.id) || str(it._id) || name));
out.push(folder);
} else {
const node = convertRequest(it, envVars, warnings);
if (node) out.push(node);
}
}
return out;
};
const children = build("__root__");
// parent_id 断链的请求兜底平铺
const placed = new Set<string>();
for (const it of folderItems) placed.add(str(it.target_id) || str(it.id));
for (const it of requestItems) {
const parent = parentKeyOf(it);
if (parent !== "__root__" && !placed.has(parent)) {
const node = convertRequest(it, envVars, warnings);
if (node) children.push(node);
}
}
const collection = createCollection(str(doc.name) || str(asObj(doc.data).name) || "Apipost 导入", children);
let count = countNodes(children);
if (count === 0) warnings.push("没有解析到请求;若是新版导出格式,请改用 OpenAPI/Postman 格式导出后导入");
// apipost 环境
for (const env of asArr(doc.env ?? asObj(doc.data).env)) {
const envObj = asObj(env);
for (const v of asArr(envObj.param ?? envObj.variable)) {
const vObj = asObj(v);
const key = str(vObj.key);
if (key) envVars.push({ key, value: str(vObj.value) });
}
}
return { collections: [collection], environmentVariables: envVars, warnings };
}
function isFolderItem(it: Obj): boolean {
const hasChildren = Array.isArray(it.children) || Array.isArray(it.item);
const typeMark = str(it.target_type ?? it.type ?? it.item_type);
return hasChildren || typeMark === "folder" || typeMark === "group" || typeMark === "目录";
}
function convertRequest(
it: Obj,
_envVars: { key: string; value: string }[],
warnings: string[],
): CollectionNode | null {
const name = str(it.name) || str(it.title) || "未命名请求";
const method = normalizeMethod(str(it.method) || "GET");
const request = asObj(it.request);
// url 可能是字符串或对象
let url = "";
if (typeof request.url === "string") url = request.url;
else {
url = str(asObj(request.url).raw ?? asObj(it.url).raw ?? it.url ?? "");
}
const headers: HttpRequestData["headers"] = [];
const query: HttpRequestData["query"] = [];
// params/headers 可能是数组或对象
const paramContainer = request.parameter ?? request.params ?? it.parameter;
collectKV(paramContainer, query);
const headerContainer = request.headers ?? request.header ?? it.headers;
collectKV(headerContainer, headers);
const body = asObj(request.body ?? it.request_body);
const mode = str(body.mode ?? body.type ?? "none");
let bodyMode: HttpRequestData["bodyMode"] = "none";
let bodyRaw = "";
let bodyForm: HttpRequestData["bodyForm"] = [];
if (mode === "json" || mode === "raw") {
bodyRaw = str(body.raw ?? body.text ?? "");
bodyMode = looksLikeJson(bodyRaw) || mode === "json" ? "json" : "text";
if (bodyMode === "json") bodyRaw = formatJson(bodyRaw);
} else if (mode === "urlencoded" || mode === "urlencoded[]") {
bodyMode = "urlencoded";
collectKV(body.parameter ?? body.params, bodyForm);
} else if (mode === "form-data" || mode === "formdata") {
bodyMode = "form-data";
collectKV(body.parameter ?? body.params, bodyForm);
} else if (mode === "xml") {
bodyMode = "xml";
bodyRaw = str(body.raw ?? "");
} else if (mode === "binary" || mode === "file") {
warnings.push(`${name}:binary body 不支持导入`);
}
const auth = asObj(request.auth ?? it.auth);
let authConfig: HttpRequestData["auth"] = { mode: "none" };
const authType = str(auth.type ?? auth.mode);
if (authType === "bearer") authConfig = { mode: "bearer", bearer: str(auth.bearer ?? auth.token) };
if (authType === "basic") authConfig = { mode: "basic", basicUser: str(auth.user), basicPass: str(auth.password) };
return createRequest("http", name, {
data: {
kind: "http",
method,
url,
query,
headers,
cookies: [],
auth: authConfig,
bodyMode,
bodyRaw,
bodyForm,
},
description: str(it.description) || str(request.description) || undefined,
});
}
/** apipost 的 KV 容器:[{key,value}] / [{name,value}] / {k:v} 都收 */
function collectKV(container: unknown, out: HttpRequestData["headers"] | HttpRequestData["query"] | HttpRequestData["bodyForm"]): void {
if (Array.isArray(container)) {
for (const item of container) {
const itemObj = asObj(item);
const key = str(itemObj.key ?? itemObj.name);
const value = str(itemObj.value ?? "");
const fieldTypeId = itemObj.field_type ?? itemObj.fieldType;
const disabled = itemObj.is_checked === 0 || itemObj.disabled === true || itemObj.enable === false;
if (key && str(fieldTypeId) !== "2") out.push(kv(key, value, !disabled));
}
} else if (container && typeof container === "object") {
for (const [k, v] of Object.entries(container as Obj)) {
const vObj = asObj(v);
if (typeof v === "string") out.push(kv(k, v));
else if (vObj.value !== undefined || vObj.key !== undefined) out.push(kv(str(vObj.key ?? k), str(vObj.value)));
}
}
}
function looksLikeJson(s: string): boolean {
const t = s.trimStart();
return t.startsWith("{") || t.startsWith("[");
}
function formatJson(s: string): string {
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return s;
}
}
function countNodes(nodes: CollectionNode[]): number {
let n = 0;
for (const node of nodes) {
if (node.type === "request") n++;
else n += countNodes(node.children);
}
return n;
}
+386
View File
@@ -0,0 +1,386 @@
/**
* cURL 命令解析器(覆盖 curl 常用 flag,容忍换行续行 / 引号 / 嵌套引号)。
*/
import type { Collection } from "../model";
import { kv } from "../factory";
import { createCollection, createRequest } from "../factory";
/** 分词:支持 '…"…'、"…'…"、\" 转义、\<newline> 续行 */
export function tokenizeCurl(input: string): string[] {
const text = input.replace(/\\\r?\n/g, " ").replace(/\r?\n/g, " ");
const tokens: string[] = [];
let i = 0;
while (i < text.length) {
while (i < text.length && /\s/.test(text[i])) i++;
if (i >= text.length) break;
let token = "";
let quote: '"' | "'" | null = null;
while (i < text.length) {
const ch = text[i];
if (quote) {
if (ch === "\\" && quote === '"') {
const next = text[i + 1];
if (next === '"' || next === "\\") {
token += next;
i += 2;
continue;
}
token += ch;
i++;
continue;
}
if (ch === quote) {
quote = null;
i++;
continue;
}
token += ch;
i++;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
i++;
continue;
}
if (/\s/.test(ch)) break;
token += ch;
i++;
}
if (token) tokens.push(token);
}
return tokens;
}
export type ParsedCurl = {
method: string;
url: string;
headers: { key: string; value: string; enabled: boolean }[];
query: { key: string; value: string; enabled: boolean }[];
cookies: { key: string; value: string; enabled: boolean }[];
bodyMode: "none" | "json" | "xml" | "text" | "html" | "urlencoded" | "form-data";
bodyRaw: string;
bodyForm: { key: string; value: string; enabled: boolean }[];
auth: { mode: "none" | "bearer" | "basic"; bearer?: string; basicUser?: string; basicPass?: string };
warnings: string[];
};
export function parseCurl(input: string): ParsedCurl {
const warnings: string[] = [];
const tokens = tokenizeCurl(input);
let url = "";
let method = "";
const headers: ParsedCurl["headers"] = [];
const formRows: ParsedCurl["bodyForm"] = [];
const dataParts: string[] = [];
let auth: ParsedCurl["auth"] = { mode: "none" };
let cookieRaw = "";
let forceJson = false;
const flagAliases: Record<string, string> = {
"-X": "--request",
"-H": "--header",
"-d": "--data",
"--data-raw": "--data",
"--data-binary": "--data",
"--data-ascii": "--data",
"--data-urlencode": "--data",
"-F": "--form",
"--form-string": "--form",
"-u": "--user",
"--user": "--user",
"-b": "--cookie",
"-A": "--user-agent",
"-e": "--referer",
"-o": "--output",
"-x": "--proxy",
};
for (let i = 0; i < tokens.length; i++) {
let t = tokens[i];
if (t === "curl") continue;
if (t === "npx" || t === "wget") warnings.push(`检测到 ${t},仅按 curl 子集解析`);
const next = (): string | undefined => {
const v = tokens[++i];
return v === "$" || v === undefined ? undefined : v;
};
if (!t.startsWith("-")) {
// 位置参数 → URL
const value = t.startsWith("$'") ? t.slice(2, -1) : t;
if (/^https?:\/\//i.test(value) || !url) url = url || value;
continue;
}
// --flag=value 形式
let inlineValue: string | undefined;
const eq = t.indexOf("=");
if (t.startsWith("--") && eq > 0) {
inlineValue = t.slice(eq + 1);
t = t.slice(0, eq);
}
const flag = flagAliases[t] ?? t;
const take = (): string => {
if (inlineValue !== undefined) return inlineValue;
const v = next();
return v ?? "";
};
switch (flag) {
case "--request":
method = take().toUpperCase();
break;
case "--header": {
const raw = take();
const idx = raw.indexOf(":");
if (idx > 0) {
const key = raw.slice(0, idx).trim();
const value = raw.slice(idx + 1).trim();
if (key.endsWith(";")) {
headers.push({ key: key.slice(0, -1), value: "", enabled: false });
} else {
headers.push({ key, value, enabled: true });
}
}
break;
}
case "--data":
case "--json": {
if (t === "--json") forceJson = true;
let v = take();
if (v.startsWith("$'") && v.endsWith("'")) v = v.slice(2, -1);
if (v === "-" || v.startsWith("@")) {
warnings.push("忽略 @file / stdin 形式的数据体");
break;
}
if (t === "--data-urlencode") {
v = encodeURIComponent(v);
}
dataParts.push(v);
break;
}
case "--form": {
const v = take();
const [k, ...rest] = v.split("=");
if (k && !k.startsWith("@")) {
formRows.push({ key: k, value: rest.join("="), enabled: true });
} else {
warnings.push(`忽略文件表单项 ${k}`);
}
break;
}
case "--user": {
const v = take();
const idx = v.indexOf(":");
auth = {
mode: "basic",
basicUser: idx >= 0 ? v.slice(0, idx) : v,
basicPass: idx >= 0 ? v.slice(idx + 1) : "",
};
break;
}
case "--cookie":
cookieRaw = take();
break;
case "--user-agent":
headers.push({ key: "User-Agent", value: take(), enabled: true });
break;
case "--referer":
headers.push({ key: "Referer", value: take(), enabled: true });
break;
case "--output":
case "--proxy":
take();
break;
default:
// -k -s -L -i --compressed 等 → 忽略;未知带值 flag 尝试吞掉一个值
if (!flag.startsWith("--") && flag.length > 2 && !inlineValue) {
// 组合短 flag(-sS)忽略
} else if (flag.startsWith("--") && inlineValue === undefined && !isNoArgFlag(flag)) {
next();
}
break;
}
}
const data = dataParts.join("&");
const inferred =
method ||
(data || formRows.length
? "POST"
: url.includes(";type=")
? "PUT"
: "GET");
const headerMap = new Map(headers.map((h) => [h.key.toLowerCase(), h]));
const contentType = headerMap.get("content-type")?.value ?? "";
let bodyMode: ParsedCurl["bodyMode"] = "none";
let bodyRaw = "";
const bodyForm: ParsedCurl["bodyForm"] = [];
if (formRows.length) {
bodyMode = "form-data";
bodyForm.push(...formRows);
} else if (data) {
if (forceJson || contentType.includes("json") || looksLikeJson(data)) {
bodyMode = "json";
bodyRaw = tryFormatJson(data);
} else if (
contentType.includes("x-www-form-urlencoded") ||
(!contentType && /^[^={\[][\s\S]*=[^=]*(&|$)/.test(data) && !data.trim().startsWith("<"))
) {
bodyMode = "urlencoded";
for (const pair of data.split("&")) {
const idx = pair.indexOf("=");
if (idx > 0) {
bodyForm.push({
key: decodeMaybe(pair.slice(0, idx)),
value: decodeMaybe(pair.slice(idx + 1)),
enabled: true,
});
}
}
} else if (contentType.includes("xml") || data.trimStart().startsWith("<")) {
bodyMode = "xml";
bodyRaw = data;
} else {
bodyMode = "text";
bodyRaw = data;
}
}
// Cookie 头 → cookies 行
const cookies: ParsedCurl["cookies"] = [];
if (cookieRaw) {
for (const pair of cookieRaw.split(";")) {
const idx = pair.indexOf("=");
if (idx > 0) {
cookies.push({ key: pair.slice(0, idx).trim(), value: pair.slice(idx + 1).trim(), enabled: true });
}
}
}
// URL 查询参数拆行
const query: ParsedCurl["query"] = [];
let normalizedUrl = url;
const qIdx = url.indexOf("?");
if (qIdx >= 0) {
normalizedUrl = url.slice(0, qIdx);
for (const pair of url.slice(qIdx + 1).split("&")) {
if (!pair) continue;
const idx = pair.indexOf("=");
query.push({
key: decodeMaybe(idx > 0 ? pair.slice(0, idx) : pair),
value: idx > 0 ? decodeMaybe(pair.slice(idx + 1)) : "",
enabled: true,
});
}
}
return {
method: inferred,
url: normalizedUrl,
headers,
query,
cookies,
bodyMode,
bodyRaw,
bodyForm,
auth,
warnings,
};
}
function isNoArgFlag(flag: string): boolean {
return [
"--compressed",
"--insecure",
"--silent",
"--verbose",
"--location",
"--include",
"--head",
"-k",
"-s",
"-S",
"-L",
"-i",
"-I",
"-v",
"-f",
"-G",
"--get",
"--no-buffer",
].includes(flag);
}
function looksLikeJson(s: string): boolean {
const t = s.trimStart();
return t.startsWith("{") || t.startsWith("[");
}
function tryFormatJson(s: string): string {
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return s;
}
}
function decodeMaybe(s: string): string {
try {
return decodeURIComponent(s.replace(/\+/g, " "));
} catch {
return s;
}
}
export function importCurl(input: string, collectionName = "cURL 导入"): Collection {
const parsed = parseCurl(input);
const name = deriveCurlName(parsed);
const node = createRequest("http", name, {
data: {
method: normalizeMethod(parsed.method),
url: parsed.url,
query: parsed.query.map((q) => kv(q.key, q.value, q.enabled)),
headers: parsed.headers
.filter((h) => h.key.toLowerCase() !== "cookie")
.map((h) => kv(h.key, h.value, h.enabled)),
cookies: parsed.cookies.map((c) => kv(c.key, c.value, c.enabled)),
auth:
parsed.auth.mode === "basic"
? { mode: "basic", basicUser: parsed.auth.basicUser, basicPass: parsed.auth.basicPass }
: parsed.auth.mode === "bearer"
? { mode: "bearer", bearer: parsed.auth.bearer }
: { mode: "none" },
bodyMode: parsed.bodyMode,
bodyRaw: parsed.bodyRaw,
bodyForm: parsed.bodyForm.map((f) => kv(f.key, f.value, f.enabled)),
},
description: parsed.warnings.join("\n") || undefined,
});
const collection = createCollection(collectionName, [node]);
collection.description = "由 cURL 命令导入";
return collection;
}
export function normalizeMethod(m: string) {
const upper = m.toUpperCase();
const allowed = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] as const;
return (allowed as readonly string[]).includes(upper)
? (upper as (typeof allowed)[number])
: "GET";
}
function deriveCurlName(parsed: ParsedCurl): string {
try {
const u = new URL(parsed.url);
const seg = u.pathname.split("/").filter(Boolean).pop();
return `${parsed.method} /${seg ?? ""}`.trim();
} catch {
return `${parsed.method} ${parsed.url}`.slice(0, 60);
}
}
+151
View File
@@ -0,0 +1,151 @@
/**
* HAR(HTTP Archive)导入器:按域名分组。
*/
import type { Collection, CollectionNode, FolderNode, HttpRequestData } from "../model";
import { createCollection, createFolder, createRequest, kv } from "../factory";
import type { ImportResult } from "../model";
import { normalizeMethod } from "./curl";
type Obj = Record<string, unknown>;
const asObj = (v: unknown): Obj => (v && typeof v === "object" ? (v as Obj) : {});
const asArr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
export function isHarDocument(doc: Obj): boolean {
const log = asObj(doc.log);
return Array.isArray(log.entries);
}
export function importHar(text: string): ImportResult {
const doc = asObj(JSON.parse(text));
if (!isHarDocument(doc)) throw new Error("不是 HAR 文件(缺少 log.entries)");
const warnings: string[] = [];
const entries = asArr(asObj(doc.log).entries);
const byHost = new Map<string, FolderNode>();
const seen = new Set<string>();
let count = 0;
for (const entryRaw of entries) {
const entry = asObj(entryRaw);
const request = asObj(entry.request);
const urlStr = str(request.url);
if (!/^https?:\/\//i.test(urlStr)) continue;
const method = normalizeMethod(str(request.method) || "GET");
const dedupeKey = `${method} ${urlStr}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
let u: URL;
try {
u = new URL(urlStr);
} catch {
warnings.push(`跳过非法 URL:${urlStr.slice(0, 60)}`);
continue;
}
const host = u.host;
let folder = byHost.get(host);
if (!folder) {
folder = createFolder(host, []);
byHost.set(host, folder);
}
// query
const query: HttpRequestData["query"] = [];
for (const q of asArr(request.queryString)) {
const qObj = asObj(q);
if (qObj.name) query.push(kv(str(qObj.name), str(qObj.value)));
}
// headers(剔除 cookie)
const headers: HttpRequestData["headers"] = [];
const cookies: HttpRequestData["cookies"] = [];
for (const h of asArr(request.headers)) {
const hObj = asObj(h);
const name = str(hObj.name);
if (!name) continue;
if (name.toLowerCase() === "cookie") {
for (const pair of str(hObj.value).split(";")) {
const idx = pair.indexOf("=");
if (idx > 0) cookies.push(kv(pair.slice(0, idx).trim(), pair.slice(idx + 1).trim()));
}
continue;
}
headers.push(kv(name, str(hObj.value)));
}
// body
const postData = asObj(request.postData);
let bodyMode: HttpRequestData["bodyMode"] = "none";
let bodyRaw = "";
let bodyForm: HttpRequestData["bodyForm"] = [];
const mime = str(postData.mimeType);
const text = str(postData.text);
if (mime.includes("json") || (!mime && looksLikeJson(text))) {
bodyMode = "json";
bodyRaw = formatJson(text);
} else if (mime.includes("x-www-form-urlencoded")) {
bodyMode = "urlencoded";
const params = asArr(postData.params);
if (params.length) {
for (const p of params) {
const pObj = asObj(p);
if (pObj.name) bodyForm.push(kv(str(pObj.name), str(pObj.value)));
}
} else {
for (const pair of text.split("&")) {
const idx = pair.indexOf("=");
if (idx > 0) bodyForm.push(kv(pair.slice(0, idx), pair.slice(idx + 1)));
}
}
} else if (mime.includes("xml")) {
bodyMode = "xml";
bodyRaw = text;
} else if (mime.includes("multipart")) {
bodyMode = "form-data";
for (const p of asArr(postData.params)) {
const pObj = asObj(p);
if (pObj.name && str(pObj.fileName) === "") bodyForm.push(kv(str(pObj.name), str(pObj.value)));
}
} else if (mime) {
bodyMode = "text";
bodyRaw = text;
}
const pathname = decodeURIComponent(u.pathname) || "/";
const node = createRequest("http", `${method} ${pathname}`, {
data: {
kind: "http",
method,
url: `${u.origin}${u.pathname}`,
query,
headers,
cookies,
auth: { mode: "none" },
bodyMode,
bodyRaw,
bodyForm,
},
});
folder.children.push(node);
count++;
}
const children: CollectionNode[] = Array.from(byHost.values());
const collection = createCollection("HAR 导入", children);
collection.description = `从 HAR 捕获导入 ${count} 个请求`;
if (count === 0) warnings.push("HAR 里没有可导入的 http(s) 请求");
return { collections: [collection], environmentVariables: [], warnings };
}
function looksLikeJson(s: string): boolean {
const t = s.trimStart();
return t.startsWith("{") || t.startsWith("[");
}
function formatJson(s: string): string {
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return s;
}
}
+135
View File
@@ -0,0 +1,135 @@
/**
* Hoppscotch 集合导入器(数组或单对象,folders/requests 递归)。
*/
import type { Collection, CollectionNode, FolderNode, HttpRequestData } from "../model";
import { createCollection, createFolder, createRequest, kv } from "../factory";
import type { ImportResult } from "../model";
import { normalizeMethod } from "./curl";
type Obj = Record<string, unknown>;
const asObj = (v: unknown): Obj => (v && typeof v === "object" ? (v as Obj) : {});
const asArr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
export function isHoppscotchCollection(doc: unknown): boolean {
const list = Array.isArray(doc) ? doc.map(asObj) : [asObj(doc)];
return list.some((c) => Array.isArray(c.requests) || Array.isArray(c.folders));
}
export function importHoppscotch(text: string): ImportResult {
const parsed: unknown = JSON.parse(text);
const roots = Array.isArray(parsed) ? parsed.map(asObj) : [asObj(parsed)];
if (!isHoppscotchCollection(parsed)) throw new Error("不是 Hoppscotch 集合(缺少 requests/folders)");
const warnings: string[] = [];
const results: Collection[] = [];
for (const root of roots) {
if (!Array.isArray(root.requests) && !Array.isArray(root.folders)) continue;
const children = convertFolder(root, warnings);
const collection = createCollection(str(root.name) || "Hoppscotch 导入", children);
results.push(collection);
}
if (results.length === 0) warnings.push("没有可导入的集合");
return { collections: results, environmentVariables: [], warnings };
}
function convertFolder(folder: Obj, warnings: string[]): CollectionNode[] {
const out: CollectionNode[] = [];
for (const f of asArr(folder.folders)) {
const fObj = asObj(f);
out.push(createFolder(str(fObj.name) || "目录", convertFolder(fObj, warnings)));
}
for (const r of asArr(folder.requests)) {
out.push(convertRequest(asObj(r), warnings));
}
return out;
}
function convertRequest(r: Obj, warnings: string[]): CollectionNode {
const method = normalizeMethod(str(r.method) || "GET");
const url = str(r.url) + str(r.path === "/" || r.path === "" ? "" : str(r.path));
const headers: HttpRequestData["headers"] = [];
for (const h of asArr(r.headers)) {
const hObj = asObj(h);
if (hObj.key) headers.push(kv(str(hObj.key), str(hObj.value), hObj.active !== false));
}
const query: HttpRequestData["query"] = [];
for (const p of asArr(r.params)) {
const pObj = asObj(p);
if (pObj.key) query.push(kv(str(pObj.key), str(pObj.value), pObj.active !== false));
}
const bodyModeRaw = str(r.bodyMode ?? r.requestBodyMode ?? "none");
let bodyMode: HttpRequestData["bodyMode"] = "none";
let bodyRaw = "";
let bodyForm: HttpRequestData["bodyForm"] = [];
if (bodyModeRaw === "raw" || bodyModeRaw === "application/json" || bodyModeRaw === "application/xml" || bodyModeRaw === "text/plain") {
bodyRaw = str(r.rawRequestBody ?? r.rawParams ?? "");
bodyMode = bodyModeRaw.includes("xml") ? "xml" : looksLikeJson(bodyRaw) ? "json" : "text";
} else if (bodyModeRaw === "urlencoded" || bodyModeRaw === "application/x-www-form-urlencoded") {
bodyMode = "urlencoded";
for (const p of asArr(r.bodyParams ?? r.params)) {
const pObj = asObj(p);
if (pObj.key) bodyForm.push(kv(str(pObj.key), str(pObj.value), pObj.active !== false));
}
} else if (bodyModeRaw === "formdata" || bodyModeRaw === "multipart/form-data") {
bodyMode = "form-data";
for (const p of asArr(r.bodyParams)) {
const pObj = asObj(p);
if (pObj.key && !str(pObj.value).startsWith("@file")) bodyForm.push(kv(str(pObj.key), str(pObj.value), pObj.active !== false));
}
}
let auth: HttpRequestData["auth"] = { mode: "none" };
const authType = str(r.auth);
if (authType === "bearer") auth = { mode: "bearer", bearer: str(r.bearerToken ?? r.httpUser) };
if (authType === "basic")
auth = { mode: "basic", basicUser: str(r.httpUser), basicPass: str(r.httpPassword) };
if (authType === "oauth2") auth = { mode: "bearer", bearer: str(r.oauth2Token ?? "") };
if (r.graphqlQuery !== undefined || str(r.requestType).includes("graphql")) {
return createRequest("graphql", str(r.name) || "GraphQL", {
data: {
kind: "graphql",
url,
headers,
auth,
query: str(r.graphqlQuery) || "query {\n \n}",
variables: safeStringify(r.graphqlVariables) || "{}",
},
});
}
return createRequest("http", str(r.name) || `${method} 请求`, {
data: {
kind: "http",
method,
url,
query,
headers,
cookies: [],
auth,
bodyMode,
bodyRaw,
bodyForm,
},
});
}
function looksLikeJson(s: string): boolean {
const t = s.trimStart();
return t.startsWith("{") || t.startsWith("[");
}
function safeStringify(v: unknown): string {
if (typeof v === "string") return v;
try {
return JSON.stringify(v, null, 2) ?? "";
} catch {
return "";
}
}
+138
View File
@@ -0,0 +1,138 @@
/**
* 导入入口:格式自动识别 + 统一 importWorkspaceData()。
*/
import type { Collection, ImportResult } from "../model";
import { importCurl, tokenizeCurl, parseCurl } from "./curl";
import { importOpenApi, isOpenApiDocument, parseSpecDocument } from "./openapi";
import { importPostman, isPostmanCollection } from "./postman";
import { importHar, isHarDocument } from "./har";
import { importInsomnia, isInsomniaExport } from "./insomnia";
import { importHoppscotch, isHoppscotchCollection } from "./hoppscotch";
import { importApipost, isApipostExport } from "./apipost";
import { importMarkdown, looksLikeMarkdown } from "./markdown";
export type ImportFormat =
| "openapi"
| "postman"
| "curl"
| "har"
| "insomnia"
| "hoppscotch"
| "apipost"
| "markdown"
| "backup"
| "unknown";
export const FORMAT_LABELS: Record<ImportFormat, string> = {
openapi: "OpenAPI / Swagger / Apifox",
postman: "Postman Collection",
curl: "cURL 命令",
har: "HAR 抓包文件",
insomnia: "Insomnia 导出",
hoppscotch: "Hoppscotch 集合",
apipost: "Apipost 项目",
markdown: "Markdown 文档",
backup: "OpsxAPI 备份",
unknown: "未知格式",
};
export function detectImportFormat(text: string): ImportFormat {
const trimmed = text.trim();
if (/^(curl|wget)\b/.test(trimmed) || (trimmed.startsWith("npx ") && trimmed.includes(" curl"))) {
return "curl";
}
let json: unknown = null;
let isJson = false;
try {
json = JSON.parse(trimmed);
isJson = true;
} catch {
isJson = false;
}
if (isJson && json && typeof json === "object") {
const doc = json as Record<string, unknown>;
if (isOpenApiDocument(doc)) return "openapi";
if (isPostmanCollection(doc)) return "postman";
if (isHarDocument(doc)) return "har";
if (isInsomniaExport(doc)) return "insomnia";
if (isApipostExport(doc)) return "apipost";
if (isHoppscotchCollection(json)) return "hoppscotch";
if (isOpsxBackup(doc)) return "backup";
// 数组也可能是 hoppscotch
if (Array.isArray(json) && isHoppscotchCollection(json)) return "hoppscotch";
}
// YAML(OpenAPI 常见)
if (!isJson && /^(openapi|swagger)\s*:/m.test(trimmed)) {
try {
const doc = parseSpecDocument(trimmed);
if (isOpenApiDocument(doc)) return "openapi";
} catch {
/* fallthrough */
}
}
if (looksLikeMarkdown(trimmed)) return "markdown";
return "unknown";
}
export function isOpsxBackup(doc: Record<string, unknown>): boolean {
return doc.__opsxapi__ === "backup" || (doc.version === 2 && Array.isArray(doc.collections));
}
export function importByFormat(text: string, format: ImportFormat): ImportResult {
switch (format) {
case "openapi":
return importOpenApi(text);
case "postman":
return importPostman(text);
case "curl":
return { collections: [importCurl(text)], environmentVariables: [], warnings: [] };
case "har":
return importHar(text);
case "insomnia":
return importInsomnia(text);
case "hoppscotch":
return importHoppscotch(text);
case "apipost":
return importApipost(text);
case "markdown":
return importMarkdown(text);
case "backup":
return importBackup(text);
case "unknown": {
const detected = detectImportFormat(text);
if (detected !== "unknown") return importByFormat(text, detected);
throw new Error("无法识别格式。支持:OpenAPI/Swagger、Postman、Apifox、Apipost、cURL、HAR、Insomnia、Hoppscotch、Markdown、OpsxAPI 备份。");
}
}
}
/** 自动识别并导入 */
export function importAuto(text: string): ImportResult {
const format = detectImportFormat(text);
return importByFormat(text, format);
}
export function importBackup(text: string): ImportResult {
const doc = JSON.parse(text) as { collections?: Collection[] };
if (!Array.isArray(doc.collections)) throw new Error("备份文件缺少 collections");
return {
collections: doc.collections,
environmentVariables: [],
warnings: [],
};
}
export { tokenizeCurl, parseCurl, importCurl };
export { importOpenApi, isOpenApiDocument, parseSpecDocument };
export { importPostman, isPostmanCollection };
export { importHar, isHarDocument };
export { importInsomnia, isInsomniaExport };
export { importHoppscotch, isHoppscotchCollection };
export { importApipost, isApipostExport };
export { importMarkdown, looksLikeMarkdown };
+203
View File
@@ -0,0 +1,203 @@
/**
* Insomnia 导出文件(export_format v3/v4)导入器。
*/
import type { Collection, CollectionNode, FolderNode, HttpRequestData } from "../model";
import { createCollection, createFolder, createRequest, kv } from "../factory";
import type { ImportResult } from "../model";
import { normalizeMethod } from "./curl";
type Obj = Record<string, unknown>;
const asObj = (v: unknown): Obj => (v && typeof v === "object" ? (v as Obj) : {});
const asArr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
export function isInsomniaExport(doc: Obj): boolean {
if (str(doc._type) === "export" && Array.isArray(doc.resources)) return true;
// v4 嵌套格式:resources 里是 workspace
return Array.isArray(doc.resources) && asArr(doc.resources).some((r) => asObj(r)._type === "workspace");
}
export function importInsomnia(text: string): ImportResult {
const doc = asObj(JSON.parse(text));
if (!isInsomniaExport(doc)) throw new Error("不是 Insomnia 导出文件(缺少 resources)");
const warnings: string[] = [];
const resources = asArr(doc.resources).map(asObj);
const workspaces = resources.filter((r) => str(r._type) === "workspace");
const results: Collection[] = [];
for (const ws of workspaces) {
const scopeIds = new Set<string>([str(ws._id)]);
// 找出属于该 workspace 的所有 resource(递归 parentId)
const byParent = new Map<string, Obj[]>();
for (const r of resources) {
if (str(r._type) === "workspace") continue;
const parent = str(r.parentId);
if (!byParent.has(parent)) byParent.set(parent, []);
byParent.get(parent)!.push(r);
}
const children = collectChildren(scopeIds, byParent, warnings);
const collection = createCollection(str(ws.name) || "Insomnia 导入", children);
results.push(collection);
}
if (results.length === 0) {
// 无 workspace(v3):全部平铺
const byParent = new Map<string, Obj[]>();
for (const r of resources) {
if (str(r._type) === "workspace") continue;
const parent = str(r.parentId) || "__root__";
if (!byParent.has(parent)) byParent.set(parent, []);
byParent.get(parent)!.push(r);
}
const children = collectChildren(new Set(["__root__"]), byParent, warnings);
results.push(createCollection("Insomnia 导入", children));
}
let total = 0;
for (const c of results) total += countNodes(c.children);
if (total === 0) warnings.push("没有可导入的请求");
return { collections: results, environmentVariables: [], warnings };
}
function collectChildren(
parentIds: Set<string>,
byParent: Map<string, Obj[]>,
warnings: string[],
): CollectionNode[] {
const out: CollectionNode[] = [];
for (const id of parentIds) {
for (const r of byParent.get(id) ?? []) {
const type = str(r._type);
if (type === "request_group") {
const folder = createFolder(str(r.name) || "目录", collectChildren(new Set([str(r._id)]), byParent, warnings));
if (r.description) folder.description = str(r.description);
out.push(folder);
} else if (type === "request") {
out.push(convertRequest(r, warnings));
}
}
}
return out;
}
function convertRequest(r: Obj, warnings: string[]): CollectionNode {
const url = str(r.url) || str(asObj(r.url).raw) || "";
const method = normalizeMethod(str(r.method) || "GET");
const headers: HttpRequestData["headers"] = [];
for (const h of asArr(r.headers)) {
const hObj = asObj(h);
if (hObj.name && str(hObj.name).toLowerCase() !== "cookie") {
headers.push(kv(str(hObj.name), str(hObj.value), hObj.disabled !== true));
}
}
const query: HttpRequestData["query"] = [];
let baseUrl = url;
for (const p of asArr(r.parameters)) {
const pObj = asObj(p);
if (pObj.name) query.push(kv(str(pObj.name), str(pObj.value), pObj.disabled !== true));
}
const body = asObj(r.body);
const mime = str(body.mimeType);
let bodyMode: HttpRequestData["bodyMode"] = "none";
let bodyRaw = "";
let bodyForm: HttpRequestData["bodyForm"] = [];
if (mime.includes("graphql")) {
const gqlText = typeof body.text === "string" ? body.text : "";
const gql = asObj(gqlText ? safeJsonParse(gqlText) : {});
return createRequest("graphql", str(r.name) || "GraphQL", {
data: {
kind: "graphql",
url: baseUrl,
headers,
auth: authFromInsomnia(asObj(r.authentication)),
query: str(gql.query) || "query {\n \n}",
variables: safeStringify(gql.variables) || "{}",
},
});
}
if (mime.includes("urlencoded")) {
bodyMode = "urlencoded";
for (const p of asArr(body.params)) {
const pObj = asObj(p);
if (pObj.name) bodyForm.push(kv(str(pObj.name), str(pObj.value), pObj.disabled !== true));
}
} else if (mime.includes("form-data") || mime.includes("multipart")) {
bodyMode = "form-data";
for (const p of asArr(body.params)) {
const pObj = asObj(p);
if (pObj.name && !pObj.file) bodyForm.push(kv(str(pObj.name), str(pObj.value), pObj.disabled !== true));
}
} else if (mime.includes("json")) {
bodyMode = "json";
bodyRaw = formatJson(str(body.text));
} else if (mime) {
bodyMode = mime.includes("xml") ? "xml" : mime.includes("html") ? "html" : "text";
bodyRaw = str(body.text);
}
return createRequest("http", str(r.name) || `${method} 请求`, {
data: {
kind: "http",
method,
url: baseUrl,
query,
headers,
cookies: [],
auth: authFromInsomnia(asObj(r.authentication)),
bodyMode,
bodyRaw,
bodyForm,
},
description: str(r.description) || undefined,
});
}
function authFromInsomnia(auth: Obj): HttpRequestData["auth"] {
const type = str(auth.type);
if (type === "bearer") return { mode: "bearer", bearer: str(auth.token) };
if (type === "basic") return { mode: "basic", basicUser: str(auth.username), basicPass: str(auth.password) };
if (type === "apikey" || type === "api_key")
return {
mode: "apikey",
apiKeyName: str(auth.key),
apiKeyValue: str(auth.value),
apiKeyIn: str(auth.in) === "query" ? "query" : "header",
};
return { mode: "none" };
}
function safeJsonParse(s: string): unknown {
try {
return JSON.parse(s);
} catch {
return {};
}
}
function safeStringify(v: unknown): string {
try {
return JSON.stringify(v, null, 2) ?? "";
} catch {
return "";
}
}
function formatJson(s: string): string {
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return s;
}
}
function countNodes(nodes: CollectionNode[]): number {
let n = 0;
for (const node of nodes) {
if (node.type === "request") n++;
else n += countNodes(node.children);
}
return n;
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Markdown API 文档导入器:提取 curl 代码块(及可选 JSON 请求体示例)。
*/
import type { Collection } from "../model";
import { createCollection, createFolder } from "../factory";
import type { ImportResult } from "../model";
import { importCurl } from "./curl";
export function looksLikeMarkdown(text: string): boolean {
const t = text.trimStart();
if (!t.startsWith("#") && !t.includes("```")) return false;
return /^#{1,6}\s/m.test(text) || /```/.test(text);
}
export function importMarkdown(text: string): ImportResult {
const warnings: string[] = [];
const blocks: { heading: string; code: string }[] = [];
const lines = text.split(/\r?\n/);
let heading = "";
let inBlock = false;
let lang = "";
let buf: string[] = [];
for (const line of lines) {
const fence = line.match(/^\s*```(\w*)\s*$/);
if (fence) {
if (!inBlock) {
inBlock = true;
lang = fence[1].toLowerCase();
buf = [];
} else {
inBlock = false;
const code = buf.join("\n");
if (["curl", "bash", "sh", "shell", ""].includes(lang) && code.trim().startsWith("curl")) {
blocks.push({ heading: heading || `curl ${blocks.length + 1}`, code });
}
}
continue;
}
if (inBlock) {
buf.push(line);
continue;
}
const h = line.match(/^#{1,6}\s+(.*)$/);
if (h) heading = h[1].trim();
}
if (blocks.length === 0) {
warnings.push("未在 Markdown 中找到 curl 代码块(也支持直接粘贴 cURL 导入)");
}
const folders = new Map<string, ReturnType<typeof createFolder>>();
const rootChildren = [];
for (const block of blocks) {
try {
const temp = importCurl(block.code, "temp");
const node = temp.children[0];
if (!node) continue;
// 一级标题做目录,二级及以下做请求名
const parts = block.heading.split(/[::]/);
const folderName = parts[0]?.trim();
if (folderName && folders.has(folderName)) {
folders.get(folderName)!.children.push(node);
} else if (folderName) {
const f = createFolder(folderName, [node]);
folders.set(folderName, f);
} else {
rootChildren.push(node);
}
} catch (e) {
warnings.push(`解析代码块失败:${e instanceof Error ? e.message : String(e)}`);
}
}
const collection = createCollection(
firstHeading(text) || "Markdown 导入",
[...folders.values(), ...rootChildren],
);
return { collections: [collection], environmentVariables: [], warnings };
}
function firstHeading(text: string): string {
const m = text.match(/^#\s+(.+)$/m);
return m ? m[1].trim().slice(0, 60) : "";
}
+425
View File
@@ -0,0 +1,425 @@
/**
* OpenAPI 3.1 / 3.0 / Swagger 2.0 导入器(JSON 或 YAML;Apifox 导出的 OpenAPI 格式同样适用)。
*/
import { load as yamlLoad } from "js-yaml";
import type {
Collection,
CollectionNode,
FolderNode,
HttpRequestData,
ApiDocumentation,
RequestMethod,
} from "../model";
import { createCollection, createFolder, createRequest, kv } from "../factory";
import type { ImportResult } from "../model";
type Obj = Record<string, unknown>;
const asObj = (v: unknown): Obj => (v && typeof v === "object" ? (v as Obj) : {});
const asArr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
const str = (v: unknown): string => (typeof v === "string" ? v : v === undefined || v === null ? "" : String(v));
const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"];
export function parseSpecDocument(text: string): Obj {
const trimmed = text.trim();
if (trimmed.startsWith("{")) {
return asObj(JSON.parse(trimmed));
}
return asObj(yamlLoad(trimmed));
}
export function isOpenApiDocument(doc: Obj): boolean {
return "openapi" in doc || "swagger" in doc;
}
export function importOpenApi(text: string): ImportResult {
const doc = parseSpecDocument(text);
if (!isOpenApiDocument(doc)) throw new Error("不是 OpenAPI/Swagger 文档(缺少 openapi/swagger 字段)");
const version = str(doc.openapi || doc.swagger);
const info = asObj(doc.info);
const warnings: string[] = [];
const baseUrl = resolveBaseUrl(doc);
const envVars: { key: string; value: string }[] = [];
if (baseUrl) envVars.push({ key: "baseUrl", value: baseUrl });
const securitySchemes = asObj(
version.startsWith("2")
? doc.securityDefinitions
: asObj(doc.components).securitySchemes,
);
// tag → folder
const tagOrder: string[] = [];
const tagDesc: Record<string, string> = {};
for (const t of asArr(doc.tags)) {
const tag = asObj(t);
if (tag.name) {
tagOrder.push(str(tag.name));
if (tag.description) tagDesc[str(tag.name)] = str(tag.description);
}
}
const folderMap = new Map<string, FolderNode>();
const rootChildren: CollectionNode[] = [];
const ensureFolder = (tag: string): FolderNode | null => {
if (!tag) return null;
let f = folderMap.get(tag);
if (!f) {
f = createFolder(tag, []);
if (tagDesc[tag]) f.description = tagDesc[tag];
folderMap.set(tag, f);
}
return f;
};
const paths = asObj(doc.paths);
let count = 0;
for (const [path, pathItemRaw] of Object.entries(paths)) {
const pathItem = asObj(pathItemRaw);
for (const method of HTTP_METHODS) {
const opRaw = pathItem[method];
if (!opRaw) continue;
const op = asObj(opRaw);
count++;
const node = buildOperationNode({
method: method.toUpperCase() as RequestMethod,
path,
op,
doc,
version,
securitySchemes,
baseUrl,
envVars,
warnings,
name: str(op.summary) || str(op.operationId) || `${method.toUpperCase()} ${path}`,
});
const tags = asArr(op.tags).map(str).filter(Boolean);
const folder = tags.length ? ensureFolder(tags[0]) : null;
if (folder) folder.children.push(node);
else rootChildren.push(node);
}
}
if (count === 0) warnings.push("文档中没有可导入的路径操作");
const children: CollectionNode[] = [
...tagOrder.filter((t) => folderMap.has(t)).map((t) => folderMap.get(t)!),
...Array.from(folderMap.values()).filter((f) => !tagOrder.includes(f.name)),
...rootChildren,
];
const collection = createCollection(str(info.title) || "OpenAPI 导入", children);
collection.description = str(info.description) || undefined;
return { collections: [collection], environmentVariables: envVars, warnings };
}
function resolveBaseUrl(doc: Obj): string {
// v3: servers[]; v2: host + basePath + schemes
const servers = asArr(doc.servers);
if (servers.length) {
const first = asObj(servers[0]);
let url = str(first.url);
const vars = asObj(first.variables);
for (const [k, v] of Object.entries(vars)) {
const def = asObj(v).default;
if (def !== undefined) url = url.replaceAll(`{${k}}`, str(def));
}
if (url) return url.replace(/\/$/, "");
}
const host = str(doc.host);
if (host) {
const schemes = asArr(doc.schemes).map(str);
const scheme = schemes.includes("https") ? "https" : schemes[0] || "https";
const basePath = str(doc.basePath) || "";
return `${scheme}://${host}${basePath}`.replace(/\/$/, "");
}
return "";
}
function buildOperationNode(ctx: {
method: RequestMethod;
path: string;
op: Obj;
doc: Obj;
version: string;
securitySchemes: Obj;
baseUrl: string;
envVars: { key: string; value: string }[];
warnings: string[];
name: string;
}): CollectionNode {
const { method, path, op, doc, version, securitySchemes, baseUrl, envVars, warnings } = ctx;
// path 参数 {id} → {{id}}
let urlPath = path;
const pathParams: { name: string; example: string }[] = [];
for (const p of [...asArr(asObj(asObj(doc.paths)[path]).parameters), ...asArr(op.parameters)]) {
const param = resolveRef(asObj(p), doc);
if (str(param.in) === "path" && param.name) {
pathParams.push({ name: str(param.name), example: exampleOf(param) || "1" });
}
}
for (const { name, example } of pathParams) {
urlPath = urlPath.replaceAll(`{${name}}`, `{{${name}}}`);
if (!envVars.some((v) => v.key === name)) envVars.push({ key: name, value: example });
}
// 统一使用 {{baseUrl}} 前缀(服务器地址写入环境变量),保持环境切换能力
const url = `{{baseUrl}}${urlPath.startsWith("/") ? urlPath : `/${urlPath.replace(/^\{\{baseUrl\}\}?\/?/, "/")}`}`;
const query: HttpRequestData["query"] = [];
const headers: HttpRequestData["headers"] = [];
let auth: HttpRequestData["auth"] = { mode: "none" };
for (const p of asArr(op.parameters)) {
const param = resolveRef(asObj(p), doc);
if (!param.name) continue;
const in_ = str(param.in);
const row = kv(str(param.name), exampleOf(param), true, str(param.description));
if (in_ === "query") query.push(row);
else if (in_ === "header") headers.push(row);
}
// v2 顶层 parameters(path 级)
const pathLevel = asArr(asObj(asObj(doc.paths)[path]).parameters);
for (const p of pathLevel) {
const param = resolveRef(asObj(p), doc);
if (!param.name) continue;
const in_ = str(param.in);
if (in_ === "query") query.push(kv(str(param.name), exampleOf(param), true, str(param.description)));
else if (in_ === "header") headers.push(kv(str(param.name), exampleOf(param), true, str(param.description)));
}
// 鉴权
const securities = asArr(op.security ?? doc.security);
if (securities.length) {
const first = asObj(securities[0]);
const schemeName = Object.keys(first)[0];
if (schemeName && securitySchemes[schemeName] !== undefined) {
const scheme = asObj(securitySchemes[schemeName]);
const type = str(scheme.type);
if (version.startsWith("2")) {
if (type === "basic") auth = { mode: "basic", basicUser: "", basicPass: "" };
else if (type === "apiKey")
auth = {
mode: "apikey",
apiKeyName: str(scheme.name),
apiKeyIn: str(scheme.in) === "query" ? "query" : "header",
apiKeyValue: "",
};
} else {
if (type === "http" && str(scheme.scheme) === "bearer") auth = { mode: "bearer", bearer: "" };
else if (type === "http" && str(scheme.scheme) === "basic") auth = { mode: "basic", basicUser: "", basicPass: "" };
else if (type === "apiKey")
auth = {
mode: "apikey",
apiKeyName: str(scheme.name),
apiKeyIn: str(scheme.in) === "query" ? "query" : "header",
apiKeyValue: "",
};
else if (type === "oauth2") auth = { mode: "bearer", bearer: "" };
}
}
}
// 请求体
let bodyMode: HttpRequestData["bodyMode"] = "none";
let bodyRaw = "";
let bodyForm: HttpRequestData["bodyForm"] = [];
if (version.startsWith("2")) {
for (const p of [...asArr(op.parameters), ...pathLevel]) {
const param = resolveRef(asObj(p), doc);
if (str(param.in) === "body") {
const consumes = asArr(op.consumes).map(str);
const mime = consumes[0] ?? "application/json";
const example = schemaToExample(asObj(param.schema), doc, 0);
if (mime.includes("json")) {
bodyMode = "json";
bodyRaw = safeStringify(example);
} else if (mime.includes("xml")) {
bodyMode = "xml";
bodyRaw = safeStringify(example);
} else {
bodyMode = "text";
bodyRaw = safeStringify(example);
}
}
if (str(param.in) === "formData") {
bodyMode = asArr(op.consumes).map(str)[0]?.includes("urlencoded") ? "urlencoded" : "form-data";
bodyForm.push(kv(str(param.name), exampleOf(param), true, str(param.description)));
}
}
} else {
const requestBody = resolveRef(asObj(op.requestBody), doc);
const content = asObj(requestBody.content);
const mimes = Object.keys(content);
if (mimes.length) {
const mime =
mimes.find((m) => m.includes("json")) ??
mimes.find((m) => m.includes("xml")) ??
mimes.find((m) => m.includes("urlencoded")) ??
mimes.find((m) => m.includes("form-data")) ??
mimes.find((m) => m.includes("text")) ??
mimes[0];
const media = asObj(content[mime]);
if (mime.includes("urlencoded")) {
bodyMode = "urlencoded";
const schema = asObj(media.schema);
for (const [k, v] of Object.entries(asObj(schema.properties))) {
bodyForm.push(kv(k, schemaFieldExample(asObj(v)), true, str(asObj(v).description)));
}
if (media.example) bodyRaw = safeStringify(media.example);
} else if (mime.includes("form-data") || mime.includes("multipart")) {
bodyMode = "form-data";
const schema = asObj(media.schema);
for (const [k, v] of Object.entries(asObj(schema.properties))) {
bodyForm.push(kv(k, schemaFieldExample(asObj(v)), true, str(asObj(v).description)));
}
} else {
const example =
media.example !== undefined
? media.example
: firstExampleObject(media.examples) ?? schemaToExample(asObj(media.schema), doc, 0);
if (mime.includes("xml")) {
bodyMode = "xml";
bodyRaw = str(example);
} else if (mime.includes("text") || mime.includes("html")) {
bodyMode = mime.includes("html") ? "html" : "text";
bodyRaw = str(example);
} else {
bodyMode = "json";
bodyRaw = safeStringify(example);
}
}
}
}
if (bodyMode !== "none" && !headers.some((h) => h.key.toLowerCase() === "content-type")) {
if (bodyMode === "json") headers.unshift(kv("Content-Type", "application/json"));
if (bodyMode === "xml") headers.unshift(kv("Content-Type", "application/xml"));
if (bodyMode === "urlencoded")
headers.unshift(kv("Content-Type", "application/x-www-form-urlencoded"));
}
const descriptionParts: string[] = [];
if (op.description) descriptionParts.push(str(op.description));
if (op.deprecated === true) descriptionParts.push("> ⚠️ 已废弃(deprecated)");
const node = createRequest("http", ctx.name.slice(0, 120), {
data: {
kind: "http",
method,
url,
query,
headers,
cookies: [],
auth,
bodyMode,
bodyRaw,
bodyForm,
},
description: descriptionParts.join("\n\n") || undefined,
});
const parameters = [...pathLevel, ...asArr(op.parameters)].map((raw) => resolveRef(asObj(raw), doc)).filter((p) => p.name && str(p.in) !== "body" && str(p.in) !== "formData");
const requestBody = version.startsWith("2")
? asObj([...asArr(op.parameters), ...pathLevel].map((p) => resolveRef(asObj(p), doc)).find((p) => p.in === "body"))
: resolveRef(asObj(op.requestBody), doc);
const bodyContent = asObj(requestBody.content);
const bodyMime = Object.keys(bodyContent)[0] || (bodyMode === "json" ? "application/json" : "text/plain");
const bodyMedia = asObj(bodyContent[bodyMime]);
const responses = Object.entries(asObj(op.responses)).map(([status, raw]) => {
const response = resolveRef(asObj(raw), doc);
const content = asObj(response.content);
const contentType = Object.keys(content).find((mime) => mime.includes("json")) || Object.keys(content)[0] || str(asArr(op.produces)[0]) || "application/json";
const media = asObj(content[contentType]);
const schema = media.schema ?? response.schema;
const example = media.example ?? firstExampleObject(media.examples) ?? asObj(response.examples)[contentType] ?? (schema ? schemaToExample(asObj(schema), doc, 0) : undefined);
return { status, description: str(response.description), contentType, example: example === undefined ? "" : contentType.includes("json") ? JSON.stringify(example, null, 2) : str(example), schema };
});
const documentation: ApiDocumentation = {
operationId: str(op.operationId) || undefined,
tags: asArr(op.tags).map(str),
parameters: parameters.map((p) => ({ name: str(p.name), in: str(p.in), required: p.required === true, description: str(p.description), type: str(asObj(p.schema).type || p.type || asObj(resolveRef(asObj(p.schema), doc)).type), example: exampleOf(p) })),
requestBody: bodyMode === "none" ? undefined : { contentType: bodyMime, description: str(requestBody.description), example: bodyRaw, schema: bodyMedia.schema ?? requestBody.schema },
responses,
};
return { ...node, documentation };
}
function exampleOf(param: Obj): string {
if (param.example !== undefined) return str(param.example);
const ex = asObj(param.examples);
const first = Object.values(ex)[0];
if (first !== undefined) return str(asObj(first).value);
const schema = asObj(param.schema);
if (schema.default !== undefined) return str(schema.default);
if (schema.example !== undefined) return str(schema.example);
const t = str(schema.type);
if (t === "integer" || t === "number") return "1";
if (t === "boolean") return "true";
if (Array.isArray(schema.enum) && schema.enum.length) return str(schema.enum[0]);
return "";
}
function firstExampleObject(examples: unknown): unknown {
const first = Object.values(asObj(examples))[0];
return asObj(first).value;
}
function schemaFieldExample(field: Obj): string {
if (field.example !== undefined) return str(field.example);
if (field.default !== undefined) return str(field.default);
if (Array.isArray(field.enum) && field.enum.length) return str(field.enum[0]);
const t = str(field.type);
if (t === "integer" || t === "number") return "0";
if (t === "boolean") return "true";
return "";
}
/** 从 JSON Schema 生成示例值(解析 $ref,深度封顶) */
export function schemaToExample(schema: Obj, doc: Obj, depth: number): unknown {
if (!schema || depth > 6) return null;
if (schema.example !== undefined) return schema.example;
if (schema.default !== undefined) return schema.default;
if (Array.isArray(schema.enum) && schema.enum.length) return schema.enum[0];
const resolved = resolveRef(schema, doc);
if (resolved !== schema) return schemaToExample(resolved, doc, depth + 1);
const t = str(schema.type);
if (t === "object" || schema.properties) {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(asObj(schema.properties))) {
out[k] = schemaToExample(asObj(v), doc, depth + 1);
}
return out;
}
if (t === "array" || schema.items) {
return [schemaToExample(asObj(schema.items), doc, depth + 1)];
}
if (t === "integer" || t === "number") return 0;
if (t === "boolean") return true;
return "";
}
function resolveRef(schema: Obj, doc: Obj): Obj {
const ref = str(schema.$ref);
if (!ref) return schema;
// #/components/schemas/X 或 #/definitions/X
const parts = ref.replace(/^#\//, "").split("/");
let cur: unknown = doc;
for (const part of parts) {
cur = asObj(cur)[part];
if (cur === undefined) return schema;
}
return asObj(cur);
}
function safeStringify(v: unknown): string {
if (typeof v === "string") return v;
try {
return JSON.stringify(v, null, 2) ?? "";
} catch {
return "";
}
}
+228
View File
@@ -0,0 +1,228 @@
/**
* Postman Collection v2.0/v2.1 导入器。
*/
import type {
Collection,
CollectionNode,
FolderNode,
HttpRequestData,
RequestMethod,
} from "../model";
import { createCollection, createFolder, createRequest, kv } from "../factory";
import type { ImportResult } from "../model";
import { normalizeMethod } from "./curl";
type Obj = Record<string, unknown>;
const asObj = (v: unknown): Obj => (v && typeof v === "object" ? (v as Obj) : {});
const asArr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
export function isPostmanCollection(doc: Obj): boolean {
const info = asObj(doc.info);
const schema = str(info.schema);
return schema.includes("postman") || Array.isArray(doc.item) || info._postman_id !== undefined;
}
export function importPostman(text: string): ImportResult {
const doc = asObj(JSON.parse(text));
if (!isPostmanCollection(doc)) throw new Error("不是 Postman Collection(缺少 item/info.schema)");
const warnings: string[] = [];
const envVars: { key: string; value: string }[] = [];
const root = createFolder("root", []);
convertItems(asArr(doc.item), root, envVars, warnings);
// collection.variable → 环境变量
for (const v of asArr(doc.variable)) {
const varObj = asObj(v);
const key = str(varObj.key);
if (key) envVars.push({ key, value: str(varObj.value) });
}
const info = asObj(doc.info);
const collection = createCollection(str(info.name) || "Postman 导入", root.children);
collection.description = str(info.description) || undefined;
let count = 0;
const countReqs = (nodes: CollectionNode[]) => {
for (const n of nodes) {
if (n.type === "request") count++;
else countReqs(n.children);
}
};
countReqs(collection.children);
if (count === 0) warnings.push("集合里没有请求");
return { collections: [collection], environmentVariables: dedupeVars(envVars), warnings };
}
function convertItems(
items: unknown[],
folder: FolderNode,
envVars: { key: string; value: string }[],
warnings: string[],
): void {
for (const itemRaw of items) {
const item = asObj(itemRaw);
if (Array.isArray(item.item)) {
const sub = createFolder(str(item.name) || "目录", []);
if (item.description) sub.description = str(item.description);
folder.children.push(sub);
convertItems(item.item, sub, envVars, warnings);
continue;
}
const request = item.request;
if (request === undefined) continue;
if (typeof request === "string") {
// url-only 形式,跳过
warnings.push(`跳过仅有 URL 的 item:${str(item.name)}`);
continue;
}
folder.children.push(convertRequest(asObj(request), str(item.name) || "未命名请求", envVars));
}
}
function convertRequest(
request: Obj,
name: string,
envVars: { key: string; value: string }[],
): CollectionNode {
const urlObj = typeof request.url === "string" ? { raw: request.url } : asObj(request.url);
// URL:raw 优先;query 行会拼进 raw,因此从 raw 里剥掉 query 后再拼 query 行
let raw = str(urlObj.raw);
let hostPath = raw;
if (!raw) {
const host = asArr(urlObj.host).map((s) => str(s)).join(".");
const path = asArr(urlObj.path).map((s) => str(s)).join("/");
const protocol = str(urlObj.protocol) || "https";
const port = str(urlObj.port) ? `:${str(urlObj.port)}` : "";
hostPath = `${protocol}://${host}${port}${path ? `/${path}` : ""}`;
raw = hostPath;
}
const query: HttpRequestData["query"] = [];
for (const q of asArr(urlObj.query)) {
const qObj = asObj(q);
query.push(kv(str(qObj.key), str(qObj.value), qObj.disabled !== true, str(qObj.description)));
}
// raw 自带 query 时拆出来
const qMark = hostPath.indexOf("?");
let baseUrl = hostPath;
if (qMark >= 0) {
baseUrl = hostPath.slice(0, qMark);
if (query.length === 0) {
for (const pair of hostPath.slice(qMark + 1).split("&")) {
if (!pair) continue;
const idx = pair.indexOf("=");
query.push(kv(idx > 0 ? pair.slice(0, idx) : pair, idx > 0 ? pair.slice(idx + 1) : ""));
}
}
}
// path 变量 {{var}}
for (const v of asArr(urlObj.variable)) {
const varObj = asObj(v);
if (varObj.key) envVars.push({ key: str(varObj.key), value: str(varObj.value) });
}
const headers: HttpRequestData["headers"] = [];
for (const h of asArr(request.header)) {
const hObj = asObj(h);
headers.push(kv(str(hObj.key), str(hObj.value), hObj.disabled !== true));
}
const cookies: HttpRequestData["cookies"] = [];
// body
let bodyMode: HttpRequestData["bodyMode"] = "none";
let bodyRaw = "";
let bodyForm: HttpRequestData["bodyForm"] = [];
const body = asObj(request.body);
if (body.mode === "raw") {
bodyRaw = str(body.raw);
const lang = str(asObj(asObj(body.options).raw).language);
bodyMode =
lang === "json" || looksLikeJson(bodyRaw)
? "json"
: lang === "xml"
? "xml"
: lang === "html"
? "html"
: "text";
} else if (body.mode === "urlencoded") {
bodyMode = "urlencoded";
for (const p of asArr(body.urlencoded)) {
const pObj = asObj(p);
bodyForm.push(kv(str(pObj.key), str(pObj.value), pObj.disabled !== true));
}
} else if (body.mode === "formdata") {
bodyMode = "form-data";
for (const p of asArr(body.formdata)) {
const pObj = asObj(p);
if (str(pObj.type) === "file") continue;
bodyForm.push(kv(str(pObj.key), str(pObj.value), pObj.disabled !== true));
}
} else if (body.mode === "graphql") {
const g = asObj(body.graphql);
return createRequest("graphql", name, {
data: {
kind: "graphql",
url: baseUrl,
headers,
auth: authFromPostman(asObj(request.auth)),
query: str(g.query) || "query {\n \n}",
variables: str(g.variables) || "{}",
},
});
}
return createRequest("http", name, {
data: {
kind: "http",
method: normalizeMethod(str(request.method) || "GET") as RequestMethod,
url: baseUrl,
query,
headers,
cookies,
auth: authFromPostman(asObj(request.auth)),
bodyMode,
bodyRaw,
bodyForm,
},
description: str(request.description) || undefined,
});
}
function authFromPostman(auth: Obj): HttpRequestData["auth"] {
const type = str(auth.type);
const read = (key: string): string => {
for (const kvRow of asArr(auth[type])) {
const kvObj = asObj(kvRow);
if (str(kvObj.key) === key) return str(kvObj.value);
}
return "";
};
if (type === "bearer") return { mode: "bearer", bearer: read("token") };
if (type === "basic") return { mode: "basic", basicUser: read("username"), basicPass: read("password") };
if (type === "apikey")
return {
mode: "apikey",
apiKeyName: read("key"),
apiKeyValue: read("value"),
apiKeyIn: read("in") === "query" ? "query" : "header",
};
return { mode: "none" };
}
function looksLikeJson(s: string): boolean {
const t = s.trimStart();
return t.startsWith("{") || t.startsWith("[");
}
function dedupeVars(vars: { key: string; value: string }[]) {
const map = new Map<string, string>();
for (const v of vars) if (!map.has(v.key)) map.set(v.key, v.value);
return Array.from(map, ([key, value]) => ({ key, value }));
}
+170
View File
@@ -0,0 +1,170 @@
// 传输层契约(HttpSpec / WsSpec / Transport)
export type {
HttpMethod,
HttpBodyMode,
HttpSpec,
WsSpec,
HttpResult,
WsSession,
Transport,
} from "./types";
// 集合数据模型
export type {
RequestMethod,
RequestKind,
KVRow,
BodyMode,
AuthMode,
AuthConfig,
HttpRequestData,
GraphqlRequestData,
WebsocketRequestData,
SseRequestData,
SocketIoRequestData,
MqttRequestData,
RequestData,
RequestDataOf,
RequestNodeBase,
HttpApiNode,
GraphqlApiNode,
WebsocketApiNode,
SseApiNode,
SocketIoApiNode,
MqttApiNode,
RequestNode,
FolderNode,
CollectionNode,
Collection,
Environment,
HistoryEntry,
ThemeMode,
WorkspaceSettings,
TransportMode,
WorkspaceData,
ImportResult,
MockRule,
MockSettings,
MockFieldType,
MockMatchMode,
AssertTarget,
AssertOp,
TestAssertion,
VariableExtract,
TestStep,
TestCase,
AssertionResult,
TestStepResult,
TestRun,
TestRunTrigger,
TestSchedule,
AiProvider,
AiSettings,
} from "./model";
export {
REQUEST_METHODS,
REQUEST_KINDS,
KIND_LABELS,
BODY_MODE_LABELS,
RAW_BODY_MODES,
} from "./model";
// 树操作
export {
newId,
walk,
findNode,
mapTree,
replaceNode,
removeNode,
insertNode,
insertIntoCollection,
moveNode,
containsId,
deepCopyNode,
uniqueName,
countRequests,
searchTree,
} from "./tree";
export type { NodeRef } from "./tree";
// 工厂 / 种子
export {
kv,
defaultRequestData,
createRequest,
defaultName,
createFolder,
createCollection,
createEnvironment,
createSeedWorkspace,
} from "./factory";
// 变量替换
export { replaceVariables, replaceVariablesInSpec } from "./replaceVariables";
// Mock 数据
export {
generateMock,
generateMockTyped,
applyMockToValue,
applyMockToJson,
smartFillJson,
matchMockRule,
globToRegExp,
normalizeMockSettings,
DEFAULT_MOCK_RULES,
MOCK_CHEATSHEET,
} from "./mock";
// 自动化测试
export { ASSERT_OP_LABELS } from "./model";
export {
queryJsonPath,
describeAssertion,
evaluateAssertion,
suggestAssertions,
extractVariables,
exportRunMarkdown,
exportRunJson,
newAssertion,
newExtract,
newStep,
newTestCase,
runStats,
} from "./testing";
// 导入
export {
detectImportFormat,
importAuto,
importByFormat,
importBackup,
FORMAT_LABELS,
} from "./importers";
export type { ImportFormat } from "./importers";
export { parseCurl, importCurl, tokenizeCurl, normalizeMethod } from "./importers/curl";
export { importOpenApi } from "./importers/openapi";
export { importPostman } from "./importers/postman";
export { importHar } from "./importers/har";
export { importInsomnia } from "./importers/insomnia";
export { importHoppscotch } from "./importers/hoppscotch";
export { importApipost } from "./importers/apipost";
export { importMarkdown } from "./importers/markdown";
// 导出
export {
exportCollectionAs,
exportBackup,
exportOpenApiWithFolders,
exportPostman,
exportMarkdown,
downloadText,
} from "./exporters";
export type { ExportFormat, OpenApiExportOptions } from "./exporters";
export { exportCurl, shellQuote, buildUrlWithQuery, authHeader } from "./exporters/curl";
export { generateCode } from "./exporters/codegen";
export type { CodeTarget } from "./exporters/codegen";
// 定时 Cron
export { isValidCron, cronMatches, scheduleIsDue } from "./cron";
+152
View File
@@ -0,0 +1,152 @@
import { describe, expect, it } from "vitest";
import {
applyMockToJson,
applyMockToValue,
DEFAULT_MOCK_RULES,
generateMock,
generateMockTyped,
globToRegExp,
matchMockRule,
normalizeMockSettings,
smartFillJson,
} from "./mock";
const mockOn = normalizeMockSettings({ enabled: true, smart: true });
const mockOff = normalizeMockSettings({ enabled: false });
describe("generateMock 表达式", () => {
it("整数保持在区间内", () => {
for (let i = 0; i < 50; i++) {
const n = Number(generateMock("@integer(3, 7)"));
expect(n).toBeGreaterThanOrEqual(3);
expect(n).toBeLessThanOrEqual(7);
expect(Number.isInteger(n)).toBe(true);
}
});
it("浮点保留指定位数", () => {
const s = generateMock("@float(1, 10, 2)");
expect(s).toMatch(/^\d+\.\d{2}$/);
expect(Number(s)).toBeGreaterThanOrEqual(1);
expect(Number(s)).toBeLessThanOrEqual(10);
});
it("邮箱 / 手机号 / UUID / 身份证格式", () => {
expect(generateMock("@email")).toMatch(/^[\w.]+@[\w.]+\.\w+$/);
expect(generateMock("@phone")).toMatch(/^1[3-9]\d{9}$/);
expect(generateMock("@guid")).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
expect(generateMock("@id")).toMatch(/^\d{17}[\dX]$/);
});
it("日期格式化", () => {
expect(generateMock("@date(YYYY-MM-DD)")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(generateMock("@datetime(YYYY/MM/DD HH:mm:ss)")).toMatch(/^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}$/);
expect(generateMock("@timestamp")).toMatch(/^\d{12,}$/);
});
it("pick 支持引号参数", () => {
const out = generateMock('@pick("男", "女")');
expect(["男", "女"]).toContain(out);
});
it("未知表达式原样返回", () => {
expect(generateMock("@notExist(1)")).toBe("@notExist(1)");
expect(generateMock("普通文本")).toBe("普通文本");
});
it("typed 生成保持类型", () => {
expect(typeof generateMockTyped("@integer(1, 9)", "number")).toBe("number");
expect(typeof generateMockTyped("@boolean", "boolean")).toBe("boolean");
expect(typeof generateMockTyped("@cname", "string")).toBe("string");
});
});
describe("通配符与智能匹配", () => {
it("glob 转换", () => {
expect(globToRegExp("*name*").test("username")).toBe(true);
expect(globToRegExp("*name*").test("nickname")).toBe(true);
expect(globToRegExp("*name*").test("age")).toBe(false);
expect(globToRegExp("*_at").test("created_at")).toBe(true);
expect(globToRegExp("is_*").test("is_vip")).toBe(true);
});
it("默认规则命中常见字段", () => {
const rules = normalizeMockSettings(null).rules;
expect(matchMockRule(rules, "username", "string")?.rule).toBe("@cname");
expect(matchMockRule(rules, "email", "string")?.rule).toBe("@email");
expect(matchMockRule(rules, "age", "integer")).not.toBeNull();
expect(matchMockRule(rules, "age", "string")).toBeNull(); // 类型不匹配
expect(matchMockRule(rules, "is_deleted", "boolean")).not.toBeNull();
expect(matchMockRule(rules, "price", "number")).not.toBeNull();
});
it("大小写不敏感(默认)与禁用规则", () => {
const rules = [
{ ...DEFAULT_MOCK_RULES[0], pattern: "*Name*", caseSensitive: false },
{ ...DEFAULT_MOCK_RULES[0], id: "x", enabled: false },
];
expect(matchMockRule(rules.slice(0, 1), "username", "string")).not.toBeNull();
expect(matchMockRule(rules.slice(1), "username", "string")).toBeNull();
});
});
describe("值与 JSON 应用", () => {
it("KV 值:表达式生成 / 智能兜底 / 关闭时原样", () => {
expect(applyMockToValue("@integer(5, 5)", mockOn)).toBe("5");
expect(applyMockToValue("hello@world", mockOn)).toBe("hello@world"); // 非 @ 开头不动
expect(applyMockToValue("@cname", mockOff)).toBe("@cname");
// 空值 + 字段名命中 → 智能填充;非空值不覆盖
expect(applyMockToValue("", mockOn, "email")).toMatch(/@/);
expect(applyMockToValue("真实值", mockOn, "email")).toBe("真实值");
});
it("JSON:叶子表达式 + 智能匹配(含类型保持)", () => {
const input = JSON.stringify({
username: "张三占位",
age: 20,
vip: false,
nested: { email: "x@y.z", list: [{ order_id: 1 }] },
});
const out = JSON.parse(applyMockToJson(input, mockOn));
expect(typeof out.username).toBe("string");
expect(out.username).not.toBe("张三占位");
expect(Number.isInteger(out.age)).toBe(true);
expect(typeof out.vip).toBe("boolean");
expect(out.nested.email).toMatch(/@/);
expect(Number.isInteger(out.nested.list[0].order_id)).toBe(true);
});
it("JSON:关闭时不改写 / 非 JSON 原样", () => {
const input = '{"a":"@cname"}';
expect(applyMockToJson(input, mockOff)).toBe(input);
expect(applyMockToJson("not-json", mockOn)).toBe("not-json");
expect(applyMockToJson("@cname", mockOn)).not.toBe("@cname"); // 顶层表达式也解析
});
it("smartFillJson 生成可编辑模板", () => {
const { text, filled } = smartFillJson(
JSON.stringify({ username: "占位", age: 18, keep: "自定义值字段名不命中" }),
mockOn,
);
const parsed = JSON.parse(text);
expect(parsed.username).toBe("@cname");
expect(parsed.age).toBe("@integer(18, 65)");
expect(parsed.keep).toBe("自定义值字段名不命中");
expect(filled).toBe(2);
});
});
describe("设置归一化", () => {
it("缺省补默认,坏字段修复", () => {
const s = normalizeMockSettings({ enabled: true, rules: [{ id: "a", pattern: "*x*" }] });
expect(s.smart).toBe(true);
expect(s.rules[0].type).toBe("string");
expect(s.rules[0].match).toBe("wildcard");
expect(s.rules[0].rule).toBe("@string(5,10)");
});
it("null / 乱数据返回默认规则", () => {
expect(normalizeMockSettings(null).rules.length).toBe(DEFAULT_MOCK_RULES.length);
expect(normalizeMockSettings("junk").rules.length).toBe(DEFAULT_MOCK_RULES.length);
});
});
+513
View File
@@ -0,0 +1,513 @@
/**
* Mock 数据引擎(Mock.js 常用语法子集 + 智能字段匹配)。
*
* 两种用法:
* 1. 显式表达式:在参数值 / Body 中直接写 @cname、@integer(1,100)、@datetime(...) 等;
* 2. 智能匹配:开启后按字段名(如 username / email / age)自动套用规则表生成。
*/
import type { MockFieldType, MockRule, MockSettings } from "./model";
/* ---------------- 基础数据 ---------------- */
const SURNAME = "赵钱孙李周吴郑王冯陈蒋沈韩杨朱秦许何吕张孔曹严华金魏陶姜谢邹柏水窦章云苏潘葛范彭鲁马苗凤花方任袁柳史唐薛雷贺倪汤";
const GIVEN = "伟芳娜秀英敏静丽强磊军洋勇艳杰娟涛明超秀兰霞平刚华民玉萍红娥玲芬芳燕春菊学成嘉昊泽宇欣怡思远志坚建国子涵梓萱浩然雨桐";
const PROVINCES = [
"北京市", "上海市", "天津市", "重庆市", "河北省", "山西省", "辽宁省", "吉林省", "黑龙江省",
"江苏省", "浙江省", "安徽省", "福建省", "江西省", "山东省", "河南省", "湖北省", "湖南省",
"广东省", "海南省", "四川省", "贵州省", "云南省", "陕西省", "甘肃省", "青海省", "台湾省",
"内蒙古自治区", "广西壮族自治区", "西藏自治区", "宁夏回族自治区", "新疆维吾尔自治区", "香港特别行政区", "澳门特别行政区",
];
const CITIES = [
"广州", "深圳", "杭州", "成都", "武汉", "西安", "南京", "苏州", "长沙", "郑州", "青岛", "大连",
"厦门", "宁波", "无锡", "佛山", "东莞", "沈阳", "哈尔滨", "石家庄", "太原", "合肥", "福州", "南昌",
"济南", "昆明", "贵阳", "兰州", "西宁", "南宁", "呼和浩特", "乌鲁木齐",
];
const COUNTIES = [
"朝阳区", "海淀区", "浦东新区", "南山区", "福田区", "天河区", "西湖区", "锦江区", "雁塔区", "玄武区",
"工业园区", "香洲区", "东湖区", "岳麓区", "金水区", "市南区", "和平区", "河东区",
];
const LETTERS = "abcdefghijklmnopqrstuvwxyz";
const UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const DIGITS = "0123456789";
const LOREM = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore";
const randInt = (min: number, max: number) => min + Math.floor(Math.random() * (max - min + 1));
const pickFrom = <T,>(arr: T[]): T => arr[Math.floor(Math.random() * arr.length)];
function randString(pool: string, len: number): string {
let s = "";
for (let i = 0; i < len; i++) s += pool[Math.floor(Math.random() * pool.length)];
return s;
}
function pad(n: number, w = 2): string {
return String(n).padStart(w, "0");
}
function formatDate(fmt: string, d = new Date()): string {
return fmt
.replace(/YYYY/g, String(d.getFullYear()))
.replace(/YY/g, String(d.getFullYear()).slice(2))
.replace(/MM/g, pad(d.getMonth() + 1))
.replace(/DD/g, pad(d.getDate()))
.replace(/HH/g, pad(d.getHours()))
.replace(/mm/g, pad(d.getMinutes()))
.replace(/ss/g, pad(d.getSeconds()));
}
/* ---------------- 表达式求值 ---------------- */
/** 拆分实参:兼容引号字符串与裸数字 */
function parseArgs(raw: string): (string | number)[] {
const out: (string | number)[] = [];
let cur = "";
let quote: string | null = null;
let depth = 0;
for (const ch of raw) {
if (quote) {
if (ch === quote) quote = null;
else cur += ch;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (ch === "(") depth++;
if (ch === ")") depth--;
if (ch === "," && depth === 0) {
out.push(cur.trim());
cur = "";
continue;
}
cur += ch;
}
if (cur.trim()) out.push(cur.trim());
return out.map((a) => {
const n = Number(a);
return a !== "" && !Number.isNaN(n) ? n : a;
});
}
const asNum = (v: string | number | undefined, fallback: number): number =>
typeof v === "number" && Number.isFinite(v) ? v : fallback;
const PHONE_HEADS = ["130", "131", "132", "133", "135", "136", "137", "138", "139", "150", "151", "152", "155", "156", "157", "158", "159", "170", "176", "177", "178", "180", "181", "182", "183", "185", "186", "187", "188", "189", "199"];
function idCard(): string {
const area = randInt(110000, 659000);
const y = randInt(1960, 2005);
const m = randInt(1, 12);
const d = randInt(1, 28);
const seq = randInt(100, 999);
return `${area}${y}${pad(m)}${pad(d)}${seq}${randInt(0, 9)}`;
}
/** 求 Mock 表达式的值(字符串形式);非 @ 开头或未知语法时原样返回 */
export function generateMock(expr: string): string {
const m = /^@([a-zA-Z][\w-]*)(?:\((.*)\))?$/.exec(expr.trim());
if (!m) return expr;
const name = m[1].toLowerCase();
const args = parseArgs(m[2] ?? "");
switch (name) {
/* 数字 */
case "integer":
return String(randInt(Math.trunc(asNum(args[0], 0)), Math.trunc(asNum(args[1], 100))));
case "natural":
return String(randInt(Math.trunc(asNum(args[0], 1)), Math.trunc(asNum(args[1], 1000))));
case "increment":
return String(randInt(1, 999));
case "float": {
const min = asNum(args[0], 0);
const max = asNum(args[1], 100);
const d = Math.trunc(asNum(args[2], 2));
return (min + Math.random() * (max - min)).toFixed(d);
}
case "timestamp":
return String(Date.now());
/* 布尔 / 选择 */
case "boolean":
return String(Math.random() < 0.5);
case "bool":
return String(Math.random() < 0.5);
case "pick":
return String(pickFrom(args.length ? args : ["A", "B", "C"]));
/* 字符串 */
case "string": {
const a = args[0];
let pool = LETTERS + UPPER + DIGITS;
let min = 5;
let max = 10;
if (typeof a === "string") {
if (a === "lower") pool = LETTERS;
else if (a === "upper") pool = UPPER;
else if (a === "number") pool = DIGITS;
else if (a === "symbol") pool = "!@#$%^&*";
else { min = asNum(a, 5); max = asNum(args[1], min); }
} else if (typeof a === "number") {
min = a;
max = asNum(args[1], min);
}
return randString(pool, randInt(min, max));
}
case "character":
return randString(LETTERS + UPPER + DIGITS, 1);
case "word":
case "cword": {
const min = Math.trunc(asNum(args[0], 3));
const max = Math.trunc(asNum(args[1], min));
return randString(GIVEN, randInt(min, max));
}
/* 姓名 */
case "cname":
return pickFrom([...SURNAME]) + randString(GIVEN, randInt(1, 2));
case "name":
return `${pickFrom(["James", "John", "Mary", "Alice", "Bob", "Emma", "Li", "Wang"])} ${pickFrom(["Smith", "Brown", "Chen", "Zhang", "Lee", "Wilson"])}`;
case "cfirst":
return pickFrom([...SURNAME]);
case "clast":
return randString(GIVEN, randInt(1, 2));
/* 网络 */
case "email":
return `${randString(LETTERS, randInt(4, 9))}${randInt(1, 99)}@${pickFrom(["qq.com", "163.com", "gmail.com", "outlook.com", "example.com"])}`;
case "url":
return `${pickFrom(["https", "http"])}://${pickFrom(["www.example.com", "api.example.com", "cdn.example.com", "example.org"])}/${randString(LETTERS, randInt(3, 8))}`;
case "domain":
return `${randString(LETTERS, randInt(4, 10))}.${pickFrom(["com", "cn", "net", "org", "io"])}`;
case "ip":
return `${randInt(1, 254)}.${randInt(0, 255)}.${randInt(0, 255)}.${randInt(1, 254)}`;
case "tld":
return pickFrom(["com", "cn", "net", "org", "io", "dev"]);
/* 标识 */
case "guid":
case "uuid": {
const h = "0123456789abcdef";
const seg = (n: number) => randString(h, n);
return `${seg(8)}-${seg(4)}-${seg(4)}-${seg(4)}-${seg(12)}`;
}
case "id":
return idCard();
/* 手机号 */
case "phone":
case "mobile":
case "tel":
return pickFrom(PHONE_HEADS) + randString(DIGITS, 8);
/* 日期时间 */
case "date":
return formatDate(typeof args[0] === "string" ? args[0] : "YYYY-MM-DD");
case "time":
return formatDate(typeof args[0] === "string" ? args[0] : "HH:mm:ss");
case "datetime":
return formatDate(typeof args[0] === "string" ? args[0] : "YYYY-MM-DD HH:mm:ss");
case "now":
return formatDate(typeof args[0] === "string" ? args[0] : "YYYY-MM-DD HH:mm:ss");
/* 地区 */
case "province":
return pickFrom(PROVINCES);
case "city":
return (String(args[0]) === "true" ? pickFrom(PROVINCES) : "") + pickFrom(CITIES) + "市";
case "county":
return (String(args[0]) === "true" ? `${pickFrom(PROVINCES)}${pickFrom(CITIES)}市` : "") + pickFrom(COUNTIES);
case "zip":
case "zipcode":
return randString(DIGITS, 6);
/* 中文文本 */
case "ctitle":
case "ctitlex":
return randString(GIVEN, randInt(Math.trunc(asNum(args[0], 4)), Math.trunc(asNum(args[1], 8))));
case "csentence":
return randString(GIVEN + GIVEN, randInt(Math.trunc(asNum(args[0], 8)), Math.trunc(asNum(args[1], 16)))) + "。";
case "cparagraph":
return Array.from({ length: Math.trunc(asNum(args[0], 2)) }, () => randString(GIVEN, randInt(10, 20)) + "。").join("");
case "sentence":
return `${pickFrom(LOREM.split(" "))} ${pickFrom(LOREM.split(" "))} ${pickFrom(LOREM.split(" "))}.`.replace(/^./, (c) => c.toUpperCase());
case "paragraph":
return Array.from({ length: 3 }, () => pickFrom(LOREM.split(" "))).join(" ");
case "title": {
const w = pickFrom(LOREM.split(" "));
return w.charAt(0).toUpperCase() + w.slice(1);
}
/* 图片 */
case "image": {
const size = typeof args[0] === "string" && /^\d+x\d+$/.test(args[0]) ? args[0] : `${randInt(2, 8) * 100}x${randInt(2, 8) * 100}`;
const [w, h] = size.split("x");
return `https://picsum.photos/seed/${randString(LETTERS + DIGITS, 8)}/${w}/${h}`;
}
default:
return expr;
}
}
/** 按目标类型生成(JSON 数字/布尔叶子保持类型) */
export function generateMockTyped(expr: string, want: "string" | "number" | "boolean"): string | number | boolean {
const raw = generateMock(expr);
if (want === "boolean") return raw === "true";
if (want === "number") {
const n = Number(raw);
return Number.isNaN(n) ? raw : n;
}
return raw;
}
/* ---------------- 智能匹配 ---------------- */
/** 通配符 → RegExp(* 任意串 / ? 单字符) */
export function globToRegExp(pattern: string): RegExp {
let re = "";
for (const ch of pattern) {
if (ch === "*") re += ".*";
else if (ch === "?") re += ".";
else re += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
}
return new RegExp(`^${re}$`);
}
export function normalizeMockSettings(raw: unknown): MockSettings {
const source = raw && typeof raw === "object" ? (raw as Partial<MockSettings>) : {};
const rules = Array.isArray(source.rules)
? source.rules
.filter((r): r is MockRule => !!r && typeof r === "object")
.map((r, i) => ({
id: typeof r.id === "string" && r.id ? r.id : `mr-${Date.now()}-${i}`,
enabled: r.enabled !== false,
type: (["string", "number", "integer", "boolean"] as const).includes(r.type as never) ? r.type : "string",
match: (["wildcard", "exact", "regex"] as const).includes(r.match as never) ? r.match : "wildcard",
pattern: typeof r.pattern === "string" ? r.pattern : "",
caseSensitive: r.caseSensitive === true,
rule: typeof r.rule === "string" ? r.rule : "@string(5,10)",
note: typeof r.note === "string" ? r.note : undefined,
}))
: DEFAULT_MOCK_RULES.map((r) => ({ ...r }));
return {
enabled: source.enabled !== false,
smart: source.smart !== false,
rules,
};
}
function leafKindOf(value: unknown): MockFieldType | null {
if (typeof value === "string") return "string";
if (typeof value === "boolean") return "boolean";
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
return null;
}
/** 字段名是否命中规则(类型需匹配叶子类型;KV 值按 string 匹配) */
export function matchMockRule(
rules: MockRule[],
fieldName: string,
valueKind: "string" | "number" | "integer" | "boolean",
): MockRule | null {
for (const r of rules) {
if (!r.enabled || !r.pattern || !r.rule) continue;
if (r.type !== valueKind && !(r.type === "number" && valueKind === "integer")) continue;
const name = r.caseSensitive ? fieldName : fieldName.toLowerCase();
const pat = r.caseSensitive ? r.pattern : r.pattern.toLowerCase();
let hit = false;
try {
if (r.match === "exact") hit = name === pat;
else if (r.match === "regex") hit = new RegExp(pat).test(name);
else hit = globToRegExp(pat).test(name);
} catch {
hit = false;
}
if (hit) return r;
}
return null;
}
/** 单值解析:@表达式 → 生成值;空值 + 字段名命中时智能填充(非空值不覆盖) */
export function applyMockToValue(
value: string,
mock: MockSettings | undefined,
fieldHint?: string,
): string {
if (!mock?.enabled) return value;
const trimmed = value.trim();
if (trimmed.startsWith("@")) return generateMock(trimmed);
if (mock.smart && value === "" && fieldHint) {
const rule = matchMockRule(mock.rules, fieldHint, "string");
if (rule) return String(generateMockTyped(rule.rule, "string"));
}
return value;
}
/** JSON 字符串 → 解析后逐叶子生成;解析失败时退回整体表达式替换 */
export function applyMockToJson(raw: string, mock: MockSettings | undefined): string {
if (!mock?.enabled) return raw;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return raw.trim().startsWith("@") ? generateMock(raw.trim()) : raw;
}
const visit = (node: unknown, key: string | null): unknown => {
if (Array.isArray(node)) return node.map((item) => visit(item, key));
if (node && typeof node === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
out[k] = visit(v, k);
}
return out;
}
// 叶子
if (typeof node === "string") {
if (node.trim().startsWith("@")) return generateMock(node.trim());
const rule = mock.smart && key ? matchMockRule(mock.rules, key, "string") : null;
if (rule) return String(generateMockTyped(rule.rule, "string"));
return node;
}
if (typeof node === "number" || typeof node === "boolean") {
const kind = leafKindOf(node);
const rule = mock.smart && key && kind ? matchMockRule(mock.rules, key, kind) : null;
if (rule) return generateMockTyped(rule.rule, kind === "boolean" ? "boolean" : "number");
return node;
}
return node;
};
return JSON.stringify(visit(parsed, null), null, 2);
}
/** 编辑态:把 JSON 各叶子值替换为命中的 @表达式(生成可编辑的 Mock 模板) */
export function smartFillJson(raw: string, mock: MockSettings): { text: string; filled: number } {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { text: raw, filled: 0 };
}
let filled = 0;
const visit = (node: unknown, key: string | null): unknown => {
if (Array.isArray(node)) return node.map((item) => visit(item, key));
if (node && typeof node === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
out[k] = visit(v, k);
}
return out;
}
const kind = leafKindOf(node);
const rule = key && kind ? matchMockRule(mock.rules, key, kind) : null;
if (rule) {
filled++;
return rule.rule;
}
return node;
};
return { text: JSON.stringify(visit(parsed, null), null, 2), filled };
}
/* ---------------- 默认规则(按特异性排序,先命中先用) ---------------- */
function rule(
id: string,
pattern: string,
type: MockFieldType,
expr: string,
note: string,
match: "wildcard" | "exact" | "regex" = "wildcard",
): MockRule {
return { id, enabled: true, type, match, pattern, caseSensitive: false, rule: expr, note };
}
export const DEFAULT_MOCK_RULES: MockRule[] = [
rule("mr-nickname", "*nickname*", "string", "@cname", "昵称"),
rule("mr-username", "*username*", "string", "@cname", "用户名/账号"),
rule("mr-name", "*name*", "string", "@cname", "姓名(通配命中 realName/fullName 等)"),
rule("mr-email", "*email*", "string", "@email", "邮箱"),
rule("mr-mail", "*mail*", "string", "@email", "邮箱"),
rule("mr-phone", "*phone*", "string", "@phone", "手机号"),
rule("mr-mobile", "*mobile*", "string", "@phone", "手机号"),
rule("mr-tel", "*tel*", "string", "@phone", "电话"),
rule("mr-avatar", "*avatar*", "string", "@image(120x120)", "头像"),
rule("mr-image", "*image*", "string", "@image", "图片"),
rule("mr-img", "*img*", "string", "@image", "图片/缩略图"),
rule("mr-icon", "*icon*", "string", "@image(64x64)", "图标"),
rule("mr-cover", "*cover*", "string", "@image(640x320)", "封面图"),
rule("mr-url", "*url*", "string", "@url", "链接"),
rule("mr-link", "*link*", "string", "@url", "链接"),
rule("mr-website", "*website*", "string", "@url", "网站"),
rule("mr-id", "*id", "integer", "@integer(1, 999999)", "业务 ID(userId/orderId…)"),
rule("mr-uuid", "*uuid*", "string", "@guid", "UUID"),
rule("mr-guid", "*guid*", "string", "@guid", "GUID"),
rule("mr-token", "*token*", "string", "@string(32)", "令牌"),
rule("mr-password", "*password*", "string", "@string(10,16)", "密码"),
rule("mr-pwd", "*pwd*", "string", "@string(10,16)", "密码"),
rule("mr-secret", "*secret*", "string", "@string(24)", "密钥"),
rule("mr-age", "*age*", "integer", "@integer(18, 65)", "年龄"),
rule("mr-sex", "*sex*", "integer", "@integer(0, 1)", "性别码"),
rule("mr-gender", "*gender*", "integer", "@integer(0, 1)", "性别码"),
rule("mr-price", "*price*", "number", "@float(1, 9999, 2)", "价格"),
rule("mr-amount", "*amount*", "number", "@float(1, 99999, 2)", "金额"),
rule("mr-money", "*money*", "number", "@float(0, 99999, 2)", "金额"),
rule("mr-fee", "*fee*", "number", "@float(0, 999, 2)", "费用"),
rule("mr-count", "*count*", "integer", "@integer(0, 9999)", "数量"),
rule("mr-num", "*num*", "integer", "@integer(0, 999)", "数量"),
rule("mr-qty", "*qty*", "integer", "@integer(1, 999)", "数量"),
rule("mr-stock", "*stock*", "integer", "@integer(0, 9999)", "库存"),
rule("mr-page", "*page*", "integer", "@integer(1, 100)", "页码"),
rule("mr-size", "*size*", "integer", "@integer(10, 50)", "分页大小"),
rule("mr-limit", "*limit*", "integer", "@integer(10, 100)", "分页条数"),
rule("mr-status", "*status*", "integer", "@integer(0, 1)", "状态码"),
rule("mr-state", "*state*", "integer", "@integer(0, 1)", "状态"),
rule("mr-deleted", "*deleted*", "boolean", "@boolean", "逻辑删除标记"),
rule("mr-enabled", "*enabled*", "boolean", "@boolean", "启用标记"),
rule("mr-is", "is_*", "boolean", "@boolean", "布尔标记", "wildcard"),
rule("mr-time", "*time*", "string", "@datetime(YYYY-MM-DD HH:mm:ss)", "时间"),
rule("mr-date", "*date*", "string", "@date(YYYY-MM-DD)", "日期"),
rule("mr-at", "*_at", "string", "@datetime(YYYY-MM-DD HH:mm:ss)", "创建/更新时间"),
rule("mr-timestamp", "*timestamp*", "integer", "@timestamp", "时间戳"),
rule("mr-address", "*address*", "string", "@county(true)", "完整地址"),
rule("mr-addr", "*addr*", "string", "@county(true)", "地址"),
rule("mr-province", "*province*", "string", "@province", "省份"),
rule("mr-city", "*city*", "string", "@city(true)", "城市"),
rule("mr-district", "*district*", "string", "@county", "区县"),
rule("mr-area", "*area*", "string", "@county", "地区"),
rule("mr-title", "*title*", "string", "@ctitle(4, 12)", "标题"),
rule("mr-content", "*content*", "string", "@csentence(10, 24)", "正文内容"),
rule("mr-desc", "*desc*", "string", "@csentence(8, 16)", "描述"),
rule("mr-remark", "*remark*", "string", "@csentence(8, 16)", "备注"),
rule("mr-comment", "*comment*", "string", "@csentence(8, 16)", "评论"),
rule("mr-intro", "*intro*", "string", "@csentence(8, 16)", "简介"),
rule("mr-ip", "*ip*", "string", "@ip", "IP 地址"),
rule("mr-version", "*version*", "string", "@pick(\"1.0.0\", \"2.3.1\", \"3.1.4\")", "版本号"),
rule("mr-keyword", "*keyword*", "string", "@word(3, 8)", "搜索关键词"),
];
/** 设置页 / 文档展示的速查表 */
export const MOCK_CHEATSHEET: { expr: string; desc: string }[] = [
{ expr: "@cname", desc: "中文姓名" },
{ expr: "@email", desc: "邮箱" },
{ expr: "@phone", desc: "手机号" },
{ expr: "@id", desc: "身份证号" },
{ expr: "@guid", desc: "UUID / GUID" },
{ expr: "@integer(1, 100)", desc: "整数" },
{ expr: "@float(1, 9999, 2)", desc: "小数(2 位)" },
{ expr: "@boolean", desc: "true / false" },
{ expr: "@string(8, 16)", desc: "随机字符串" },
{ expr: "@datetime(YYYY-MM-DD HH:mm:ss)", desc: "日期时间" },
{ expr: "@timestamp", desc: "毫秒时间戳" },
{ expr: "@url", desc: "随机 URL" },
{ expr: "@ip", desc: "IPv4 地址" },
{ expr: "@county(true)", desc: "省市区完整地址" },
{ expr: "@ctitle(4, 12)", desc: "中文标题" },
{ expr: "@csentence(10, 24)", desc: "中文句子" },
{ expr: "@image(200x200)", desc: "图片地址" },
{ expr: '@pick("A", "B", "C")', desc: "从列表选择" },
];
+440
View File
@@ -0,0 +1,440 @@
/**
* OpsxAPI 集合数据模型 —— UI / 导入导出 / 持久化共用的唯一事实源。
*/
export type RequestMethod =
| "GET"
| "POST"
| "PUT"
| "PATCH"
| "DELETE"
| "HEAD"
| "OPTIONS";
export const REQUEST_METHODS: RequestMethod[] = [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"HEAD",
"OPTIONS",
];
export type RequestKind = "http" | "graphql" | "websocket" | "sse" | "socketio" | "mqtt";
export const REQUEST_KINDS: RequestKind[] = [
"http",
"graphql",
"websocket",
"sse",
"socketio",
"mqtt",
];
export const KIND_LABELS: Record<RequestKind, string> = {
http: "HTTP",
graphql: "GraphQL",
websocket: "WebSocket",
sse: "SSE",
socketio: "Socket.IO",
mqtt: "MQTT",
};
/** 键值行:Params / Headers / Cookies / 表单 / 环境变量共用 */
export type KVRow = {
id: string;
key: string;
value: string;
enabled: boolean;
description?: string;
};
export type BodyMode =
| "none"
| "json"
| "xml"
| "text"
| "html"
| "urlencoded"
| "form-data";
export const BODY_MODE_LABELS: Record<BodyMode, string> = {
none: "none",
json: "JSON",
xml: "XML",
text: "Text",
html: "HTML",
urlencoded: "x-www-form-urlencoded",
"form-data": "form-data",
};
export const RAW_BODY_MODES: BodyMode[] = ["json", "xml", "text", "html"];
export type AuthMode = "none" | "bearer" | "basic" | "apikey";
export type AuthConfig = {
mode: AuthMode;
bearer?: string;
basicUser?: string;
basicPass?: string;
apiKeyName?: string;
apiKeyValue?: string;
apiKeyIn?: "header" | "query";
};
export type HttpRequestData = {
kind: "http";
method: RequestMethod;
url: string;
query: KVRow[];
headers: KVRow[];
cookies: KVRow[];
auth: AuthConfig;
bodyMode: BodyMode;
bodyRaw: string;
bodyForm: KVRow[];
};
export type GraphqlRequestData = {
kind: "graphql";
url: string;
headers: KVRow[];
auth: AuthConfig;
query: string;
variables: string;
};
export type WebsocketRequestData = {
kind: "websocket";
url: string;
headers: KVRow[];
protocols: string;
};
export type SseRequestData = {
kind: "sse";
url: string;
headers: KVRow[];
query: KVRow[];
auth: AuthConfig;
lastEventId: string;
/** 仅解析 data: 行为 JSON */
dataOnly: boolean;
};
export type SocketIoRequestData = {
kind: "socketio";
url: string;
/** 额外连接参数(JSON:auth / transports / path …) */
options: string;
/** 监听的服务端事件名 */
listenEvents: string[];
sendEvent: string;
sendArgs: string;
};
export type MqttRequestData = {
kind: "mqtt";
brokerUrl: string;
username: string;
password: string;
clientId: string;
/** 订阅主题,逗号分隔 */
subscribeTopics: string;
publishTopic: string;
publishPayload: string;
qos: 0 | 1 | 2;
};
export type RequestData =
| HttpRequestData
| GraphqlRequestData
| WebsocketRequestData
| SseRequestData
| SocketIoRequestData
| MqttRequestData;
export type RequestDataOf<K extends RequestKind> = Extract<RequestData, { kind: K }>;
export type RequestNodeBase = {
id: string;
type: "request";
name: string;
description?: string;
createdAt: number;
updatedAt: number;
};
/** 文档与请求共用节点,导入的定义和手动修改随集合一起保存/导出备份。 */
export type ApiDocumentation = {
operationId?: string;
tags: string[];
parameters: { name: string; in: string; required: boolean; description: string; type: string; example: string }[];
requestBody?: { contentType: string; description: string; example: string; schema?: unknown };
responses: { status: string; description: string; contentType: string; example: string; schema?: unknown }[];
};
export type HttpApiNode = RequestNodeBase & { kind: "http"; data: HttpRequestData; documentation?: ApiDocumentation; documentationEdited?: boolean; descriptionEdited?: boolean };
export type GraphqlApiNode = RequestNodeBase & { kind: "graphql"; data: GraphqlRequestData };
export type WebsocketApiNode = RequestNodeBase & { kind: "websocket"; data: WebsocketRequestData };
export type SseApiNode = RequestNodeBase & { kind: "sse"; data: SseRequestData };
export type SocketIoApiNode = RequestNodeBase & { kind: "socketio"; data: SocketIoRequestData };
export type MqttApiNode = RequestNodeBase & { kind: "mqtt"; data: MqttRequestData };
/** 判别联合:`node.kind === "http"` 时自动收窄 data 类型 */
export type RequestNode =
| HttpApiNode
| GraphqlApiNode
| WebsocketApiNode
| SseApiNode
| SocketIoApiNode
| MqttApiNode;
export type FolderNode = {
id: string;
type: "folder";
name: string;
description?: string;
children: CollectionNode[];
createdAt: number;
updatedAt: number;
};
export type CollectionNode = RequestNode | FolderNode;
/** 一个集合(项目),本身是一个顶层目录 */
export type Collection = {
id: string;
type: "collection";
name: string;
description?: string;
children: CollectionNode[];
createdAt: number;
updatedAt: number;
};
export type Environment = {
id: string;
name: string;
/** 前置 URL:请求路径以 / 开头时自动拼接(随环境切换) */
baseUrl?: string;
variables: KVRow[];
};
export type HistoryEntry = {
id: string;
at: number;
kind: RequestKind;
label: string;
method?: RequestMethod;
url: string;
status?: number;
durationMs?: number;
ok: boolean;
/** 关联的请求节点 id(可回跳) */
nodeId?: string;
};
export type ThemeMode = "system" | "dark" | "light";
/* ---------------- Mock 数据 ---------------- */
/** 规则作用于哪类字段值(JSON 叶子类型;KV 值视为 string) */
export type MockFieldType = "string" | "number" | "integer" | "boolean";
export type MockMatchMode = "wildcard" | "exact" | "regex";
/** 智能 Mock 匹配规则:字段名命中即按 rule 表达式生成数据 */
export type MockRule = {
id: string;
enabled: boolean;
type: MockFieldType;
match: MockMatchMode;
/** 匹配模式:通配符如 *name* / 精确 / 正则 */
pattern: string;
caseSensitive: boolean;
/** Mock 规则表达式,如 @cname / @integer(1,100) */
rule: string;
note?: string;
};
export type MockSettings = {
/** 全局开关:发送请求时解析 @mock 表达式 */
enabled: boolean;
/** 智能 Mock:字段名命中规则时自动生成(无需写 @表达式) */
smart: boolean;
rules: MockRule[];
};
export type TransportMode = "auto" | "gin-proxy" | "browser" | "tauri-native";
export type WorkspaceSettings = {
theme: ThemeMode;
/** 请求超时(毫秒) */
timeoutMs: number;
/** 发信通道:auto 按环境探测 */
transportMode?: TransportMode;
mock: MockSettings;
ai: AiSettings;
};
export type WorkspaceData = {
version: 2;
collections: Collection[];
environments: Environment[];
activeEnvironmentId: string | null;
history: HistoryEntry[];
settings: WorkspaceSettings;
/** 自动化测试:用例 / 运行报告 / 定时任务 */
testCases: TestCase[];
testRuns: TestRun[];
schedules: TestSchedule[];
};
/* ---------------- 自动化测试 ---------------- */
/** 断言目标 */
export type AssertTarget = "status" | "timeMs" | "header" | "body";
/** 断言操作 */
export type AssertOp =
| "eq"
| "neq"
| "lt"
| "gt"
| "contains"
| "not_contains"
| "exists"
| "not_exists"
| "jsonpath_eq"
| "jsonpath_contains";
export const ASSERT_OP_LABELS: Record<AssertOp, string> = {
eq: "等于",
neq: "不等于",
lt: "小于",
gt: "大于",
contains: "包含文本",
not_contains: "不包含文本",
exists: "存在",
not_exists: "不存在",
jsonpath_eq: "JSONPath 等于",
jsonpath_contains: "JSONPath 包含",
};
/** 单条断言 */
export type TestAssertion = {
id: string;
enabled: boolean;
target: AssertTarget;
/** target=header 时为 Header 名;jsonpath_* 时为路径 */
locator: string;
op: AssertOp;
/** 期望值(exists/not_exists 忽略) */
expected: string;
};
/** 响应 → 环境变量提取 */
export type VariableExtract = {
id: string;
/** JSONPath,如 $.data.token */
jsonpath: string;
varName: string;
};
/** 测试步骤:引用一个请求节点 + 断言 + 提取 */
export type TestStep = {
id: string;
enabled: boolean;
/** 引用的请求节点 id(HTTP) */
nodeId: string;
assertions: TestAssertion[];
extracts: VariableExtract[];
};
/** 场景用例 */
export type TestCase = {
id: string;
name: string;
description?: string;
/** 失败后是否继续执行后续步骤 */
continueOnFailure: boolean;
steps: TestStep[];
createdAt: number;
updatedAt: number;
};
export type AssertionResult = {
label: string;
passed: boolean;
/** 实际值(截断展示) */
actual: string;
expected: string;
};
export type TestStepResult = {
stepId: string;
name: string;
method: string;
url: string;
ok: boolean;
httpStatus: number | null;
durationMs: number | null;
error: string | null;
assertionResults: AssertionResult[];
};
export type TestRunTrigger = "manual" | "ai" | "schedule";
/** 一次运行报告 */
export type TestRun = {
id: string;
caseId: string;
caseName: string;
at: number;
trigger: TestRunTrigger;
totalMs: number;
passed: boolean;
stepResults: TestStepResult[];
};
/** 定时任务(应用运行期间生效) */
export type TestSchedule = {
caseId: string;
enabled: boolean;
/** 间隔分钟(未设置 cron 时使用) */
intervalMinutes: number;
/**
* 可选 5 段 Cron(分 时 日 月 周),本地时区。
* 设置后优先于 intervalMinutes。例:每 5 分钟一次(分 位写 星号斜杠 5)
*/
cron?: string | null;
lastRunAt: number | null;
/** 最近一次调度日志(环形,最多保留若干条由调度器截断) */
runLog?: { at: number; ok: boolean; message: string }[];
};
/* ---------------- AI 助手 ---------------- */
/** OpenAI 兼容供应商配置 */
export type AiProvider = {
id: string;
name: string;
baseUrl: string;
apiKey: string;
model: string;
enabled: boolean;
};
export type AiSettings = {
providers: AiProvider[];
activeProviderId: string | null;
};
/** 导入结果:集合 + 建议写入环境的前缀变量(如 baseUrl) */
export type ImportResult = {
collections: Collection[];
environmentVariables: { key: string; value: string }[];
warnings: string[];
};
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { replaceVariables, replaceVariablesInSpec } from "./replaceVariables";
describe("replaceVariables", () => {
it("replaces Postman-style {{var}}", () => {
expect(replaceVariables("{{baseUrl}}/api", { baseUrl: "https://x.test" })).toBe(
"https://x.test/api",
);
});
it("leaves unknown vars intact", () => {
expect(replaceVariables("hello {{missing}}", {})).toBe("hello {{missing}}");
});
it("supports multiple and spaced keys", () => {
expect(
replaceVariables("{{ host }}/{{path}}", { host: "h", path: "p" }),
).toBe("h/p");
});
it("replaceVariablesInSpec walks nested strings", () => {
const out = replaceVariablesInSpec(
{ url: "{{baseUrl}}/v1", headers: { Authorization: "Bearer {{token}}" } },
{ baseUrl: "http://a", token: "t" },
);
expect(out.url).toBe("http://a/v1");
expect(out.headers.Authorization).toBe("Bearer t");
});
});
+35
View File
@@ -0,0 +1,35 @@
/**
* Postman-style {{var}} replacement.
* Unknown variables are left as-is.
*/
export function replaceVariables(
input: string,
env: Record<string, string>,
): string {
if (!input) return input;
return input.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key: string) => {
if (Object.prototype.hasOwnProperty.call(env, key)) {
return env[key] ?? "";
}
return match;
});
}
export function replaceVariablesInSpec<T extends Record<string, unknown>>(
spec: T,
env: Record<string, string>,
): T {
const walk = (v: unknown): unknown => {
if (typeof v === "string") return replaceVariables(v, env);
if (Array.isArray(v)) return v.map(walk);
if (v && typeof v === "object") {
const out: Record<string, unknown> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
out[k] = walk(val);
}
return out;
}
return v;
};
return walk(spec) as T;
}
+14
View File
@@ -0,0 +1,14 @@
import { expect, it } from "vitest";
import { environmentPath, resolveRequestUrl } from "./requestUrl";
it("switches environment prefix without changing request path", () => {
expect(resolveRequestUrl("/pet/{{id}}", { id: "1" }, "http://localhost:3000")).toBe("http://localhost:3000/pet/1");
expect(resolveRequestUrl("/pet/{{id}}", { id: "1" }, "https://api.example.com/v1/")).toBe("https://api.example.com/v1/pet/1");
expect(resolveRequestUrl(" /pet/1 ", { host: "api.example.com" }, "https://{{host}}")).toBe("https://api.example.com/pet/1");
expect(resolveRequestUrl("https://fixed.test/pet", {}, "https://api.example.com")).toBe("https://fixed.test/pet");
expect(resolveRequestUrl("/pet", {}, "http://localhost:8788/mock/token")).toBe("http://localhost:8788/mock/token/pet");
});
it("converts only URLs matching a known environment at a path boundary", () => {
expect(environmentPath("https://api.test/v1/pet?id=1", ["https://api.test/v1"])).toBe("/pet?id=1");
expect(environmentPath("https://api.test/v12/pet", ["https://api.test/v1"])).toBeNull();
expect(environmentPath("https://other.test/pet", ["https://api.test"])).toBeNull();
});
+25
View File
@@ -0,0 +1,25 @@
import { replaceVariables } from "./replaceVariables";
/** One resolver for sending, address previews and generated code. */
export function resolveRequestUrl(path: string, variables: Record<string, string> = {}, base = ""): string {
const resolved = replaceVariables(path.trim(), variables);
const prefix = replaceVariables(base.trim(), variables).replace(/\/+$/, "");
if (prefix && (!resolved || (resolved.startsWith("/") && !resolved.startsWith("//")))) return `${prefix}${resolved || "/"}`;
return resolved;
}
/** Only offer conversion when the URL belongs to a known environment base. */
export function environmentPath(url: string, bases: string[]): string | null {
try {
const target = new URL(url);
for (const raw of [...bases].sort((a, b) => b.length - a.length)) {
let base: URL;
try { base = new URL(raw); } catch { continue; }
const prefix = base.pathname.replace(/\/+$/, "");
if (base.origin !== target.origin || base.search || base.hash) continue;
if (target.pathname !== prefix && !target.pathname.startsWith(`${prefix}/`)) continue;
return (target.pathname.slice(prefix.length) || "/") + target.search + target.hash;
}
} catch { /* relative or incomplete URL */ }
return null;
}
+196
View File
@@ -0,0 +1,196 @@
import { describe, expect, it } from "vitest";
import {
describeAssertion,
evaluateAssertion,
exportRunJson,
exportRunMarkdown,
extractVariables,
newTestCase,
queryJsonPath,
runStats,
suggestAssertions,
type StepHttpResponse,
} from "./testing";
import type { TestAssertion } from "./model";
const res: StepHttpResponse = {
status: 200,
headers: { "Content-Type": "application/json", "X-Trace-Id": "abc123" },
body: JSON.stringify({ code: 0, data: { id: 42, name: "opsx", tags: ["a", "b"] }, msg: "ok" }),
timeMs: 123,
};
const as = (over: Partial<TestAssertion>): TestAssertion => ({
id: "a1",
enabled: true,
target: "status",
locator: "",
op: "eq",
expected: "",
...over,
});
describe("queryJsonPath", () => {
it("基本路径", () => {
const obj = JSON.parse(res.body);
expect(queryJsonPath(obj, "$.code")).toBe(0);
expect(queryJsonPath(obj, "$.data.id")).toBe(42);
expect(queryJsonPath(obj, "$.data.tags[1]")).toBe("b");
expect(queryJsonPath(obj, "$['data']['name']")).toBe("opsx");
expect(queryJsonPath(obj, "$.data.missing")).toBeUndefined();
expect(queryJsonPath(obj, "$")).toEqual(obj);
});
});
describe("断言评估", () => {
it("状态码 / 耗时(数字比较)", () => {
expect(evaluateAssertion(res, as({ target: "status", op: "eq", expected: "200" })).passed).toBe(true);
expect(evaluateAssertion(res, as({ target: "status", op: "lt", expected: "300" })).passed).toBe(true);
expect(evaluateAssertion(res, as({ target: "timeMs", op: "gt", expected: "100" })).passed).toBe(true);
expect(evaluateAssertion(res, as({ target: "timeMs", op: "lt", expected: "50" })).passed).toBe(false);
});
it("Header 大小写不敏感", () => {
expect(evaluateAssertion(res, as({ target: "header", locator: "x-trace-id", op: "contains", expected: "abc" })).passed).toBe(true);
expect(evaluateAssertion(res, as({ target: "header", locator: "X-Missing", op: "exists" })).passed).toBe(false);
});
it("JSONPath 断言", () => {
expect(evaluateAssertion(res, as({ target: "body", locator: "$.data.name", op: "jsonpath_eq", expected: "opsx" })).passed).toBe(true);
expect(evaluateAssertion(res, as({ target: "body", locator: "$.data.id", op: "jsonpath_eq", expected: "43" })).passed).toBe(false);
expect(evaluateAssertion(res, as({ target: "body", locator: "$.msg", op: "jsonpath_contains", expected: "o" })).passed).toBe(true);
expect(evaluateAssertion(res, as({ target: "body", locator: "$.data.name", op: "exists" })).passed).toBe(true);
expect(evaluateAssertion(res, as({ target: "body", locator: "$.none", op: "not_exists" })).passed).toBe(true);
});
it("非 JSON 响应的 jsonpath 断言失败但不抛错", () => {
const plain = { ...res, body: "hello world" };
const r = evaluateAssertion(plain, as({ target: "body", locator: "$.a", op: "jsonpath_eq", expected: "1" }));
expect(r.passed).toBe(false);
expect(r.actual).toContain("非 JSON");
});
it("断言文案", () => {
expect(describeAssertion(as({ target: "status", op: "eq", expected: "200" }))).toContain("状态码");
expect(describeAssertion(as({ target: "body", locator: "$.a", op: "jsonpath_contains", expected: "x" }))).toContain("$.a");
});
});
describe("变量提取", () => {
it("提取到平面对象", () => {
const out = extractVariables(res, [
{ id: "e1", jsonpath: "$.data.id", varName: "petId" },
{ id: "e2", jsonpath: "$.data.name", varName: "petName" },
{ id: "e3", jsonpath: "$.none", varName: "missing" },
{ id: "e4", jsonpath: "", varName: "bad" },
]);
expect(out).toEqual({ petId: "42", petName: "opsx" });
});
});
describe("默认断言与统计", () => {
it("suggestAssertions 至少含状态码与耗时", () => {
const list = suggestAssertions({ method: "GET", url: "https://x" } as never);
expect(list.length).toBeGreaterThanOrEqual(2);
expect(list[0].target).toBe("status");
});
it("runStats", () => {
expect(runStats({ stepResults: [{ ok: true }, { ok: false }, { ok: true }] })).toEqual({ ok: 2, fail: 1 });
});
});
describe("报告导出", () => {
it("Markdown 结构", () => {
const md = exportRunMarkdown({
caseName: "下单流程",
at: Date.now(),
trigger: "manual",
totalMs: 500,
passed: false,
stepResults: [
{
name: "创建订单",
method: "POST",
url: "https://api.test/orders",
ok: true,
httpStatus: 200,
durationMs: 200,
error: null,
assertionResults: [{ label: "状态码 = 200", passed: true, actual: "200", expected: "200" }],
},
{
name: "查询订单",
method: "GET",
url: "https://api.test/orders/1",
ok: false,
httpStatus: 404,
durationMs: 300,
error: null,
assertionResults: [{ label: "状态码 = 200", passed: false, actual: "404", expected: "200" }],
},
],
});
expect(md).toContain("# 测试报告 · 下单流程");
expect(md).toContain("1/2 步骤通过");
expect(md).toContain("POST https://api.test/orders");
expect(md).toContain("| 状态码 = 200 | 200 | 404 | ❌ |");
});
});
describe("用例构造", () => {
it("newTestCase 默认值", () => {
const tc = newTestCase("冒烟");
expect(tc.steps).toEqual([]);
expect(tc.continueOnFailure).toBe(false);
expect(tc.id).toMatch(/^tc-/);
});
});
import { cronMatches, isValidCron, scheduleIsDue } from "./cron";
describe("exportRunJson", () => {
it("emits opsxapi schema", () => {
const json = JSON.parse(
exportRunJson({
caseName: "demo",
at: 1,
trigger: "manual",
totalMs: 10,
passed: true,
stepResults: [],
}),
);
expect(json.schema).toBe("opsxapi.test-run.v1");
expect(json.caseName).toBe("demo");
});
});
describe("cron", () => {
it("validates 5-field expressions", () => {
expect(isValidCron("*/5 * * * *")).toBe(true);
expect(isValidCron("* * *")).toBe(false);
expect(isValidCron("*/0 * * * *")).toBe(false);
expect(isValidCron("61 * * * *")).toBe(false);
expect(isValidCron("10-2 * * * *")).toBe(false);
expect(cronMatches("*/0 * * * *", new Date(2026, 0, 1, 12, 10))).toBe(false);
});
it("matches */5 minutes", () => {
const d = new Date(2026, 0, 1, 12, 10, 0); // min=10
expect(cronMatches("*/5 * * * *", d)).toBe(true);
expect(cronMatches("*/5 * * * *", new Date(2026, 0, 1, 12, 11, 0))).toBe(false);
});
it("scheduleIsDue prefers cron over interval", () => {
const d = new Date(2026, 0, 1, 12, 0, 0);
expect(
scheduleIsDue({ intervalMinutes: 999, cron: "0 * * * *", lastRunAt: null }, d),
).toBe(true);
expect(
scheduleIsDue(
{ intervalMinutes: 1, cron: "0 * * * *", lastRunAt: d.getTime() },
d,
),
).toBe(false);
});
});
+337
View File
@@ -0,0 +1,337 @@
/**
* 自动化测试引擎(纯函数):JSONPath 求值 / 断言评估 / 变量提取 / 报告导出。
*/
import type {
AssertOp,
AssertionResult,
HttpRequestData,
TestAssertion,
TestCase,
TestStep,
VariableExtract,
} from "./model";
export type StepHttpResponse = {
status: number;
headers: Record<string, string>;
body: string;
timeMs: number;
};
/* ---------------- JSONPath($.a.b[0].c / $['k'] 子集) ---------------- */
/** 返回命中的第一个值;未命中返回 undefined */
export function queryJsonPath(data: unknown, path: string): unknown {
const normalized = path.trim().replace(/^\$\.?/, "");
if (normalized === "" || normalized === "$") return data;
const tokens: string[] = [];
const re = /([^[\].]+)|\['([^']+)'\]|\["([^"]+)"\]|\[(\d+)\]/g;
let m: RegExpExecArray | null;
while ((m = re.exec(normalized)) !== null) {
if (m[1] !== undefined) tokens.push(m[1]);
else if (m[2] !== undefined) tokens.push(m[2]);
else if (m[3] !== undefined) tokens.push(m[3]);
else if (m[4] !== undefined) tokens.push(m[4]);
}
let cur: unknown = data;
for (const t of tokens) {
if (cur === null || cur === undefined) return undefined;
if (Array.isArray(cur)) {
const idx = Number(t);
if (Number.isNaN(idx)) return undefined;
cur = cur[idx];
} else if (typeof cur === "object") {
cur = (cur as Record<string, unknown>)[t];
} else {
return undefined;
}
}
return cur;
}
function stringifyValue(v: unknown): string {
if (v === undefined) return "";
if (typeof v === "object") {
try {
return JSON.stringify(v);
} catch {
return String(v);
}
}
return String(v);
}
/* ---------------- 断言评估 ---------------- */
export function describeAssertion(a: TestAssertion): string {
const where =
a.target === "status"
? "状态码"
: a.target === "timeMs"
? "耗时(ms)"
: a.target === "header"
? `Header ${a.locator}`
: a.op.startsWith("jsonpath")
? `Body ${a.locator}`
: "Body";
const OPS: Record<AssertOp, string> = {
eq: "=",
neq: "≠",
lt: "<",
gt: ">",
contains: "包含",
not_contains: "不包含",
exists: "存在",
not_exists: "不存在",
jsonpath_eq: "=",
jsonpath_contains: "包含",
};
const needExpected = !(a.op === "exists" || a.op === "not_exists");
return `${where} ${OPS[a.op]}${needExpected ? ` ${a.expected}` : ""}`;
}
export function evaluateAssertion(
res: StepHttpResponse,
a: TestAssertion,
): AssertionResult {
const base = { label: describeAssertion(a), expected: a.op === "exists" || a.op === "not_exists" ? "—" : a.expected };
let actual: string;
let num: number | null = null;
switch (a.target) {
case "status":
actual = String(res.status);
num = res.status;
break;
case "timeMs":
actual = String(res.timeMs);
num = res.timeMs;
break;
case "header": {
const key = Object.keys(res.headers).find((h) => h.toLowerCase() === a.locator.trim().toLowerCase());
actual = key ? res.headers[key] : "";
break;
}
default: {
// body:jsonpath_* 与 exists(locator 以 $. 开头时)先解析再取值;contains 系直接对原文
if (a.op.startsWith("jsonpath") || (a.locator.trim().startsWith("$.") && (a.op === "exists" || a.op === "not_exists"))) {
let parsed: unknown;
try {
parsed = JSON.parse(res.body);
} catch {
return { ...base, passed: false, actual: "(非 JSON 响应)" };
}
actual = stringifyValue(queryJsonPath(parsed, a.locator));
} else {
actual = res.body;
}
}
}
const expectedNum = Number(a.expected);
const numeric = num !== null && a.expected.trim() !== "" && !Number.isNaN(expectedNum);
let passed = false;
switch (a.op) {
case "eq":
passed = numeric ? num === expectedNum : actual.trim() === a.expected.trim();
break;
case "neq":
passed = numeric ? num !== expectedNum : actual.trim() !== a.expected.trim();
break;
case "lt":
passed = numeric && num !== null && num < expectedNum;
break;
case "gt":
passed = numeric && num !== null && num > expectedNum;
break;
case "contains":
passed = actual.includes(a.expected);
break;
case "not_contains":
passed = !actual.includes(a.expected);
break;
case "exists":
passed = actual !== "";
break;
case "not_exists":
passed = actual === "";
break;
case "jsonpath_eq":
passed = actual.trim() === a.expected.trim();
break;
case "jsonpath_contains":
passed = actual.includes(a.expected);
break;
}
return { ...base, passed, actual: actual.length > 300 ? `${actual.slice(0, 300)}…` : actual };
}
/** 按 HTTP 节点推断默认断言 */
export function suggestAssertions(data: HttpRequestData): TestAssertion[] {
const mk = (target: TestAssertion["target"], locator: string, op: AssertOp, expected: string): TestAssertion => ({
id: `as-${Math.random().toString(36).slice(2, 10)}`,
enabled: true,
target,
locator,
op,
expected,
});
const out = [mk("status", "", "eq", "200"), mk("timeMs", "", "lt", "5000")];
return out;
}
/** 执行变量提取:返回 { 变量名: 值 } */
export function extractVariables(res: StepHttpResponse, extracts: VariableExtract[]): Record<string, string> {
const out: Record<string, string> = {};
let parsed: unknown;
let parsedOnce = false;
for (const e of extracts) {
if (!e.varName.trim() || !e.jsonpath.trim()) continue;
if (!parsedOnce) {
try {
parsed = JSON.parse(res.body);
} catch {
parsed = undefined;
}
parsedOnce = true;
}
const v = queryJsonPath(parsed, e.jsonpath);
if (v !== undefined) out[e.varName.trim()] = stringifyValue(v);
}
return out;
}
/* ---------------- 报告导出 ---------------- */
export function exportRunMarkdown(run: {
caseName: string;
at: number;
trigger: string;
totalMs: number;
passed: boolean;
stepResults: {
name: string;
method: string;
url: string;
ok: boolean;
httpStatus: number | null;
durationMs: number | null;
error: string | null;
assertionResults: AssertionResult[];
}[];
}): string {
const dt = new Date(run.at).toLocaleString("zh-CN");
const total = run.stepResults.length;
const okCount = run.stepResults.filter((s) => s.ok).length;
const lines: string[] = [
`# 测试报告 · ${run.caseName}`,
"",
`- 时间:${dt}`,
`- 触发:${run.trigger === "schedule" ? "定时任务" : run.trigger === "ai" ? "AI 流程" : "手动运行"}`,
`- 结果:**${run.passed ? "✅ 全部通过" : "❌ 存在失败"}**(${okCount}/${total} 步骤通过)`,
`- 总耗时:${run.totalMs} ms`,
"",
];
for (const [i, s] of run.stepResults.entries()) {
lines.push(`## ${i + 1}. ${s.ok ? "✅" : "❌"} ${s.name}`);
lines.push("");
lines.push(`- 请求:\`${s.method} ${s.url}\``);
if (s.httpStatus !== null) lines.push(`- 状态码:${s.httpStatus}`);
if (s.durationMs !== null) lines.push(`- 耗时:${s.durationMs} ms`);
if (s.error) lines.push(`- 错误:${s.error}`);
if (s.assertionResults.length) {
lines.push("", "| 断言 | 期望 | 实际 | 结果 |", "| --- | --- | --- | --- |");
for (const a of s.assertionResults) {
lines.push(`| ${a.label} | ${a.expected} | ${a.actual || "—"} | ${a.passed ? "✅" : "❌"} |`);
}
}
lines.push("");
}
return lines.join("\n");
}
/* ---------------- 用例构造 ---------------- */
/** JSON 报告(便于 CI / 二次处理) */
export function exportRunJson(run: {
id?: string;
caseId?: string;
caseName: string;
at: number;
trigger: string;
totalMs: number;
passed: boolean;
stepResults: {
name: string;
method: string;
url: string;
ok: boolean;
httpStatus: number | null;
durationMs: number | null;
error: string | null;
assertionResults: AssertionResult[];
}[];
}): string {
return JSON.stringify(
{
schema: "opsxapi.test-run.v1",
id: run.id ?? null,
caseId: run.caseId ?? null,
caseName: run.caseName,
at: run.at,
trigger: run.trigger,
totalMs: run.totalMs,
passed: run.passed,
stats: runStats(run),
stepResults: run.stepResults,
},
null,
2,
);
}
export function newAssertion(): TestAssertion {
return {
id: `as-${Math.random().toString(36).slice(2, 10)}`,
enabled: true,
target: "status",
locator: "",
op: "eq",
expected: "200",
};
}
export function newExtract(): VariableExtract {
return { id: `ex-${Math.random().toString(36).slice(2, 10)}`, jsonpath: "$.", varName: "" };
}
export function newStep(nodeId: string, data?: HttpRequestData): TestStep {
return {
id: `st-${Math.random().toString(36).slice(2, 10)}`,
enabled: true,
nodeId,
assertions: data ? suggestAssertions(data) : [newAssertion()],
extracts: [],
};
}
export function newTestCase(name = "未命名用例"): TestCase {
return {
id: `tc-${Math.random().toString(36).slice(2, 10)}`,
name,
continueOnFailure: false,
steps: [],
createdAt: Date.now(),
updatedAt: Date.now(),
};
}
/** 统计:通过/失败步骤数 */
export function runStats(run: { stepResults: { ok: boolean }[] }): { ok: number; fail: number } {
const ok = run.stepResults.filter((s) => s.ok).length;
return { ok, fail: run.stepResults.length - ok };
}
+227
View File
@@ -0,0 +1,227 @@
/**
* 集合树纯函数操作(不可变更新)。
*/
import type {
Collection,
CollectionNode,
FolderNode,
RequestNode,
} from "./model";
export type NodeRef = {
collection: Collection;
/** 父目录;集合顶层时为 null */
parent: FolderNode | null;
node: CollectionNode;
};
export function newId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
export function walk(
nodes: CollectionNode[],
fn: (node: CollectionNode, parent: FolderNode | null) => void,
parent: FolderNode | null = null,
): void {
for (const node of nodes) {
fn(node, parent);
if (node.type === "folder") walk(node.children, fn, node);
}
}
export function findNode(
collections: Collection[],
id: string,
): NodeRef | null {
for (const collection of collections) {
let found: NodeRef | null = null;
walk(collection.children, (node, parent) => {
if (!found && node.id === id) found = { collection, parent, node };
});
if (found) return found;
}
return null;
}
export function mapTree<T extends CollectionNode>(
nodes: T[],
fn: (node: T) => T,
): T[] {
return nodes.map((node) => {
const mapped = fn(node);
if (mapped.type === "folder") {
return { ...mapped, children: mapTree(mapped.children as T[], fn) };
}
return mapped;
});
}
/** 返回新的 collections 数组;未命中时原样返回 */
export function replaceNode(
collections: Collection[],
id: string,
updater: (node: CollectionNode) => CollectionNode,
): Collection[] {
const visit = (nodes: CollectionNode[]): CollectionNode[] =>
nodes.map((node) => {
if (node.id === id) return updater(node);
if (node.type === "folder") return { ...node, children: visit(node.children) };
return node;
});
let changed = false;
const result = collections.map((c) => {
const children = visit(c.children);
if (children !== c.children) changed = true;
return changed || children !== c.children ? { ...c, children } : c;
});
return result;
}
export function removeNode(
collections: Collection[],
id: string,
): Collection[] {
const visit = (nodes: CollectionNode[]): CollectionNode[] =>
nodes
.filter((node) => node.id !== id)
.map((node) =>
node.type === "folder" ? { ...node, children: visit(node.children) } : node,
);
return collections.map((c) => ({ ...c, children: visit(c.children) }));
}
export function insertNode(
collections: Collection[],
target: { parentId: string | null },
node: CollectionNode,
): Collection[] {
const visit = (nodes: CollectionNode[]): CollectionNode[] => {
if (target.parentId === null) return [...nodes, node];
return nodes.map((n) => {
if (n.type === "folder") {
if (n.id === target.parentId) return { ...n, children: [...n.children, node] };
return { ...n, children: visit(n.children) };
}
return n;
});
};
// parentId 为 null 时插入第一个集合顶层(调用方一般会用 appendToCollection)
return collections.map((c) => ({ ...c, children: visit(c.children) }));
}
/** 插入到指定集合(collectionId)的指定目录(parentId 可为 null = 顶层) */
export function insertIntoCollection(
collections: Collection[],
collectionId: string,
parentId: string | null,
node: CollectionNode,
): Collection[] {
return collections.map((c) => {
if (c.id !== collectionId) return c;
if (parentId === null) return { ...c, children: [...c.children, node] };
const visit = (nodes: CollectionNode[]): CollectionNode[] =>
nodes.map((n) => {
if (n.type !== "folder") return n;
if (n.id === parentId) return { ...n, children: [...n.children, node] };
return { ...n, children: visit(n.children) };
});
return { ...c, children: visit(c.children) };
});
}
/** 把节点移动到目标目录(跨集合移动也支持;null = 集合顶层) */
export function moveNode(
collections: Collection[],
id: string,
targetCollectionId: string,
targetParentId: string | null,
): Collection[] {
const ref = findNode(collections, id);
if (!ref) return collections;
if (id === targetParentId) return collections; // 不能移动到自己下面
// 目标是自己后代时拒绝
if (ref.node.type === "folder" && containsId(ref.node.children, targetParentId)) {
return collections;
}
const node = ref.node;
const removed = removeNode(collections, id);
return insertIntoCollection(removed, targetCollectionId, targetParentId, node);
}
export function containsId(nodes: CollectionNode[], id: string | null): boolean {
if (id === null) return false;
let hit = false;
walk(nodes, (n) => {
if (n.id === id) hit = true;
});
return hit;
}
export function deepCopyNode(node: CollectionNode, rename?: (name: string) => string): CollectionNode {
if (node.type === "request") {
return { ...node, id: newId(), name: rename ? rename(node.name) : node.name, createdAt: Date.now(), updatedAt: Date.now() };
}
return {
...node,
id: newId(),
name: rename ? rename(node.name) : node.name,
children: node.children.map((c) => deepCopyNode(c, rename)),
createdAt: Date.now(),
updatedAt: Date.now(),
};
}
export function uniqueName(siblings: CollectionNode[], base: string): string {
const names = new Set(siblings.map((n) => n.name));
if (!names.has(base)) return base;
for (let i = 2; i < 999; i++) {
const candidate = `${base} ${i}`;
if (!names.has(candidate)) return candidate;
}
return `${base} ${Date.now()}`;
}
export function countRequests(collection: Collection): number {
let n = 0;
walk(collection.children, (node) => {
if (node.type === "request") n++;
});
return n;
}
/** 名称匹配搜索(大小写不敏感),返回命中的请求/目录 id 集合(目录命中自身或含命中后代) */
export function searchTree(
collections: Collection[],
term: string,
): Set<string> {
const q = term.trim().toLowerCase();
const hits = new Set<string>();
if (!q) return hits;
const visitFolder = (nodes: CollectionNode[], ancestors: string[]): boolean => {
let any = false;
for (const node of nodes) {
if (node.type === "request") {
const text = `${node.name} ${node.kind} ${
node.kind === "http" ? node.data.method + " " + node.data.url : ""
}`.toLowerCase();
if (text.includes(q)) {
hits.add(node.id);
any = true;
}
} else {
const childHit = visitFolder(node.children, [...ancestors, node.id]);
if (childHit || node.name.toLowerCase().includes(q)) {
hits.add(node.id);
any = true;
}
}
}
return any;
};
for (const c of collections) visitFolder(c.children, [c.id]);
return hits;
}
+58
View File
@@ -0,0 +1,58 @@
export type HttpMethod =
| "GET"
| "POST"
| "PUT"
| "PATCH"
| "DELETE"
| "HEAD"
| "OPTIONS";
export type HttpBodyMode =
| "none"
| "json"
| "raw"
| "xml"
| "text"
| "html"
| "urlencoded"
| "form"
| "form-data";
export type HttpSpec = {
method: HttpMethod;
url: string;
headers: Record<string, string>;
query?: Record<string, string>;
body?: {
mode: HttpBodyMode;
raw?: string;
};
timeoutMs?: number;
proxy?: { mode: "automatic" | "disabled" | "custom"; http: string; https: string; bypass: string; auth?: { username: string; password: string } };
};
export type WsSpec = {
url: string;
protocols?: string[];
headers?: Record<string, string>;
};
export type HttpResult = {
status: number;
headers: Record<string, string>;
body: string;
timeMs: number;
size: number;
};
export interface WsSession {
onMessage(cb: (data: string) => void): void;
onStatus(cb: (s: string) => void): void;
send(data: string): void;
close(): void;
}
export interface Transport {
http(spec: HttpSpec): Promise<HttpResult>;
ws(spec: WsSpec): WsSession;
}
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useState } from "react";
import type { ApiDocumentation, CollectionNode, HttpApiNode } from "../../core/model";
import { useWorkspace } from "../../workspaceStore";
import { Button, EmptyState } from "../../components/primitives";
import { Icon } from "../../components/icons";
import { cx } from "../../components/common";
import { localMockCall, mockEndpointUrl, readShareOrigin, type LocalMockStatus } from "../mock/localMock";
import { findMockForNode, useMockRoutes, validateRoute, type MockRoute } from "../mock/mockStore";
import { mockFromDoc, mockPathFromDoc } from "./mockFromDoc";
import { localDocsCall, publishedSnapshot, type PublishedDoc } from "./localDocs";
import { findNode } from "../../core";
import { rememberedDocument, rememberDocument } from "./docNavigation";
import { WorkspaceSidebar } from "../../components/WorkspaceSidebar";
function httpNodes(nodes: CollectionNode[]): HttpApiNode[] {
return nodes.flatMap((node) => node.type === "folder" ? httpNodes(node.children) : node.kind === "http" ? [node] : []);
}
export function DocsView() {
const projects = useWorkspace((s) => s.collections);
const [navigationTarget] = useState(rememberedDocument);
const [projectId, setProjectId] = useState<string | null>(() => findNode(projects, navigationTarget ?? "")?.collection.id ?? null);
const [selectedId, setSelectedId] = useState<string | null>(navigationTarget);
const [search, setSearch] = useState("");
const [message, setMessage] = useState("");
const [server, setServer] = useState<LocalMockStatus | null>(null);
const [shared, setShared] = useState<PublishedDoc[]>([]);
const [sharing, setSharing] = useState(false);
const project = projects.find((p) => p.id === projectId) ?? projects[0];
const endpoints = project ? httpNodes(project.children) : [];
const selected = endpoints.find((n) => n.id === selectedId) ?? endpoints[0];
const setDialog = useWorkspace((s) => s.setDialog);
const openTab = useWorkspace((s) => s.openTab);
const setView = useWorkspace((s) => s.setView);
const routes = useMockRoutes((s) => s.routes);
useEffect(() => { let active = true; localMockCall<LocalMockStatus>("GET").then((v) => { if (active) { useMockRoutes.getState().importPublished(v.endpoints); setServer(v); } }).catch(() => {}); return () => { active = false; }; }, []);
useEffect(() => { let active = true; localDocsCall<{ projects: PublishedDoc[] }>("GET").then((v) => { if (active) setShared(v.projects); }).catch(() => {}); return () => { active = false; }; }, []);
const published = shared.find((d) => d.projectId === project?.id);
const shareUrl = published ? `${readShareOrigin() || "http://127.0.0.1:8788"}/docs/${encodeURIComponent(published.token)}` : "";
const publishDocs = async () => {
if (!project) return;
setSharing(true); setMessage("");
try {
const snapshot = publishedSnapshot(project, server);
const doc = await localDocsCall<PublishedDoc>("PUT", snapshot);
setShared((items) => [...items.filter((item) => item.projectId !== doc.projectId), doc]);
setMessage("只读文档快照已发布;修改后需重新发布才能更新内网页面");
} catch (e) { setMessage(String(e)); } finally { setSharing(false); }
};
const revokeDocs = async () => {
if (!project) return;
setSharing(true); setMessage("");
try { await localDocsCall("DELETE", undefined, project.id); setShared((items) => items.filter((d) => d.projectId !== project.id)); setMessage("分享链接已失效"); }
catch (e) { setMessage(String(e)); } finally { setSharing(false); }
};
let selectedPath = "";
try { if (selected) selectedPath = mockPathFromDoc(selected); } catch { /* Show the path guidance when creating a Mock. */ }
const route = selected ? findMockForNode(routes, selected, selectedPath) : undefined;
const routePath = route?.path ?? selectedPath;
const isPublished = !!server?.active && !!selected && server.endpoints.some((e) => e.method === (route?.method ?? selected.data.method) && e.path === routePath);
const mockUrl = isPublished && server ? mockEndpointUrl(readShareOrigin() || "http://127.0.0.1:8788", server.token, routePath) : "";
const mockState = isPublished ? (server?.enabled ? "已发布 · HTTP 响应开启" : "已发布 · HTTP 响应关闭") : route ? "已保存草稿 · 待发布" : "尚未创建 Mock";
const addMock = () => {
if (!selected) return;
try {
const savedRoutes = useMockRoutes.getState().routes;
const linked = savedRoutes.find((item) => item.sourceNodeId === selected.id);
const existing = linked ?? findMockForNode(savedRoutes, selected, mockPathFromDoc(selected));
if (existing) {
if (!existing.sourceNodeId) useMockRoutes.getState().save({ ...existing, sourceNodeId: selected.id });
useMockRoutes.getState().select(existing.id);
setView("mock");
return;
}
const proposed = mockFromDoc(selected);
const saved = useMockRoutes.getState().routes;
const error = validateRoute(proposed, saved);
if (error) throw new Error(error);
useMockRoutes.getState().save(proposed);
setView("mock");
} catch (e) { setMessage(e instanceof Error ? e.message : String(e)); }
};
const refreshMockFromDoc = () => {
if (!selected || !route) return;
try {
const next = { ...route, ...mockFromDoc(selected), id: route.id, enabled: route.enabled, delayMs: route.delayMs };
const error = validateRoute(next, useMockRoutes.getState().routes);
if (error) throw new Error(error);
if (!confirm(`用文档中的响应示例覆盖「${route.name}」的模拟响应?请先保存文档更改。`)) return;
useMockRoutes.getState().save(next);
setMessage("已从文档更新 Mock 草稿;请到 Mock 页面发布后生效");
} catch (cause) { setMessage(cause instanceof Error ? cause.message : String(cause)); }
};
const grouped = new Map<string, HttpApiNode[]>();
for (const node of endpoints.filter((n) => `${n.name} ${n.data.url} ${n.data.method} ${n.documentation?.tags.join(" ") ?? ""}`.toLowerCase().includes(search.toLowerCase()))) {
const tag = node.documentation?.tags[0] || "未分组";
grouped.set(tag, [...(grouped.get(tag) || []), node]);
}
return <div className="docs-layout flex min-h-0 min-w-0 flex-1">
<WorkspaceSidebar label="文档" className="docs-sidebar">
<div className="docs-sidebar-top">
<div className="docs-sidebar-heading"><div><span className="docs-eyebrow">WORKSPACE</span><h2>API 文档</h2></div><span className="docs-count">{endpoints.length}</span></div>
<select className="ops-input" aria-label="选择文档项目" value={project?.id ?? ""} onChange={(e) => { setProjectId(e.target.value); setSelectedId(null); }}>
{projects.map((p) => <option value={p.id} key={p.id}>{p.name}</option>)}
</select>
<Button className="docs-import-button w-full" onClick={() => setDialog({ type: "import" })}><Icon name="download" size={14} /> 导入 OpenAPI / Swagger</Button>
<details className="docs-share">
<summary><span className="flex items-center gap-2"><Icon name="globe" size={14} /> 内网文档分享</span><span className={cx("docs-share-state", published && "is-published")}>{published ? "已发布" : "未发布"}</span></summary>
<div className="docs-share-content">
<p>生成当前项目的只读快照,供内网成员访问。</p>
<Button className="w-full" busy={sharing} disabled={!project || !endpoints.length} onClick={publishDocs}>{published ? "更新分享内容" : "发布当前项目"}</Button>
{shareUrl && <><code className="docs-share-url" title={shareUrl}>{shareUrl}</code><div className="flex gap-2"><Button className="flex-1" onClick={async () => { try { await navigator.clipboard.writeText(shareUrl); setMessage("已复制文档地址"); } catch { setMessage("复制失败"); } }}><Icon name="copy" size={13} /> 复制地址</Button><Button variant="ghost" busy={sharing} onClick={revokeDocs}>撤销</Button></div></>}
<p className="docs-share-hint">通过分享地址设置可访问的 IP 或域名。</p>
</div>
</details>
<label className="docs-search"><Icon name="search" size={14} /><input aria-label="搜索接口文档" placeholder="搜索接口、路径或标签" value={search} onChange={(e) => setSearch(e.target.value)} /></label>
</div>
<nav className="docs-tree ops-scroll" aria-label="接口文档列表">
{[...grouped].map(([tag, items]) => <section key={tag} className="docs-group"><h3>{tag}<span>{items.length}</span></h3>{items.map((node) => <button key={node.id} onClick={() => setSelectedId(node.id)} title={`${node.data.method} ${node.data.url}\n${node.name}`} className={cx("docs-endpoint", selected?.id === node.id && "active")} aria-current={selected?.id === node.id ? "page" : undefined}><span className={cx("docs-method", `ops-method-${node.data.method}`)}>{node.data.method}</span><span className="truncate">{node.name}</span></button>)}</section>)}
{endpoints.length > 0 && grouped.size === 0 && <p className="docs-tree-empty">没有匹配的接口</p>}
{endpoints.length === 0 && <p className="docs-tree-empty">暂无接口,导入 OpenAPI 或新建请求后会显示在这里。</p>}
</nav>
</WorkspaceSidebar>
<main className="docs-main ops-scroll min-w-0 flex-1">
{selected ? <DocumentDetail key={`${selected.id}-${selected.updatedAt}`} node={selected} projectName={project?.name ?? ""} mockUrl={mockUrl} mockState={mockState} mockRoute={route} message={message} onTry={() => { openTab(selected.id); setView("api"); }} onMock={addMock} onRefreshMock={refreshMockFromDoc} /> : <EmptyState title="还没有 HTTP 接口">导入 OpenAPI/Swagger,或在接口页面新建请求。</EmptyState>}
</main>
</div>;
}
function DocumentDetail({ node, projectName, mockUrl, mockState, mockRoute, message, onTry, onMock, onRefreshMock }: { node: HttpApiNode; projectName: string; mockUrl: string; mockState: string; mockRoute?: MockRoute; message: string; onTry: () => void; onMock: () => void; onRefreshMock: () => void }) {
const updateDescription = useWorkspace((s) => s.updateNodeDescription);
const updateDocumentation = useWorkspace((s) => s.updateApiDocumentation);
const [description, setDescription] = useState(node.description ?? "");
const [doc, setDoc] = useState<ApiDocumentation>(node.documentation ?? { tags: [], parameters: [], responses: [] });
const [saved, setSaved] = useState("");
const dirty = description !== (node.description ?? "") || JSON.stringify(doc) !== JSON.stringify(node.documentation ?? { tags: [], parameters: [], responses: [] });
const editDoc = (next: ApiDocumentation) => { setDoc(next); setSaved(""); };
const editResponse = (index: number, patch: Partial<ApiDocumentation["responses"][number]>) =>
editDoc({ ...doc, responses: doc.responses.map((row, at) => at === index ? { ...row, ...patch } : row) });
return <div className="docs-detail">
<header className="docs-detail-header">
<div className="docs-breadcrumb"><span>{projectName}</span><Icon name="chevron-right" size={13} /><span>接口文档</span></div>
<div className="docs-title-row"><div className="min-w-0"><h1>{node.name}</h1><div className="docs-path"><span className={cx("docs-method", `ops-method-${node.data.method}`)}>{node.data.method}</span><code title={node.data.url}>{node.data.url}</code></div></div>
<div className="docs-actions"><Button onClick={onTry}><Icon name="play" size={13} /> 试一试</Button><Button disabled={dirty} title={dirty ? "请先保存文档更改" : mockRoute ? "编辑关联 Mock" : "根据响应示例创建 Mock"} onClick={onMock}><Icon name="flask-conical" size={14} /> {mockRoute ? "打开 Mock" : "生成 Mock"}</Button><Button variant="primary" disabled={!dirty} onClick={() => { updateDescription(node.id, description); updateDocumentation(node.id, doc); useWorkspace.getState().showToast("文档已保存", "success"); }}><Icon name="file-text" size={14} /> 保存文档</Button></div>
</div>
</header>
<div className="docs-detail-content">
{(message || saved) && <p role="status" className="docs-notice">{message || saved}</p>}
<div className="docs-overview-grid">
<label className="docs-field">说明<textarea className="ops-input docs-description" value={description} onChange={(e) => { setDescription(e.target.value); setSaved(""); }} placeholder="填写接口用途与调用说明" /></label>
<label className="docs-field">标签 <span className="docs-field-hint">逗号分隔</span><input className="ops-input" value={doc.tags.join(", ")} onChange={(e) => editDoc({ ...doc, tags: e.target.value.split(/[,,]/).map((s) => s.trim()).filter(Boolean) })} placeholder="例如:用户、订单" /></label>
</div>
<section className="docs-section"><div className="docs-section-heading"><h2>请求参数</h2><span>{doc.parameters.length} 项</span></div>{doc.parameters.length ? <div className="docs-table-wrap"><table className="ops-table docs-params-table"><thead><tr><th>名称</th><th>位置</th><th>类型</th><th>必填</th><th>示例</th><th>说明</th></tr></thead><tbody>{doc.parameters.map((p, i) => <tr key={`${p.in}-${p.name}-${i}`}><td><code>{p.name}</code></td><td>{p.in}</td><td>{p.type}</td><td>{p.required ? "是" : "否"}</td><td><input className="ops-input font-mono" aria-label={`${p.name} 示例`} value={p.example} onChange={(e) => editDoc({ ...doc, parameters: doc.parameters.map((row, index) => index === i ? { ...row, example: e.target.value } : row) })} /></td><td>{p.description || "—"}</td></tr>)}</tbody></table></div> : <p className="docs-section-empty">此接口尚无参数定义</p>}</section>
{doc.requestBody && <section className="docs-section"><div className="docs-section-heading"><h2>请求体</h2><code>{doc.requestBody.contentType}</code></div>{doc.requestBody.description && <p className="docs-section-caption">{doc.requestBody.description}</p>}<textarea spellCheck={false} className="ops-input docs-code-input" aria-label="请求体示例" value={doc.requestBody.example} onChange={(e) => editDoc({ ...doc, requestBody: { ...doc.requestBody!, example: e.target.value } })} /></section>}
<section className="docs-section"><div className="docs-section-heading"><h2>响应示例</h2><Button onClick={() => editDoc({ ...doc, responses: [...doc.responses, { status: "200", description: "", contentType: "application/json", example: "{}" }] })}><Icon name="plus" size={13} /> 添加响应</Button></div>{doc.responses.length ? doc.responses.map((r, i) => <div key={i} className="docs-response"><div className="docs-response-fields"><label className="docs-field">状态码<input className="ops-input font-mono" aria-label={`响应 ${i + 1} 状态码`} value={r.status} onChange={(e) => editResponse(i, { status: e.target.value })} /></label><label className="docs-field">格式<input className="ops-input font-mono" aria-label={`响应 ${i + 1} 格式`} value={r.contentType} onChange={(e) => editResponse(i, { contentType: e.target.value })} /></label><label className="docs-field">说明<input className="ops-input" aria-label={`响应 ${i + 1} 说明`} placeholder="响应说明" value={r.description} onChange={(e) => editResponse(i, { description: e.target.value })} /></label><Button variant="ghost" className="docs-remove-response" title="删除此响应示例" aria-label={`删除响应示例 ${i + 1}`} onClick={() => editDoc({ ...doc, responses: doc.responses.filter((_, at) => at !== i) })}><Icon name="trash" size={14} /></Button></div><label className="docs-field">返回示例<textarea spellCheck={false} className="ops-input docs-code-input" aria-label={`HTTP ${r.status} 响应示例`} value={r.example} onChange={(e) => editResponse(i, { example: e.target.value })} /></label></div>) : <p className="docs-section-empty">尚无响应示例,添加后可用于生成 Mock。</p>}</section>
<section className="docs-section docs-mock-section"><div className="docs-section-heading"><h2>关联 Mock</h2><span className={cx("docs-mock-state", mockRoute && "linked")}>{mockState}</span></div>{mockUrl ? <code className="docs-mock-url">{mockUrl}</code> : <p className="docs-section-caption">从响应示例生成 Mock,发布后在此显示访问地址。</p>}{mockRoute && <><p className="docs-section-caption">模拟响应 · {mockRoute.status} · {mockRoute.contentType}</p><details className="docs-mock-preview"><summary>查看模拟返回内容</summary><pre className="ops-code">{mockRoute.body}</pre></details><div className="docs-mock-actions"><Button disabled={dirty} onClick={onRefreshMock}>用文档示例更新 Mock</Button><Button onClick={() => {
const responses = [...doc.responses];
const index = responses.findIndex((r) => r.status === String(mockRoute.status));
const response = { ...(responses[index] ?? { status: String(mockRoute.status), description: "" }), contentType: mockRoute.contentType, example: mockRoute.body };
if (index < 0) responses.push(response); else responses[index] = response;
editDoc({ ...doc, responses }); setSaved("已填入文档,请保存更改");
}}>将 Mock 响应填入文档</Button></div></>}<p className="docs-section-caption">Mock 与文档分别保存;更新 Mock 后需重新发布,分享文档修改后需更新快照。</p></section>
</div>
</div>;
}
+13
View File
@@ -0,0 +1,13 @@
const selectedDocumentKey = "opsxapi.docs.selectedNode";
export function rememberDocument(nodeId: string) {
try { sessionStorage.setItem(selectedDocumentKey, nodeId); } catch { /* Optional navigation memory. */ }
}
export function rememberedDocument(): string | null {
try {
const id = sessionStorage.getItem(selectedDocumentKey);
sessionStorage.removeItem(selectedDocumentKey);
return id;
} catch { return null; }
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { createCollection, createFolder, createRequest } from "../../core/factory";
import { mergeProject } from "./merge";
import { mockFromDoc, mockPathFromDoc } from "./mockFromDoc";
import { publishedSnapshot } from "./localDocs";
describe("文档同步与 Mock", () => {
it("合并时复用请求 ID、保留旧接口;覆盖时移除旧接口", () => {
const old = createRequest("http", "old"); old.data.url = "/old";
const before = createRequest("http", "edited"); before.data.url = "/items";
before.description = "本地备注"; before.descriptionEdited = true;
before.documentation = { tags: [], parameters: [], responses: [{ status: "200", contentType: "text/plain", description: "", example: "手工示例" }] };
before.documentationEdited = true;
const next = createRequest("http", "imported"); next.data.url = "/items";
const existing = createCollection("Project", [createFolder("Tag", [old, before])]);
const incoming = createCollection("Project", [createFolder("Tag", [next])]);
const merged = mergeProject(existing, incoming, "merge");
const folders = merged.children.filter((n) => n.type === "folder");
expect(folders.length).toBe(1);
if (folders[0].type !== "folder") throw new Error();
expect(folders[0].children[0]).toMatchObject({ id: before.id, name: "imported", description: "本地备注", documentation: before.documentation });
expect(JSON.stringify(merged)).toContain(old.id);
expect(JSON.stringify(mergeProject(existing, incoming, "replace"))).not.toContain(old.id);
});
it("用响应示例生成精确路径 Mock,缺示例拒绝", () => {
const node = createRequest("http", "查询"); node.data.url = "/pets/{{id}}";
node.documentation = { tags: ["pets"], parameters: [{ name: "id", in: "path", required: true, type: "string", description: "", example: "42" }], responses: [{ status: "201", description: "", contentType: "application/json", example: '{"ok":true}' }] };
expect(mockFromDoc(node)).toMatchObject({ sourceNodeId: node.id, path: "/pets/42", status: 201, body: '{"ok":true}' });
expect(mockPathFromDoc(node)).toBe("/pets/42");
const project = createCollection("Pets", [node]);
const snapshot = publishedSnapshot(project, { enabled: true, allowPublic: false, active: true, bearerRequired: false, bearerConfigured: false, token: "demo-token", endpoints: [{ method: "GET", path: "/pets/42", status: 201, contentType: "application/json", body: '{"ok":true}', delayMs: 0 }], expiresAt: 0, addresses: [], listenAddr: "0.0.0.0:8788" });
expect(snapshot.endpoints[0]).toMatchObject({ path: "/pets/{{id}}", mockUrl: "http://127.0.0.1:8788/mock/demo-token/pets/42" });
node.documentation.responses = [];
expect(() => mockFromDoc(node)).toThrow(/响应示例/);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { isTauriRuntime, tauriNativeTransport } from "../../transport";
import type { Collection } from "../../core/model";
import { mockEndpointUrl, readShareOrigin, type LocalMockStatus } from "../mock/localMock";
import { mockPathFromDoc } from "./mockFromDoc";
export type PublishedDoc = { projectId: string; name: string; token: string; updatedAt: number };
export async function localDocsCall<T>(method: "GET" | "PUT" | "DELETE", payload?: unknown, projectId?: string): Promise<T> {
const body = payload === undefined ? undefined : JSON.stringify(payload);
const path = `/api/docs/local${projectId ? `/${encodeURIComponent(projectId)}` : ""}`;
let status: number; let content: string;
if (isTauriRuntime()) {
const result = await tauriNativeTransport.http({ method, url: `http://127.0.0.1:8787${path}`, headers: { "Content-Type": "application/json" }, body: body ? { mode: "json", raw: body } : undefined, timeoutMs: 10000, proxy: { mode: "disabled", http: "", https: "", bypass: "" } });
status = result.status; content = result.body;
} else {
const result = await fetch(path, { method, headers: { "Content-Type": "application/json" }, body });
status = result.status; content = await result.text();
}
let data: T & { error?: string };
try { data = JSON.parse(content); } catch { throw new Error("本机文档服务响应无效,请重新启动 ./run.sh gui"); }
if (status >= 400) throw new Error(data.error || `文档服务返回 ${status}`);
return data;
}
/** Whitelist shareable fields; auth, environment secrets, and runtime never leave the client. */
export function publishedSnapshot(project: Collection, server: LocalMockStatus | null) {
const endpoints: ReturnType<typeof flatten> = flatten(project.children);
const origin = readShareOrigin() || "http://127.0.0.1:8788";
return { projectId: project.id, name: project.name, endpoints: endpoints.map((node) => {
const path = node.data.url.replace(/^\{\{baseUrl\}\}/, "");
let mockPath = "";
try { mockPath = mockPathFromDoc(node); } catch { /* Keep documenting routes without concrete examples. */ }
const active = server?.active && server.endpoints.some((e) => e.method === node.data.method && e.path === mockPath);
return { method: node.data.method, path, name: node.name, description: node.description || "", parameters: node.documentation?.parameters || [], requestBody: node.documentation?.requestBody ? { contentType: node.documentation.requestBody.contentType, example: node.documentation.requestBody.example } : null,
responses: node.documentation?.responses.map(({ status, description, contentType, example }) => ({ status, description, contentType, example })) || [],
mockUrl: active ? mockEndpointUrl(origin, server.token, mockPath) : "" };
}) };
}
function flatten(nodes: Collection["children"]): import("../../core/model").HttpApiNode[] {
return nodes.flatMap((node) => node.type === "folder" ? flatten(node.children) : node.kind === "http" ? [node] : []);
}
+41
View File
@@ -0,0 +1,41 @@
import type { Collection, CollectionNode, HttpApiNode } from "../../core/model";
const identity = (node: HttpApiNode) => `${node.data.method} ${node.data.url.replace(/\/$/, "")}`;
export function mergeProject(existing: Collection, incoming: Collection, mode: "merge" | "replace"): Collection {
if (mode === "replace") return { ...incoming, id: existing.id, createdAt: existing.createdAt };
const known = new Map<string, HttpApiNode>();
const collect = (nodes: CollectionNode[]) => nodes.forEach((node) => {
if (node.type === "folder") collect(node.children);
else if (node.kind === "http") known.set(identity(node), node);
});
collect(existing.children);
const imported = new Set<string>();
const update = (nodes: CollectionNode[]): CollectionNode[] => nodes.flatMap<CollectionNode>((node) => {
if (node.type === "folder") return [{ ...node, children: update(node.children) }];
if (node.kind !== "http") return [node];
const key = identity(node);
if (imported.has(key)) return [];
imported.add(key);
const previous = known.get(key);
return [previous ? { ...node, id: previous.id, createdAt: previous.createdAt,
description: previous.descriptionEdited ? previous.description : node.description,
documentation: previous.documentationEdited ? previous.documentation : node.documentation,
descriptionEdited: previous.descriptionEdited, documentationEdited: previous.documentationEdited } : node];
});
const children = update(incoming.children);
const leftovers = (nodes: CollectionNode[]): CollectionNode[] => nodes.flatMap<CollectionNode>((node) => {
if (node.type === "folder") {
const remaining = leftovers(node.children);
return remaining.length ? [{ ...node, children: remaining }] : [];
}
return node.kind !== "http" || !imported.has(identity(node)) ? [node] : [];
});
for (const old of leftovers(existing.children)) {
const matching = old.type === "folder" ? children.findIndex((c) => c.type === "folder" && c.name === old.name) : -1;
if (matching >= 0 && old.type === "folder") {
const current = children[matching];
if (current.type === "folder") children[matching] = { ...current, children: [...current.children, ...old.children] };
} else children.push(old);
}
return { ...existing, description: incoming.description || existing.description, children, updatedAt: Date.now() };
}
+26
View File
@@ -0,0 +1,26 @@
import type { HttpApiNode } from "../../core/model";
import type { MockRoute } from "../mock/mockStore";
/** Mock v1 only supports exact paths: instantiate documented path parameters with examples. */
export function mockPathFromDoc(node: HttpApiNode): string {
let path = node.data.url.replace(/^\{\{baseUrl\}\}/, "").split(/[?#]/)[0];
if (!path.startsWith("/")) throw new Error("请先将请求地址关联环境,使用相对路径");
for (const param of node.documentation?.parameters ?? []) {
if (param.in !== "path") continue;
if (!param.example || /[/?#]/.test(param.example)) throw new Error(`请为路径参数 ${param.name} 填写无斜杠的示例值`);
path = path.replaceAll(`{{${param.name}}}`, encodeURIComponent(param.example)).replaceAll(`{${param.name}}`, encodeURIComponent(param.example));
}
if (/[{}]/.test(path)) throw new Error("路径中还有未填写示例值的参数");
return path;
}
export function mockFromDoc(node: HttpApiNode): MockRoute {
const response = node.documentation?.responses.find((r) => /^2\d\d$/.test(r.status) && r.example) ?? node.documentation?.responses.find((r) => r.example);
if (!response) throw new Error("文档没有响应示例或可推导的 schema,请先补充示例");
const path = mockPathFromDoc(node);
const contentType = response.contentType || "application/json";
const body = response.example;
if (contentType.includes("json")) JSON.parse(body);
return { id: crypto.randomUUID(), sourceNodeId: node.id, name: node.name, enabled: true, method: node.data.method, path,
status: /^\d{3}$/.test(response.status) ? Number(response.status) : 200, contentType, body, delayMs: 0 };
}
+74
View File
@@ -0,0 +1,74 @@
import { useWorkspace } from "../../workspaceStore";
import { useEffect, useState } from "react";
import { Button } from "../../components/primitives";
import { localMockCall, readShareOrigin, validateShareOrigin, type LocalMockStatus } from "./localMock";
export function MockSharingPanel() {
const [data, setData] = useState<LocalMockStatus | null>(null);
const [origin, setOrigin] = useState(readShareOrigin);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [busy, setBusy] = useState(false);
const [bearerToken, setBearerToken] = useState("");
const [showBearer, setShowBearer] = useState(false);
const refresh = async () => {
setBusy(true); setError("");
try { setData(await localMockCall<LocalMockStatus>("GET")); } catch (e) { setError(e instanceof Error ? e.message : String(e)); }
finally { setBusy(false); }
};
useEffect(() => { let disposed = false; setBusy(true); localMockCall<LocalMockStatus>("GET").then((v) => { if (!disposed) setData(v); }).catch((e) => { if (!disposed) setError(String(e.message || e)); }).finally(() => { if (!disposed) setBusy(false); }); return () => { disposed = true; }; }, []);
const chosen = origin || data?.addresses.find((a) => !a.includes("127.0.0.1")) || "http://127.0.0.1:8788";
let validOrigin = "";
try { validOrigin = validateShareOrigin(chosen); } catch { /* show validation below */ }
return <section className="ops-card space-y-3 p-4">
<div className="flex items-center justify-between"><h3 className="font-semibold">自托管 Mock · 发布与分享</h3><Button busy={busy} onClick={refresh}>刷新</Button></div>
<p className="ops-dim text-xs">模拟数据保存在本机,不上传第三方平台。访问地址支持本机 IP、内网域名及经用户自行端口映射或反向代理后的公网域名。</p>
{error && <p className="ops-danger text-xs" role="alert">{error}。请确认本机服务已启动。</p>}
{data && <>
<label className="flex items-center gap-2 text-sm"><input type="checkbox" disabled={busy} checked={data.enabled} onChange={async (e) => {
setBusy(true); setError("");
try { await localMockCall("PATCH", { enabled: e.target.checked }); await refresh(); }
catch (error) { setError(String(error)); } finally { setBusy(false); }
}} />启用 Mock HTTP 响应</label>
<p className="ops-faint text-xs">关闭后所有 Mock 请求返回 503,监听端口仍保留;接口和数据不会删除。</p>
<div className="space-y-2 border-t ops-border pt-3">
<h4 className="text-sm font-medium">访问验证(可选)</h4>
<p className="ops-faint text-xs">默认只需完整的 /mock/&lt;地址令牌&gt;/接口路径,无需额外 Header。需要限制已知地址的访问时,可再启用 Bearer 密钥。</p>
<div className="flex flex-wrap items-center justify-between gap-2 text-xs"><span>{data.bearerRequired ? "Bearer 验证已启用" : "Bearer 验证未启用"}</span>{data.bearerRequired && <Button busy={busy} onClick={async () => {
setBusy(true); setError("");
try { await localMockCall("PUT", { enabled: false }, "/api/mock/local/access"); setData(await localMockCall<LocalMockStatus>("GET")); setBearerToken(""); setNotice("已关闭 Bearer 验证,完整 Mock 地址现在可以直接访问"); }
catch (cause) { setError(String(cause)); } finally { setBusy(false); }
}}>关闭验证</Button>}</div>
<div className="flex flex-wrap gap-2">
<input aria-label="Mock Bearer 密钥" className="ops-input min-w-40 flex-1 font-mono text-xs" type={showBearer ? "text" : "password"} autoComplete="new-password" value={bearerToken} onChange={(event) => setBearerToken(event.target.value)} placeholder={data.bearerRequired ? "已配置;输入新密钥可替换" : "输入至少 16 位密钥"} />
<Button onClick={() => { const bytes = crypto.getRandomValues(new Uint8Array(24)); setBearerToken(Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")); setShowBearer(true); setNotice("已生成密钥,请先复制留存再保存"); }}>生成</Button>
<Button disabled={!bearerToken} onClick={async () => { try { await navigator.clipboard.writeText(bearerToken); setNotice("已复制 Bearer 密钥,请妥善留存"); } catch { setError("复制密钥失败,请手动复制"); } }}>复制</Button>
<Button onClick={() => setShowBearer((value) => !value)}>{showBearer ? "隐藏" : "显示"}</Button>
</div>
<Button busy={busy} disabled={busy || bearerToken.length < 16 || bearerToken.length > 256 || bearerToken.trim() !== bearerToken} onClick={async () => {
setBusy(true); setError("");
try { await localMockCall("PUT", { enabled: true, token: bearerToken }, "/api/mock/local/access"); setData(await localMockCall<LocalMockStatus>("GET")); setBearerToken(""); setShowBearer(false); setNotice("Bearer 验证已启用。以后请求须携带 Authorization Header;密钥不会再次显示"); }
catch (cause) { setError(String(cause)); } finally { setBusy(false); }
}}>{data.bearerRequired ? "更新 Bearer 密钥" : "启用 Bearer 验证"}</Button>
<p className="ops-faint text-xs">{data.bearerRequired ? "当前需要 Bearer 密钥" : "当前无需 Bearer 密钥"}。服务只保存密钥摘要;忘记密钥可在此重新生成并替换。分享给他人时分别发送完整地址和密钥。</p>
</div>
<p className="text-xs">{data.active ? "已发布" : "未发布或已过期"} · {data.endpoints.length} 个接口 · {data.expiresAt ? `有效期至 ${new Date(data.expiresAt * 1000).toLocaleString()}` : "不过期"}</p>
<label className="block text-xs">访问地址(IP / 自定义域名)
<input className="ops-input mt-1" list="mock-origins" value={chosen} onChange={(e) => { setOrigin(e.target.value); setNotice(""); }} placeholder="http://192.168.1.10:8788" />
<datalist id="mock-origins">{data.addresses.map((a) => <option key={a} value={a} />)}</datalist>
</label>
{!validOrigin && <p className="ops-danger text-xs">请输入 HTTP/HTTPS 根地址,不含路径、查询参数或账号密码。</p>}
<Button disabled={!validOrigin} onClick={() => { try { localStorage.setItem("opsxapi.mock.shareOrigin", validOrigin); setNotice("分享地址已保存"); } catch { setError("分享地址保存失败"); } }}>保存分享地址</Button>
<Button disabled={!data.active || !validOrigin} onClick={() => {
const state = useWorkspace.getState();
const id = state.environments.find((env) => env.name === "自托管 Mock")?.id ?? state.addEnvironment("自托管 Mock");
useWorkspace.getState().updateEnvironment(id, { baseUrl: `${validOrigin}/mock/${data.token}` });
useWorkspace.getState().setActiveEnvironment(id);
setNotice("已切换到自托管 Mock 环境,使用相对路径的请求会自动访问模拟接口");
}}>使用此 Mock 环境</Button>
<p className="ops-faint text-xs">监听 {data.listenAddr},只提供 Mock 响应。域名需解析到这台机器;HTTPS 需配置证书或反向代理。修改分享地址不会更改监听或配置 DNS。</p>
<p className="ops-faint text-xs">来源范围:{data.allowPublic ? "允许公网来源访问 Mock 响应" : "本机与内网;公网直连需设置 OPSXAPI_MOCK_ALLOW_PUBLIC=1 后重启服务"}。域名解析、端口映射和反向代理由你自行配置;管理接口不对外开放。</p>
<Button onClick={() => { useWorkspace.getState().setDialog(null); useWorkspace.getState().setView("mock"); }}>管理 Mock 接口与响应 →</Button>
</>}
{notice && <p className="ops-dim text-xs" role="status">{notice}</p>}
</section>;
}
+129
View File
@@ -0,0 +1,129 @@
import { useEffect, useState } from "react";
import { REQUEST_METHODS, findNode, type HttpMethod } from "../../core";
import { Button, EmptyState } from "../../components/primitives";
import { useWorkspace } from "../../workspaceStore";
import { isTauriRuntime, tauriNativeTransport } from "../../transport";
import { localMockCall, mockEndpointUrl, readShareOrigin, type LocalMockStatus } from "./localMock";
import { demoRoute, newRoute, useMockRoutes, validateRoute, type MockRoute } from "./mockStore";
import { rememberDocument } from "../docs/docNavigation";
import { WorkspaceSidebar } from "../../components/WorkspaceSidebar";
export function MockView() {
const { routes, selected, select, save, remove, importPublished } = useMockRoutes();
const [status, setStatus] = useState<LocalMockStatus | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [search, setSearch] = useState("");
const [dirtyRouteId, setDirtyRouteId] = useState<string | null>(null);
const route = routes.find((r) => r.id === selected) || routes[0];
const saved = routes.filter((r) => r.enabled);
const publishedMatches = !!status && saved.length === status.endpoints.length && saved.every((r) => status.endpoints.some((ep) => ep.method === r.method && ep.path === r.path && ep.status === r.status && ep.contentType === r.contentType && ep.body === r.body && ep.delayMs === r.delayMs));
const setDialog = useWorkspace((s) => s.setDialog);
const collections = useWorkspace((s) => s.collections);
const setView = useWorkspace((s) => s.setView);
const source = route?.sourceNodeId ? findNode(collections, route.sourceNodeId) : null;
const createDemo = () => act(() => {
const existing = useMockRoutes.getState().routes.find((r) => r.method === "GET" && r.path === "/demo");
if (existing) { select(existing.id); setNotice("已打开 GET /demo;如需对外访问,请发布接口"); return; }
save(demoRoute()); setNotice("已创建 GET /demo 草稿,点击「发布全部已保存接口」后即可访问");
});
useEffect(() => {
let disposed = false;
localMockCall<LocalMockStatus>("GET").then((data) => { if (!disposed) { importPublished(data.endpoints); setStatus(data); } }).catch((e) => { if (!disposed) setError(String(e.message || e)); });
return () => { disposed = true; };
}, [importPublished]);
useEffect(() => {
let disposed = false;
const refreshAccess = () => { localMockCall<LocalMockStatus>("GET").then((data) => { if (!disposed) setStatus(data); }).catch((cause) => { if (!disposed) setError(String(cause)); }); };
window.addEventListener("opsxapi:mock-access-changed", refreshAccess);
return () => { disposed = true; window.removeEventListener("opsxapi:mock-access-changed", refreshAccess); };
}, []);
const publish = async () => {
const current = useMockRoutes.getState().routes;
for (const r of current.filter((item) => item.enabled)) { const error = validateRoute(r, current.filter((item) => item.enabled)); if (error) { setError(`${r.name}:${error}`); useMockRoutes.getState().select(r.id); return; } }
setBusy(true); setError("");
try {
await localMockCall("PUT", { endpoints: current.filter((r) => r.enabled), ttlSeconds: 0 });
setStatus(await localMockCall<LocalMockStatus>("GET"));
setNotice("已发布全部已保存且启用的接口;禁用或删除的接口已从服务中移除");
} catch (e) { setError(String(e)); } finally { setBusy(false); }
};
const act = (callback: () => void) => { try { callback(); setError(""); } catch (e) { setError(`本机保存失败:${String(e)}`); } };
return <div className="flex min-h-0 min-w-0 flex-1">
<WorkspaceSidebar label="Mock">
<div className="space-y-2 p-3"><h2 className="font-semibold">Mock 接口</h2>
<p className="ops-faint text-xs">自托管服务 · {routes.length} 个接口</p>
<Button className="w-full" onClick={() => act(() => save(newRoute()))}>+ 新建接口</Button>
<Button className="w-full" onClick={createDemo}>创建 GET /demo 示例</Button>
<input className="ops-input" aria-label="搜索 Mock 接口" placeholder="搜索名称 / 路径" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<div className="ops-scroll min-h-0 flex-1 p-2">{routes.filter((r) => `${r.name} ${r.path}`.toLowerCase().includes(search.toLowerCase())).map((r) => <button key={r.id} className={`ops-tree-row w-full text-left ${r.id === route?.id ? "ops-tree-row-active" : ""}`} onClick={() => select(r.id)}>
<span className="ops-accent text-[11px]">{r.method}</span><span className="min-w-0 truncate text-xs">{r.name}</span>{!r.enabled && <span className="ops-faint text-[10px]">停用</span>}
</button>)}</div>
<Button className="m-3" onClick={() => setDialog({ type: "settings", tab: "mock" })}>服务设置</Button>
</WorkspaceSidebar>
<main className="flex min-w-0 flex-1 flex-col">
<div className="ops-divider flex flex-wrap items-center gap-3 p-3">
<div className="flex-1"><h2 className="font-semibold">接口与模拟响应</h2><p className="ops-faint text-xs">{status ? (status.enabled ? "HTTP 响应已开启" : "HTTP 响应已关闭") : "尚未连接本机服务"} · {dirtyRouteId === route?.id ? "当前编辑尚未保存" : publishedMatches ? "已保存接口与服务一致" : "有待发布的接口更改"}</p></div>
<label className="flex items-center gap-2 text-xs"><input type="checkbox" checked={status?.enabled ?? false} disabled={!status || busy} onChange={async (event) => {
setBusy(true); setError(""); setNotice("");
try { await localMockCall("PATCH", { enabled: event.target.checked }); setStatus(await localMockCall<LocalMockStatus>("GET")); }
catch (e) { setError(String(e)); } finally { setBusy(false); }
}} />启用 HTTP 响应</label>
<Button busy={busy} title="重新读取本机服务和已发布接口" onClick={async () => { setBusy(true); setError(""); try { const data = await localMockCall<LocalMockStatus>("GET"); importPublished(data.endpoints); setStatus(data); setNotice("服务状态已更新"); } catch (e) { setError(String(e)); } finally { setBusy(false); } }}>刷新状态</Button>
<Button variant="primary" busy={busy} disabled={!status || publishedMatches || dirtyRouteId === route?.id} title={dirtyRouteId === route?.id ? "请先保存当前 Mock 响应" : "把已保存且启用的接口同步到内网服务;禁用或删除的接口会移除"} onClick={publish}>发布已保存接口({saved.length})</Button>
</div>
<p className="ops-faint px-4 pt-2 text-xs">启用 HTTP 响应控制内网是否可访问;保存只写入本机草稿,发布才会替换服务中的接口。刷新状态用于重新读取服务;管理端口 8787,访问端口 8788。</p>
{error && <p role="alert" className="ops-danger px-4 py-2 text-xs">{error}</p>}
{notice && <p role="status" className="ops-success px-4 py-2 text-xs">{notice}</p>}
{route ? <RouteEditor key={route.id} route={route} server={status} sourceName={source?.node.name} onOpenDoc={source ? () => { rememberDocument(source.node.id); setView("docs"); } : undefined} onDirtyChange={(dirty) => setDirtyRouteId(dirty ? route.id : null)} onSave={(value) => act(() => { save(value); setNotice("已保存本机草稿,点击发布后对外生效"); })} onDelete={() => act(() => { remove(route.id); setNotice("草稿已删除,发布后从服务中移除"); })} /> : <EmptyState title="创建第一个 Mock 接口">点击左侧「创建 GET /demo 示例」,发布后即可试运行;也可从 HTTP 编辑器创建 Mock。</EmptyState>}
</main>
</div>;
}
function RouteEditor({ route, server, onSave, onDelete, onDirtyChange, sourceName, onOpenDoc }: { route: MockRoute; server: LocalMockStatus | null; onSave: (r: MockRoute) => void; onDelete: () => void; onDirtyChange: (dirty: boolean) => void; sourceName?: string; onOpenDoc?: () => void }) {
const draftKey = `opsxapi.mock.edit.${route.id}`;
const [draft, setDraft] = useState<MockRoute>(() => {
try { const saved = JSON.parse(localStorage.getItem(draftKey) || "null"); return saved?.id === route.id ? saved : route; } catch { return route; }
});
const [output, setOutput] = useState("");
const [trying, setTrying] = useState(false);
const [trialBearer, setTrialBearer] = useState("");
const routes = useMockRoutes((s) => s.routes);
const dirty = JSON.stringify(draft) !== JSON.stringify(route);
const error = validateRoute(draft, routes);
let jsonError = "";
if (draft.contentType.toLowerCase().includes("json")) {
try { JSON.parse(draft.body); } catch (cause) { jsonError = cause instanceof Error ? cause.message : "JSON 解析失败"; }
}
useEffect(() => { onDirtyChange(dirty); }, [dirty, onDirtyChange]);
const patch = (value: Partial<MockRoute>) => {
const next = { ...draft, ...value }; setDraft(next);
try { localStorage.setItem(draftKey, JSON.stringify(next)); } catch { setOutput("未能保存编辑缓存,请保持页面打开并重试保存"); }
};
useEffect(() => { if (JSON.stringify(route) === JSON.stringify(draft)) { try { localStorage.removeItem(draftKey); } catch {} } }, [route, draft, draftKey]);
const published = server?.endpoints.find((e) => e.method === route.method && e.path === route.path);
const url = server && published ? mockEndpointUrl(readShareOrigin() || "http://127.0.0.1:8788", server.token, route.path) : "";
return <div className="ops-scroll min-h-0 flex-1 space-y-4 p-4">
<div className="flex items-center gap-2"><input aria-label="Mock 名称" className="ops-input flex-1" value={draft.name} onChange={(e) => patch({ name: e.target.value })} /><label className="flex shrink-0 items-center gap-1 text-xs"><input type="checkbox" checked={draft.enabled} onChange={(e) => patch({ enabled: e.target.checked })} />启用</label><Button disabled={!!error || !dirty} onClick={() => onSave(draft)}>{dirty ? "保存草稿" : "已保存"}</Button><Button variant="danger" onClick={() => { if (confirm(`删除 Mock「${route.name}」?发布后从服务中移除。`)) { onDelete(); } }}>删除</Button></div>
<div className="flex gap-2"><select aria-label="Mock 方法" className="ops-input !w-28" value={draft.method} onChange={(e) => patch({ method: e.target.value })}>{REQUEST_METHODS.map((m) => <option key={m}>{m}</option>)}</select><input aria-label="Mock 路径" className="ops-input font-mono" placeholder="/orders/123" value={draft.path} onChange={(e) => patch({ path: e.target.value })} /></div>
{sourceName && <div className="flex items-center gap-2 text-xs"><span className="ops-faint">关联接口:{sourceName}</span><Button onClick={onOpenDoc}>查看接口文档</Button></div>}
<div className="grid grid-cols-3 gap-3"><label className="text-xs">状态码<input className="ops-input mt-1" type="number" min={200} max={599} value={draft.status} onChange={(e) => patch({ status: Number(e.target.value) })} /></label><label className="text-xs">延迟(ms)<input className="ops-input mt-1" type="number" min={0} max={5000} value={draft.delayMs} onChange={(e) => patch({ delayMs: Number(e.target.value) })} /></label><label className="text-xs">响应格式<select className="ops-input mt-1" value={draft.contentType} onChange={(e) => patch({ contentType: e.target.value })}><option value="application/json">JSON</option><option value="text/plain">文本</option>{!["application/json", "text/plain"].includes(draft.contentType) && <option>{draft.contentType}</option>}</select></label></div>
<div className="ops-card space-y-2 p-3"><h3 className="text-sm font-semibold">访问已发布接口</h3><p className="ops-faint text-xs">{url ? `${route.method} · ${server?.enabled ? "HTTP 响应开启" : "HTTP 响应关闭"}` : "此接口尚未发布;编辑区的路径不是可直接访问的 URL"}</p><p className="break-all font-mono text-xs">{url || "保存并发布后,这里显示完整地址(8788 /mock/<地址令牌>/接口路径)"}</p>
<p className="ops-faint text-xs">5173 是桌面开发界面;请使用上方完整地址。{server?.bearerRequired ? "此服务已启用 Bearer 验证,请在请求 Header 中带 Authorization。" : "默认无需额外 Header,完整地址含随机令牌。"}</p>
{server?.bearerRequired && <input aria-label="试运行 Bearer 密钥" className="ops-input font-mono text-xs" type="password" autoComplete="off" placeholder="试运行 Bearer 密钥(仅本次使用)" value={trialBearer} onChange={(event) => setTrialBearer(event.target.value)} />}
<div className="flex gap-2"><Button disabled={!url || !server?.active} onClick={async () => { try { await navigator.clipboard.writeText(url); setOutput("已复制接口地址"); } catch { setOutput("复制失败"); } }}>复制 URL</Button>
<Button busy={trying} disabled={!url || !server?.active || !server?.enabled || (server.bearerRequired && !trialBearer)} onClick={async () => { setTrying(true); try {
const local = mockEndpointUrl("http://127.0.0.1:8787", server!.token, route.path);
const headers: Record<string, string> = server!.bearerRequired ? { Authorization: `Bearer ${trialBearer}` } : {};
if (isTauriRuntime()) { const result = await tauriNativeTransport.http({ method: route.method as HttpMethod, url: local, headers, proxy: { mode: "disabled", http: "", https: "", bypass: "" } }); setOutput(`HTTP ${result.status}\n${result.body}`); }
else { const response = await fetch(local, { method: route.method, headers }); setOutput(`HTTP ${response.status}\n${await response.text()}`); }
} catch (e) { setOutput(String(e)); } finally { setTrying(false); } }}>试运行已发布版本</Button></div>
{output && <pre role="status" className="ops-code max-h-52 overflow-auto text-xs">{output}</pre>}
</div>
<div className="flex flex-wrap items-center gap-2"><label className="text-xs font-semibold" htmlFor={`mock-body-${route.id}`}>响应 Body</label><span className="ops-faint text-xs">保存到本机草稿,发布后才对外生效</span><div className="flex-1" /><Button disabled={!!jsonError || !draft.contentType.toLowerCase().includes("json")} onClick={() => patch({ body: JSON.stringify(JSON.parse(draft.body), null, 2) })}>格式化 JSON</Button></div>
<textarea id={`mock-body-${route.id}`} spellCheck={false} className="ops-input min-h-64 font-mono" value={draft.body} aria-invalid={!!jsonError} onChange={(e) => patch({ body: e.target.value })} />
{jsonError && <div role="alert" className="ops-card flex flex-wrap items-center gap-2 p-3 text-xs"><span className="ops-danger">JSON 解析失败:{jsonError}</span><Button onClick={() => patch({ contentType: "text/plain" })}>改为文本响应</Button></div>}
{error && !jsonError && <p role="alert" className="ops-danger text-xs">{error}</p>}
</div>;
}
+7
View File
@@ -0,0 +1,7 @@
import { expect, it } from "vitest";
import { mockEndpointUrl, validateShareOrigin } from "./localMock";
it("builds share URLs without letting paths become queries or fragments", () => {
expect(mockEndpointUrl("http://192.168.1.5:8788", "abc", "/hello world/#id")).toBe("http://192.168.1.5:8788/mock/abc/hello%20world/%23id");
expect(validateShareOrigin("https://mock.internal/")).toBe("https://mock.internal");
for (const value of ["https://host/path", "https://user:secret@host", "file:///tmp/test"]) expect(() => validateShareOrigin(value)).toThrow();
});
+30
View File
@@ -0,0 +1,30 @@
import { isTauriRuntime, tauriNativeTransport } from "../../transport";
import type { HttpMethod } from "../../core";
export type MockEndpoint = { method: string; path: string; status: number; contentType: string; body: string; delayMs: number };
export type LocalMockStatus = { enabled: boolean; allowPublic: boolean; active: boolean; bearerRequired: boolean; bearerConfigured: boolean; token: string; endpoints: MockEndpoint[]; expiresAt: number; addresses: string[]; listenAddr: string };
export async function localMockCall<T>(method: HttpMethod, payload?: unknown, path = "/api/mock/local"): Promise<T> {
const body = payload === undefined ? undefined : JSON.stringify(payload);
let text: string, status: number;
if (isTauriRuntime()) {
const result = await tauriNativeTransport.http({ method, url: `http://127.0.0.1:8787${path}`, headers: { "Content-Type": "application/json" }, body: body ? { mode: "json", raw: body } : undefined, timeoutMs: 10000, proxy: { mode: "disabled", http: "", https: "", bypass: "" } });
text = result.body; status = result.status;
} else {
const result = await fetch(path, { method, headers: { "Content-Type": "application/json" }, body });
text = await result.text(); status = result.status;
}
let data: T & { error?: string };
try { data = JSON.parse(text); } catch { throw new Error("本机 Mock 服务响应无效,请更新并重启 ./run.sh gui"); }
if (status === 404 && data.error === "接口不存在") throw new Error("当前运行的本机后端缺少 Mock 管理接口。请执行 ./run.sh stop,再执行 ./run.sh gui 更新后端");
if (status >= 400) throw new Error(data.error || `Mock 操作失败 (${status})`);
if (path === "/api/mock/local/access" && method === "PUT") window.dispatchEvent(new Event("opsxapi:mock-access-changed"));
return data;
}
export function readShareOrigin(): string { try { return localStorage.getItem("opsxapi.mock.shareOrigin") || ""; } catch { return ""; } }
export function validateShareOrigin(input: string): string {
const url = new URL(input);
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw new Error("请输入 IP 或自定义域名的 HTTP/HTTPS 根地址,不含路径");
return url.origin;
}
export function mockEndpointUrl(origin: string, token: string, path: string): string {
return `${origin}/mock/${encodeURIComponent(token)}/${path.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/")}`;
}
+44
View File
@@ -0,0 +1,44 @@
import { beforeEach, expect, it, vi } from "vitest";
vi.mock("../../workspaceStore", () => ({ useWorkspace: { getState: () => ({ environments: [], setView: vi.fn() }) } }));
import { createRequest } from "../../core/factory";
import { findMockForNode, newRoute, useMockRoutes, validateRoute } from "./mockStore";
beforeEach(() => {
const data = new Map<string,string>();
vi.stubGlobal("localStorage", { getItem: (k:string) => data.get(k) ?? null, setItem: (k:string,v:string) => data.set(k,v), removeItem: (k:string) => data.delete(k) });
useMockRoutes.setState({ routes: [], selected: null, revision: 0 });
});
it("validates JSON, path uniqueness, status and delay before publishing", () => {
const route = newRoute();
expect(validateRoute(route, [])).toBeNull();
expect(validateRoute({ ...route, body: "hello" }, [])).toContain("JSON");
expect(validateRoute({ ...route, contentType: "text/plain", body: "hello" }, [])).toBeNull();
expect(validateRoute({ ...route, path: "https://host/path" }, [])).toContain("路径");
expect(validateRoute({ ...route, delayMs: -1 }, [])).toContain("延迟");
expect(validateRoute({ ...route, status: 600 }, [])).toContain("状态码");
expect(validateRoute(route, [{ ...route, id: "other" }])).toContain("相同");
});
it("merges existing published routes without replacing local drafts or resurrecting deleted routes", () => {
const route = newRoute(); const remote = { ...newRoute(), path: "/existing" };
useMockRoutes.getState().save(route);
useMockRoutes.getState().importPublished([remote]);
expect(useMockRoutes.getState().routes).toHaveLength(2);
const id = useMockRoutes.getState().routes.find((r) => r.path === "/existing")!.id;
useMockRoutes.getState().remove(id);
useMockRoutes.getState().importPublished([remote]);
expect(useMockRoutes.getState().routes).toEqual([route]);
});
it("does not replace a saved draft when storage fails", () => {
const route = newRoute(); useMockRoutes.getState().save(route);
vi.spyOn(localStorage,"setItem").mockImplementation(() => { throw new Error("quota"); });
expect(() => useMockRoutes.getState().save({ ...route, body: "changed" })).toThrow("quota");
expect(useMockRoutes.getState().routes[0].body).toBe(route.body);
});
it("keeps an explicit document link when a Mock path changes and avoids claiming another document's route", () => {
const first = createRequest("http", "First"); first.data.url = "/items";
const second = createRequest("http", "Second"); second.data.url = "/items";
const linked = { ...newRoute(), path: "/renamed", sourceNodeId: first.id };
const legacy = { ...newRoute(), path: "/items" };
expect(findMockForNode([linked, legacy], first, "/items")).toBe(linked);
expect(findMockForNode([linked], second, "/renamed")).toBeUndefined();
expect(findMockForNode([legacy], second, "/items")).toBe(legacy);
});
+79
View File
@@ -0,0 +1,79 @@
import { create } from "zustand";
import type { HttpApiNode, HttpResult } from "../../core";
import { environmentPath } from "../../core/requestUrl";
import { useWorkspace } from "../../workspaceStore";
import type { MockEndpoint } from "./localMock";
export type MockRoute = MockEndpoint & { id: string; name: string; enabled: boolean; sourceNodeId?: string };
const key = "opsxapi.mock.routes.v1";
const removedKey = "opsxapi.mock.removed.v1";
const routeKey = (route: MockEndpoint) => `${route.method} ${route.path.replace(/\/$/, "")}`;
function removed(): string[] { try { return JSON.parse(localStorage.getItem(removedKey) || "[]"); } catch { return []; } }
function rememberRemoval(route: MockRoute) { localStorage.setItem(removedKey, JSON.stringify([...new Set([...removed(), routeKey(route)])])); }
function load(): MockRoute[] {
try { const value = JSON.parse(localStorage.getItem(key) || "[]"); return Array.isArray(value) ? value : []; } catch { return []; }
}
export function validateRoute(route: MockRoute, others: MockRoute[]): string | null {
if (!route.name.trim()) return "请填写接口名称";
if (!/^\/(?!\/)/.test(route.path) || /[?#\r\n]/.test(route.path)) return "路径须以 / 开头,不含域名、查询参数或片段";
if (!Number.isInteger(route.status) || route.status < 200 || route.status > 599) return "状态码应为 200~599";
if (!Number.isInteger(route.delayMs) || route.delayMs < 0 || route.delayMs > 5000) return "延迟应为 0~5000 毫秒";
if (others.some((r) => r.id !== route.id && r.method === route.method && r.path.replace(/\/$/, "") === route.path.replace(/\/$/, ""))) return "已有相同方法和路径的接口";
if (route.contentType.toLowerCase().includes("json")) { try { JSON.parse(route.body); } catch (cause) { return `响应不是有效 JSON:${cause instanceof Error ? cause.message : "解析失败"}。请修正响应 Body,或改选「文本」格式`; } }
return null;
}
export function newRoute(): MockRoute {
return { id: crypto.randomUUID(), name: "新建接口", method: "GET", path: `/example-${Date.now().toString(36)}`, status: 200, contentType: "application/json", body: '{\n "message": "Hello Mock"\n}', delayMs: 0, enabled: true };
}
export function demoRoute(): MockRoute {
return { ...newRoute(), name: "演示接口", path: "/demo", body: '{\n "message": "Hello Mock",\n "ok": true\n}' };
}
export const useMockRoutes = create<{ routes: MockRoute[]; selected: string | null; revision: number; save: (route: MockRoute) => void; remove: (id: string) => void; select: (id: string) => void; importPublished: (routes: MockEndpoint[]) => void }>((set, get) => ({
routes: load(), selected: null, revision: 0,
save(route) {
const previous = get().routes.find((r) => r.id === route.id);
if (previous && routeKey(previous) !== routeKey(route)) rememberRemoval(previous);
const routes = get().routes.some((r) => r.id === route.id) ? get().routes.map((r) => r.id === route.id ? route : r) : [...get().routes, route];
localStorage.setItem(key, JSON.stringify(routes)); set({ routes, selected: route.id, revision: get().revision + 1 });
},
remove(id) { const old = get().routes.find((r) => r.id === id); if (old) rememberRemoval(old); const routes = get().routes.filter((r) => r.id !== id); localStorage.setItem(key, JSON.stringify(routes)); set({ routes, selected: routes[0]?.id ?? null, revision: get().revision + 1 }); },
select(selected) { set({ selected }); },
importPublished(endpoints) {
const existing = get().routes;
const deleted = removed();
const additions = endpoints.filter((ep) => !existing.some((r) => routeKey(r) === routeKey(ep)) && !deleted.includes(routeKey(ep))).map((ep) => ({ ...ep, id: crypto.randomUUID(), name: `${ep.method} ${ep.path}`, enabled: true }));
if (!additions.length) return;
const routes = [...existing, ...additions];
localStorage.setItem(key, JSON.stringify(routes)); set({ routes, selected: get().selected ?? routes[0]?.id ?? null });
},
}));
export function findMockForNode(routes: MockRoute[], node: HttpApiNode, path?: string): MockRoute | undefined {
return routes.find((route) => route.sourceNodeId === node.id)
?? (path ? routes.find((route) => !route.sourceNodeId && route.method === node.data.method && route.path === path) : undefined);
}
export function createMockFromRequest(node: HttpApiNode, response?: HttpResult) {
const state = useWorkspace.getState();
const relative = environmentPath(node.data.url, state.environments.map((e) => e.baseUrl || ""));
let path = relative || node.data.url;
try { path = new URL(path).pathname; } catch { path = path.split(/[?#]/)[0]; }
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const existing = findMockForNode(useMockRoutes.getState().routes, node, normalizedPath);
if (!existing && useMockRoutes.getState().routes.some((route) => route.method === node.data.method && route.path === normalizedPath)) {
throw new Error("该方法和路径已有其他接口关联的 Mock,请在 Mock 页面修改路径后重试");
}
if (existing && !response) {
if (!existing.sourceNodeId) useMockRoutes.getState().save({ ...existing, sourceNodeId: node.id });
useMockRoutes.getState().select(existing.id);
state.setView("mock");
return;
}
let contentType = response ? Object.entries(response.headers).find(([header]) => header.toLowerCase() === "content-type")?.[1] || "text/plain" : "application/json";
if (response && contentType.toLowerCase().includes("json")) {
try { JSON.parse(response.body); } catch { contentType = "text/plain"; }
}
const route = { ...(existing ?? newRoute()), sourceNodeId: node.id, name: node.name, method: node.data.method, path: existing?.path ?? normalizedPath, body: response?.body ?? '{\n "message": "请编辑模拟响应"\n}', status: response?.status ?? 200, contentType };
const error = validateRoute(route, useMockRoutes.getState().routes);
if (error) throw new Error(error);
useMockRoutes.getState().save(route);
state.setView("mock");
}
@@ -0,0 +1,145 @@
import { useState } from "react";
import type { AiProvider } from "../../core/model";
import { AI_PROVIDER_PRESETS, newAiProvider, testProviderConnection } from "../../ai";
import { useWorkspace } from "../../workspaceStore";
import { Icon } from "../../components/icons";
function validate(provider: AiProvider): string {
if (!provider.name.trim()) return "请填写供应商名称";
if (!provider.model.trim()) return "请填写模型 ID";
try {
const url = new URL(provider.baseUrl.trim());
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error();
} catch { return "请填写有效的 HTTP/HTTPS Base URL,不含账号、查询参数或片段"; }
return "";
}
export function AiProviderSettings() {
const ai = useWorkspace((s) => s.settings.ai);
const add = useWorkspace((s) => s.addAiProvider);
const update = useWorkspace((s) => s.updateAiProvider);
const remove = useWorkspace((s) => s.removeAiProvider);
const setActive = useWorkspace((s) => s.setActiveAiProvider);
const [preset, setPreset] = useState("0");
const [selectedId, setSelectedId] = useState<string | null>(() => useWorkspace.getState().settings.ai.providers[0]?.id ?? null);
const [draft, setDraft] = useState<AiProvider | null>(() => useWorkspace.getState().settings.ai.providers[0] ?? null);
const [showKey, setShowKey] = useState(false);
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<{ signature: string; ok: boolean; message: string } | null>(null);
const [notice, setNotice] = useState("");
const stored = ai.providers.find((item) => item.id === draft?.id);
const dirty = !!draft && (!stored || JSON.stringify(draft) !== JSON.stringify(stored));
const signature = draft ? JSON.stringify([draft.baseUrl, draft.model, draft.apiKey]) : "";
const currentResult = result?.signature === signature ? result : null;
const error = draft ? validate(draft) : "";
const choose = (provider: AiProvider) => {
if (dirty && !confirm("当前模型配置尚未保存,确定放弃修改?")) return;
setSelectedId(provider.id);
setDraft({ ...provider });
setShowKey(false);
setResult(null);
setNotice("");
};
const create = () => {
if (dirty && !confirm("当前模型配置尚未保存,确定放弃修改?")) return;
const item = preset === "custom" ? newAiProvider() : newAiProvider(AI_PROVIDER_PRESETS[Number(preset)]);
setSelectedId(item.id);
setDraft(item);
setShowKey(false);
setResult(null);
setNotice("选择模型并填写密钥,然后测试或保存配置");
};
const save = () => {
if (!draft || error) return;
const value = { ...draft, name: draft.name.trim(), baseUrl: draft.baseUrl.trim().replace(/\/+$/, ""), model: draft.model.trim(), apiKey: draft.apiKey.trim() };
if (stored) update(value.id, value);
else add(value);
if (ai.activeProviderId === value.id && !value.enabled) setActive(ai.providers.find((item) => item.id !== value.id && item.enabled)?.id ?? null);
setSelectedId(value.id);
setDraft(value);
setNotice("配置已保存到当前工作区");
};
const test = async () => {
if (!draft || error) return;
const snapshot = signature;
setTesting(true);
setResult(null);
setNotice("");
const start = performance.now();
try {
const answer = await testProviderConnection(draft);
setResult({ signature: snapshot, ok: true, message: `连接成功 · ${Math.round(performance.now() - start)} ms · ${answer}` });
} catch (cause) {
setResult({ signature: snapshot, ok: false, message: cause instanceof Error ? cause.message : String(cause) });
} finally { setTesting(false); }
};
const discard = () => {
if (stored) { setDraft({ ...stored }); setSelectedId(stored.id); }
else { const first = ai.providers[0] ?? null; setDraft(first ? { ...first } : null); setSelectedId(first?.id ?? null); }
setResult(null); setNotice("");
};
const deleteSelected = () => {
if (!draft) return;
if (stored && !confirm(`删除模型供应商「${draft.name}」?`)) return;
if (stored) remove(stored.id);
const first = ai.providers.find((item) => item.id !== draft.id) ?? null;
setSelectedId(first?.id ?? null); setDraft(first ? { ...first } : null);
setResult(null); setNotice("");
};
return <section className="ai-settings min-w-0">
<div className="ai-settings-intro"><div className="ai-settings-intro-icon"><Icon name="bot" size={19} /></div><div><h3>AI 场景用例</h3><p>配置用于自动化测试用例生成的模型。支持 OpenAI 兼容接口与本机 Ollama。</p></div></div>
<div className="ai-config-layout">
<aside className="ai-config-list">
<div className="ai-config-add">
<select aria-label="供应商预设" className="ops-input min-w-0 flex-1 text-xs" value={preset} onChange={(event) => setPreset(event.target.value)}>
<optgroup label="云端模型">{AI_PROVIDER_PRESETS.map((item, index) => item.name.includes("本地") ? null : <option key={item.name} value={index}>{item.name}</option>)}</optgroup>
<optgroup label="本地模型">{AI_PROVIDER_PRESETS.map((item, index) => item.name.includes("本地") ? <option key={item.name} value={index}>{item.name}</option> : null)}</optgroup>
<option value="custom">自定义</option>
</select>
<button type="button" className="ops-btn ops-btn-primary" onClick={create}><Icon name="plus" size={14} /> 添加</button>
</div>
<p className="ai-config-list-caption">已配置供应商 <span>{ai.providers.length}</span></p>
{ai.providers.length === 0 && <p className="ops-faint px-3 pb-5 text-xs leading-5">选择预设并添加,填写连接信息后即可测试。</p>}
{ai.providers.map((item) => <button key={item.id} type="button" aria-pressed={selectedId === item.id} className={`ai-config-item ${selectedId === item.id ? "active" : ""}`} onClick={() => choose(item)}>
<span className="flex items-center gap-1.5"><Icon name="bot" size={14} /><span className="min-w-0 flex-1 truncate font-medium">{item.name}</span>{ai.activeProviderId === item.id && <span className="ai-default-badge">默认</span>}</span>
<span className="ops-faint block truncate pl-5 text-[11px]">{item.model || "未填写模型"}{!item.enabled ? " · 已停用" : ""}</span>
</button>)}
</aside>
<div className="ai-config-detail">
{!draft ? <div className="ops-faint grid min-h-64 place-items-center px-5 text-center text-xs">从左侧添加供应商,然后配置模型和连接信息。</div> : <>
<div className="ai-config-detail-head">
<span className="ai-settings-intro-icon"><Icon name="bot" size={18} /></span>
<div className="min-w-0 flex-1"><h3 className="truncate text-sm font-semibold">{draft.name || "新供应商"}</h3><p className="ops-faint text-[11px]">OpenAI 兼容 · Chat Completions</p></div>
<span className={`ai-config-state ${dirty ? "pending" : ai.activeProviderId === draft.id ? "active" : ""}`}>{dirty ? "未保存" : ai.activeProviderId === draft.id ? "当前默认" : "已保存"}</span>
</div>
<div className="ai-config-fields">
<h4>模型</h4>
<label className="ai-form-field">供应商名称<input className="ops-input" value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} placeholder="例如:团队 DeepSeek" /></label>
<label className="ai-form-field">模型 ID<input className="ops-input font-mono" value={draft.model} onChange={(event) => setDraft({ ...draft, model: event.target.value })} placeholder="例如:deepseek-chat" /></label>
<h4 className="ai-form-divider">连接</h4>
<label className="ai-form-field">服务地址<input className="ops-input font-mono" value={draft.baseUrl} onChange={(event) => setDraft({ ...draft, baseUrl: event.target.value })} placeholder="https://api.example.com/v1" /><span className="ops-faint text-[11px]">Base URL,发送时追加 /chat/completions</span></label>
<label className="ai-form-field">API Key {draft.baseUrl.includes("127.0.0.1") || draft.baseUrl.includes("localhost") ? "(本机可留空)" : ""}
<span className="flex min-w-0 gap-1.5"><input className="ops-input min-w-0 flex-1 font-mono" type={showKey ? "text" : "password"} autoComplete="new-password" value={draft.apiKey} onChange={(event) => setDraft({ ...draft, apiKey: event.target.value })} placeholder="填写模型服务密钥" /><button className="ops-btn ops-btn-secondary shrink-0" type="button" onClick={() => setShowKey((value) => !value)}>{showKey ? "隐藏" : "显示"}</button></span>
</label>
<label className="ai-config-enabled"><input type="checkbox" checked={draft.enabled} onChange={(event) => setDraft({ ...draft, enabled: event.target.checked })} /><span>启用此供应商</span></label>
</div>
<div className="ai-config-footer">
{error && <p className="ops-danger text-xs" role="alert">{error}</p>}
{currentResult && <p className={`ai-test-result ${currentResult.ok ? "success" : "failure"}`} role={currentResult.ok ? "status" : "alert"}>{currentResult.ok ? currentResult.message : `连接失败:${currentResult.message}`}</p>}
<div className="ai-config-actions">
<button className="ops-btn ops-btn-ghost ops-danger mr-auto" type="button" title="删除供应商" onClick={deleteSelected} aria-label="删除供应商"><Icon name="trash" size={14} /></button>
{dirty && <button className="ops-btn ops-btn-secondary" type="button" onClick={discard}>放弃修改</button>}
<button className="ops-btn ops-btn-secondary" type="button" disabled={!!error || testing} onClick={() => void test()}><Icon name="network" size={13} /> {testing ? "测试中…" : "测试连接"}</button>
{dirty && <button className="ops-btn ops-btn-primary" type="button" disabled={!!error || testing} onClick={save}>保存配置</button>}
{!dirty && ai.activeProviderId !== draft.id && draft.enabled && <button className="ops-btn ops-btn-primary" type="button" onClick={() => setActive(draft.id)}>设为默认</button>}
</div>
{notice && <p className="ops-faint text-xs" role="status">{notice}</p>}
</div>
</>}
</div>
</div>
<p className="ops-faint text-[11px] leading-5">API Key 随当前工作区保存;团队工作区的数据会同步。请只在信任的工作区配置密钥。</p>
</section>;
}
@@ -0,0 +1,38 @@
import { useState } from "react";
import { readProxySettings, saveProxySettings, proxyAddressError } from "../../transport/proxySettings";
import { Button } from "../../components/primitives";
export function ProxySettingsPanel() {
const [value, setValue] = useState(readProxySettings);
const [message, setMessage] = useState("");
const error = value.mode === "custom" ? proxyAddressError(value.http) || proxyAddressError(value.https) : null;
return <section className="ops-card space-y-3 p-4">
<h3 className="font-semibold">请求代理</h3>
<p className="ops-dim text-xs">用于桌面 HTTP / GraphQL 和自动化测试。仅保存到这台设备,不参与工作区同步或导出。</p>
<label className="block">代理模式
<select className="ops-input mt-1" value={value.mode} onChange={(e) => { setValue({ ...value, mode: e.target.value as typeof value.mode }); setMessage(""); }}>
<option value="automatic">自动检测</option><option value="custom">自定义代理</option><option value="disabled">不使用代理</option>
</select>
</label>
{value.mode === "automatic" && <p className="ops-faint text-xs">使用网络库自动检测的代理配置(含 HTTP_PROXY / HTTPS_PROXY / NO_PROXY);不支持 PAC 脚本。</p>}
{value.mode === "custom" && <>
{(["http", "https"] as const).map((key) => <label className="block" key={key}>{key.toUpperCase()} 请求代理
<input className="ops-input mt-1" value={value[key]} placeholder="http://127.0.0.1:7890" onChange={(e) => { setValue({ ...value, [key]: e.target.value }); setMessage(""); }} />
</label>)}
<p className="ops-faint text-xs">留空的协议直接连接;HTTPS 请求可通过 HTTP 代理隧道转发。</p>
<label className="block">不经过代理的主机
<input className="ops-input mt-1" value={value.bypass} onChange={(e) => { setValue({ ...value, bypass: e.target.value }); setMessage(""); }} placeholder="localhost,127.0.0.1,*.internal.example" />
</label>
<p className="ops-faint text-xs">逗号分隔;支持精确主机、*.域名、可选端口以及 *。此设置不改变 WebSocket / SSE / MQTT 等实时连接。</p>
<label className="flex items-center gap-2 text-xs"><input type="checkbox" checked={!!value.auth} onChange={(e) => setValue({ ...value, auth: e.target.checked ? { username: "", password: "" } : undefined })} />代理身份验证</label>
{value.auth && <div className="grid grid-cols-2 gap-2">
<label className="text-xs">用户名<input className="ops-input mt-1" autoComplete="off" value={value.auth.username} onChange={(e) => setValue({ ...value, auth: { ...value.auth!, username: e.target.value } })} /></label>
<label className="text-xs">密码<input className="ops-input mt-1" type="password" autoComplete="off" value={value.auth.password} onChange={(e) => setValue({ ...value, auth: { ...value.auth!, password: e.target.value } })} /></label>
<p className="ops-faint col-span-2 text-xs">凭据仅保存在本机应用存储,不同步到工作区。</p>
</div>}
</>}
{error && <p role="alert" className="ops-danger text-xs">{error}</p>}
<Button disabled={!!error} onClick={() => { try { saveProxySettings(value); setMessage("代理设置已保存,下次请求生效"); } catch { setMessage("保存失败,请检查本机存储是否可用"); } }}>保存代理设置</Button>
{message && <p role="status" className="ops-dim text-xs">{message}</p>}
</section>;
}
+165
View File
@@ -0,0 +1,165 @@
import { useTestActions } from "./useTestActions";
import type { TestCase, TestStep } from "../../core/index";
import { newStep } from "../../core/index";
import { useEffect, useMemo, useRef, useState } from "react";
import { useWorkspace } from "../../workspaceStore";
import { cx } from "../../components/common";
import { Icon } from "../../components/icons";
import { Button } from "../../components/primitives";
import { StepEditor } from "./StepEditor";
import { ReportPanel } from "./ReportPanel";
import { useHttpNodeOptions } from "./useHttpNodeOptions";
import { reorderSteps } from "./stepOrder";
export function CaseDetail({ tc, onOpenSchedule }: { tc: TestCase; onOpenSchedule: () => void }) {
const updateTestCase = useWorkspace((s) => s.updateTestCase);
const removeTestCase = useWorkspace((s) => s.removeTestCase);
const allRuns = useWorkspace((s) => s.testRuns);
const schedule = useWorkspace((s) => s.schedules.find((item) => item.caseId === tc.id));
const runs = useMemo(() => allRuns.filter((r) => r.caseId === tc.id), [allRuns, tc.id]);
const nodeOptions = useHttpNodeOptions();
const [tab, setTab] = useState<"edit" | "report">("edit");
const [activeStepId, setActiveStepId] = useState<string | null>(tc.steps[0]?.id ?? null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const draggingId = useRef<string | null>(null);
const [removed, setRemoved] = useState<{ caseId: string; step: TestStep; index: number } | null>(null);
const { busy, run: execute } = useTestActions(tc.id);
const patch = (p: Partial<TestCase>) => updateTestCase(tc.id, p);
const activeStep = tc.steps.find((step) => step.id === activeStepId) ?? tc.steps[0];
const activeIndex = activeStep ? tc.steps.findIndex((step) => step.id === activeStep.id) : -1;
useEffect(() => {
if (tc.steps.length && !tc.steps.some((step) => step.id === activeStepId)) setActiveStepId(tc.steps[0].id);
if (!tc.steps.length && activeStepId) setActiveStepId(null);
}, [tc.steps, activeStepId]);
const patchStep = (stepId: string, p: Partial<TestCase["steps"][number]>) =>
patch({ steps: tc.steps.map((st) => (st.id === stepId ? { ...st, ...p } : st)) });
const moveStep = (stepId: string, dir: -1 | 1) => {
const index = tc.steps.findIndex((step) => step.id === stepId);
const target = tc.steps[index + dir];
if (index < 0 || !target) return;
patch({ steps: reorderSteps(tc.steps, stepId, target.id) });
};
const removeStep = (stepId: string) => {
const index = tc.steps.findIndex((step) => step.id === stepId);
if (index < 0) return;
setRemoved({ caseId: tc.id, step: tc.steps[index], index });
if (activeStep?.id === stepId) setActiveStepId(tc.steps[index + 1]?.id ?? tc.steps[index - 1]?.id ?? null);
patch({ steps: tc.steps.filter((step) => step.id !== stepId) });
};
const undoRemove = () => {
if (!removed || removed.caseId !== tc.id) return;
if (!tc.steps.some((step) => step.id === removed.step.id)) {
const steps = [...tc.steps];
steps.splice(Math.min(removed.index, steps.length), 0, removed.step);
patch({ steps });
setActiveStepId(removed.step.id);
}
setRemoved(null);
};
const finishDrag = () => { draggingId.current = null; setDragOverId(null); };
const run = () => { setTab("report"); void execute(); };
return (
<div className="test-case-detail flex min-w-0 flex-1 flex-col">
{/* 头部 */}
<div className="test-case-toolbar flex flex-wrap items-center gap-2 px-3 py-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
<input
className="ops-input ops-input-sm min-w-40 flex-1 font-semibold"
value={tc.name}
onChange={(e) => patch({ name: e.target.value })}
placeholder="用例名称"
/>
<label className="ops-faint flex items-center gap-1.5 text-[11px]" title="某步失败后是否继续执行后续步骤">
<input
type="checkbox"
checked={tc.continueOnFailure}
onChange={(e) => patch({ continueOnFailure: e.target.checked })}
/>
失败后继续
</label>
<Button onClick={onOpenSchedule} title="配置运行间隔或 Cron;应用保持运行时执行">
<Icon name="calendar-clock" size={13} /> {schedule?.enabled ? "定时已启用" : "定时运行"}
</Button>
<Button variant="primary" busy={busy} disabled={tc.steps.length === 0} onClick={run}>
<Icon name="play" size={13} /> {busy ? "运行中…" : "运行"}
</Button>
<button
className="ops-btn ops-btn-ghost ops-danger"
title="删除用例"
onClick={() => {
if (confirm(`删除用例「${tc.name}」及其运行记录?`)) removeTestCase(tc.id);
}}
>
<Icon name="trash" size={13} />
</button>
</div>
{/* Tabs */}
<div className="flex shrink-0 px-2" style={{ borderBottom: "1px solid var(--border)" }}>
<button className={cx("ops-tab", tab === "edit" && "ops-tab-active")} onClick={() => setTab("edit")}>
用例编排
</button>
<button className={cx("ops-tab", tab === "report" && "ops-tab-active")} onClick={() => setTab("report")}>
测试报告 {runs.length > 0 && <span className="ops-count">{runs.length}</span>}
</button>
</div>
{tab === "edit" ? (
<div className="ops-scroll min-h-0 flex-1 p-4">
<div className="mx-auto max-w-[1320px] space-y-4">
<details className="test-case-notes">
<summary><span className="shrink-0">场景说明</span>{tc.description ? <span className="ops-faint ml-2 truncate font-normal" title={tc.description}>{tc.description}</span> : <span className="ops-faint ml-2 truncate font-normal">添加测试目的和前置条件</span>}</summary>
<textarea className="ops-input mt-3 min-h-24 w-full resize-y text-[12px]" placeholder="场景目的、前置条件…" value={tc.description ?? ""} onChange={(e) => patch({ description: e.target.value })} />
</details>
<div className="test-workflow">
<aside className="test-flow-list" aria-label="测试步骤列表">
<div className="test-flow-heading"><div className="min-w-0"><strong>执行流程</strong><p className="ops-faint text-xs">顺序执行 · {tc.steps.filter((s) => s.enabled).length}/{tc.steps.length} 步启用</p></div><button className="ops-btn ops-btn-secondary shrink-0" onClick={() => { const step = newStep(""); patch({ steps: [...tc.steps, step] }); setActiveStepId(step.id); setRemoved(null); }}><Icon name="plus" size={13} /> 添加步骤</button></div>
{tc.steps.length > 1 && <p className="test-flow-hint ops-faint">拖动手柄排序,或使用上下按钮调整执行顺序</p>}
<div className="test-flow-items">
{tc.steps.map((step, idx) => {
const node = nodeOptions.find((o) => o.id === step.nodeId);
return <div key={step.id} className={cx("test-flow-item", activeStep?.id === step.id && "active", !step.enabled && "disabled", dragOverId === step.id && "test-flow-drop")}
onDragOver={(e) => { if (!draggingId.current || draggingId.current === step.id) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDragOverId(step.id); }}
onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverId((id) => id === step.id ? null : id); }}
onDrop={(e) => { if (!draggingId.current) return; e.preventDefault(); const steps = reorderSteps(tc.steps, draggingId.current, step.id); if (steps !== tc.steps) patch({ steps }); finishDrag(); }}>
<div className="test-flow-main">
<button className="test-flow-drag" draggable={tc.steps.length > 1} title="拖拽调整执行顺序" aria-label={`拖拽步骤 ${idx + 1} 调整顺序`}
onDragStart={(e) => { draggingId.current = step.id; e.dataTransfer.setData("text/plain", step.id); e.dataTransfer.effectAllowed = "move"; }} onDragEnd={finishDrag}>
<span aria-hidden="true">⠿</span>
</button>
<button className="test-flow-select" onClick={() => setActiveStepId(step.id)} aria-current={activeStep?.id === step.id ? "step" : undefined}>
<span className="test-flow-index">{idx + 1}</span><span className="min-w-0 flex-1"><span className="test-flow-name" title={node?.label}>{node?.name ?? "选择 HTTP 接口"}</span><span className="test-flow-meta">{node?.method ?? "待配置"} · {step.assertions.filter((a) => a.enabled).length} 条断言{step.extracts.length ? ` · ${step.extracts.length} 个提取` : ""}</span></span>{!step.enabled && <span className="ops-faint text-[11px]">停用</span>}
</button>
</div>
<div className="test-flow-actions">
<button title="上移步骤" aria-label={`上移步骤 ${idx + 1}`} disabled={idx === 0} onClick={() => moveStep(step.id, -1)}><Icon name="chevron-right" size={12} style={{ transform: "rotate(-90deg)" }} /></button>
<button title="下移步骤" aria-label={`下移步骤 ${idx + 1}`} disabled={idx === tc.steps.length - 1} onClick={() => moveStep(step.id, 1)}><Icon name="chevron-down" size={12} /></button>
<button title="删除步骤" aria-label={`删除步骤 ${idx + 1}`} className="ops-danger" onClick={() => removeStep(step.id)}><Icon name="trash" size={12} /></button>
</div>
</div>;
})}
{!tc.steps.length && <p className="ops-faint px-3 py-6 text-center text-xs">添加步骤后,从集合中选择接口</p>}
</div>
{removed?.caseId === tc.id && <div className="test-flow-undo" role="status"><span>已删除步骤 {removed.index + 1}</span><button onClick={undoRemove}>撤销</button><button title="关闭提示" aria-label="关闭撤销提示" onClick={() => setRemoved(null)}>×</button></div>}
</aside>
<section className="test-step-detail" aria-label="当前步骤编辑">
{activeStep ? <>
<div className="test-detail-heading"><div><span className="ops-faint text-xs">步骤 {activeIndex + 1} / {tc.steps.length}</span><h2 className="font-semibold">请求与校验</h2></div><p className="ops-faint text-xs">选择接口,配置断言和响应变量</p></div>
<StepEditor key={activeStep.id} index={activeIndex} total={tc.steps.length} step={activeStep} nodeOptions={nodeOptions} onPatch={(p) => patchStep(activeStep.id, p)} onRemove={() => removeStep(activeStep.id)} onMove={(dir) => moveStep(activeStep.id, dir)} />
</> : <div className="ops-faint grid min-h-64 place-items-center text-sm">从左侧添加步骤并选择接口</div>}
</section>
</div>
</div>
</div>
) : (
<ReportPanel caseId={tc.id} />
)}
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
import { useTestActions } from "./useTestActions";
import { exportRunJson, exportRunMarkdown, runStats } from "../../core/index";
import { useEffect, useMemo, useState } from "react";
import { useWorkspace } from "../../workspaceStore";
import { cx } from "../../components/common";
import { Icon } from "../../components/icons";
import { Button, EmptyState } from "../../components/primitives";
export function ReportPanel({ caseId }: { caseId: string }) {
const allRuns = useWorkspace((s) => s.testRuns);
const runs = useMemo(() => allRuns.filter((r) => r.caseId === caseId), [allRuns, caseId]);
const [selectedRun, setSelectedRun] = useState<string | null>(null);
const [filter, setFilter] = useState<"all" | "pass" | "fail">("all");
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const run = runs.find((r) => r.id === selectedRun) ?? runs[0];
const { busy, run: rerun } = useTestActions(caseId);
useEffect(() => {
if (run && selectedRun && !runs.some((r) => r.id === selectedRun)) setSelectedRun(null);
}, [runs, selectedRun, run]);
if (!run) {
return (
<EmptyState title={busy ? "正在运行用例…" : "还没有运行记录"}>
{busy ? "完成后会在这里显示报告" : "点击右上「运行」执行用例后查看报告"}
</EmptyState>
);
}
const stats = runStats(run);
const assertCount = run.stepResults.reduce((acc, s) => acc + s.assertionResults.length, 0);
const shown = run.stepResults.filter((s) => (filter === "all" ? true : filter === "pass" ? s.ok : !s.ok));
const download = (format: "md" | "json") => {
const body = format === "json" ? exportRunJson(run) : exportRunMarkdown(run);
const blob = new Blob([body], {
type: format === "json" ? "application/json;charset=utf-8" : "text/markdown;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const day = new Date(run.at).toISOString().slice(0, 10);
a.download = `${run.caseName}-报告-${day}.${format === "json" ? "json" : "md"}`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="ops-scroll flex-1 p-3.5">
{/* 运行选择 + 操作 */}
<div className="mb-3 flex flex-wrap items-center gap-2">
<select
className="ops-input ops-input-sm w-64"
value={run.id}
onChange={(e) => setSelectedRun(e.target.value)}
>
{runs.map((r) => (
<option key={r.id} value={r.id}>
{new Date(r.at).toLocaleString("zh-CN")} · {r.trigger === "schedule" ? "定时" : r.trigger === "ai" ? "AI" : "手动"} ·{" "}
{r.passed ? "通过" : "失败"}
</option>
))}
</select>
<div className="ml-auto flex gap-1.5">
<Button busy={busy} onClick={async () => { await rerun(); setSelectedRun(null); }}>
<Icon name="rotate-cw" size={13} /> {busy ? "运行中…" : "再次运行"}
</Button>
<button className="ops-btn ops-btn-secondary" onClick={() => download("md")}>
<Icon name="file-down" size={13} /> 导出 MD
</button>
<button className="ops-btn ops-btn-secondary" onClick={() => download("json")}>
<Icon name="file-down" size={13} /> 导出 JSON
</button>
</div>
</div>
{/* 先展示运行结论,明细按步骤展开 */}
<div className="test-stat-grid mb-3">
<div className={cx("test-stat", run.passed ? "ok" : "bad")}>
<div className="test-stat-value">{run.passed ? "通过" : "未通过"}</div>
<div className="test-stat-label">本次运行</div>
</div>
<div className="test-stat">
<div className="test-stat-value">{stats.ok} / {run.stepResults.length}</div>
<div className="test-stat-label">步骤通过 · {stats.fail} 失败</div>
</div>
<div className="test-stat">
<div className="test-stat-value">{run.totalMs}</div>
<div className="test-stat-label">总耗时 (ms)</div>
</div>
</div>
<p className="ops-faint mb-3 text-xs">{new Date(run.at).toLocaleString("zh-CN")} · {assertCount} 条断言 · 点击步骤查看期望与实际值</p>
{/* 过滤 */}
<div className="mb-2 flex gap-1.5">
{([["all", `全部 ${run.stepResults.length}`], ["pass", `通过 ${stats.ok}`], ["fail", `失败 ${stats.fail}`]] as const).map(
([k, label]) => (
<button key={k} className={cx("ops-chip", filter === k && "ops-chip-active")} onClick={() => setFilter(k)}>
{label}
</button>
),
)}
</div>
{shown.length === 0 && <EmptyState title="没有符合筛选条件的步骤" />}
{/* 步骤明细 */}
<div className="space-y-2">
{shown.map((s) => (
<div key={s.stepId} className="test-step-result">
<button className="flex w-full min-w-0 flex-wrap items-center gap-2 px-3 py-3 text-left" aria-expanded={expanded[`${run.id}-${s.stepId}`] ?? !s.ok} onClick={() => setExpanded((prev) => ({ ...prev, [`${run.id}-${s.stepId}`]: !(prev[`${run.id}-${s.stepId}`] ?? !s.ok) }))}>
<Icon name={s.ok ? "circle-check" : "circle-x"} size={15} className={s.ok ? "ops-success" : "ops-danger"} />
<span className="text-[13px] font-medium">{s.name}</span>
{s.method && s.url !== "—" && (
<span className="ops-faint truncate font-mono text-[11px]">
{s.method} {s.url}
</span>
)}
{s.httpStatus !== null && (
<span className={cx("text-[11px] font-bold", s.httpStatus < 400 ? "ops-success" : "ops-danger")}>{s.httpStatus}</span>
)}
{s.durationMs !== null && (
<span className="ops-faint ml-auto flex items-center gap-1 text-[11px]">
<Icon name="timer" size={11} /> {s.durationMs} ms
</span>
)}
<Icon name="chevron-down" size={13} className="ops-faint" />
</button>
{(expanded[`${run.id}-${s.stepId}`] ?? !s.ok) && <>
{s.error && <div className="ops-danger px-3 pb-2 text-[11px]">{s.error}</div>}
{s.assertionResults.length > 0 && (
<div className="overflow-x-auto px-3 pb-2">
<table className="ops-table">
<thead>
<tr>
<th>断言</th>
<th>期望</th>
<th>实际</th>
<th>结果</th>
</tr>
</thead>
<tbody>
{s.assertionResults.map((a, j) => (
<tr key={j}>
<td>{a.label}</td>
<td className="font-mono">{a.expected || "—"}</td>
<td className="font-mono">{a.actual || "—"}</td>
<td className={a.passed ? "ops-success" : "ops-danger"}>{a.passed ? "通过" : "失败"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>}
</div>
))}
</div>
</div>
);
}
+199
View File
@@ -0,0 +1,199 @@
import type { AssertOp, AssertTarget, TestCase } from "../../core/index";
import { ASSERT_OP_LABELS, newAssertion, newExtract } from "../../core/index";
import { useState } from "react";
import { cx } from "../../components/common";
import { Icon } from "../../components/icons";
const TARGET_LABELS: Record<AssertTarget, string> = {
status: "状态码",
timeMs: "耗时(ms)",
header: "Header",
body: "Body",
};
export function StepEditor({
index,
total,
step,
nodeOptions,
onPatch,
onRemove,
onMove,
}: {
index: number;
total: number;
step: TestCase["steps"][number];
nodeOptions: { id: string; label: string; name: string; method: string }[];
onPatch: (p: Partial<TestCase["steps"][number]>) => void;
onRemove: () => void;
onMove: (dir: -1 | 1) => void;
}) {
const [detailTab, setDetailTab] = useState<"assertions" | "extracts">("assertions");
return (
<div className={cx("test-step-editor", !step.enabled && "test-step-disabled")}>
<div className="test-step-toolbar">
<span className="ops-faint w-5 text-center text-[11px] font-bold">{index + 1}</span>
<select
aria-label="选择步骤接口"
className="ops-input ops-input-sm min-w-0 flex-1"
value={step.nodeId}
onChange={(e) => onPatch({ nodeId: e.target.value })}
>
<option value="">选择 HTTP 接口…</option>
{nodeOptions.map((o) => (
<option key={o.id} value={o.id}>
{o.method} · {o.label}
</option>
))}
</select>
<button className="ops-btn ops-btn-ghost px-1.5" title="上移" disabled={index === 0} onClick={() => onMove(-1)}>
<Icon name="chevron-right" size={12} style={{ transform: "rotate(-90deg)" }} />
</button>
<button className="ops-btn ops-btn-ghost px-1.5" title="下移" disabled={index === total - 1} onClick={() => onMove(1)}>
<Icon name="chevron-down" size={12} />
</button>
<button
className="ops-btn ops-btn-ghost px-1.5"
title={step.enabled ? "禁用步骤" : "启用步骤"}
onClick={() => onPatch({ enabled: !step.enabled })}
>
<Icon name={step.enabled ? "circle-check" : "circle-x"} size={13} className={step.enabled ? "ops-success" : "ops-faint"} />
</button>
<button className="ops-btn ops-btn-ghost px-1.5" title="删除步骤" onClick={onRemove}>
<Icon name="trash" size={13} />
</button>
</div>
<div className="test-step-tabs" role="tablist" aria-label="步骤配置">
<button role="tab" aria-selected={detailTab === "assertions"} className={cx("test-step-tab", detailTab === "assertions" && "active")} onClick={() => setDetailTab("assertions")}>断言 <span>{step.assertions.length}</span></button>
<button role="tab" aria-selected={detailTab === "extracts"} className={cx("test-step-tab", detailTab === "extracts" && "active")} onClick={() => setDetailTab("extracts")}>变量提取 <span>{step.extracts.length}</span></button>
</div>
<div className="test-step-fields">
{/* 断言 */}
{detailTab === "assertions" && <div>
<p className="ops-faint mb-4 text-xs">校验响应状态、耗时、Header 或 Body。仅启用的断言参与运行。</p>
{step.assertions.length === 0 && <p className="ops-faint mb-3 text-xs">尚无断言,运行时只记录请求是否成功。</p>}
{step.assertions.map((a) => (
<div key={a.id} className="test-assert-row">
<select
className="ops-input ops-input-sm"
value={a.target}
onChange={(e) =>
onPatch({
assertions: step.assertions.map((x) =>
x.id === a.id ? { ...x, target: e.target.value as AssertTarget, locator: "", expected: "" } : x,
),
})
}
>
{(Object.keys(TARGET_LABELS) as AssertTarget[]).map((t) => (
<option key={t} value={t}>
{TARGET_LABELS[t]}
</option>
))}
</select>
{(a.target === "header" || a.op.startsWith("jsonpath")) && (
<input
className="ops-input ops-input-sm test-assert-locator"
placeholder={a.target === "header" ? "Header 名" : "$.data.id"}
value={a.locator}
onChange={(e) =>
onPatch({ assertions: step.assertions.map((x) => (x.id === a.id ? { ...x, locator: e.target.value } : x)) })
}
/>
)}
<select
className="ops-input ops-input-sm"
value={a.op}
onChange={(e) =>
onPatch({ assertions: step.assertions.map((x) => (x.id === a.id ? { ...x, op: e.target.value as AssertOp } : x)) })
}
>
{(Object.keys(ASSERT_OP_LABELS) as AssertOp[]).map((op) => (
<option key={op} value={op}>
{ASSERT_OP_LABELS[op]}
</option>
))}
</select>
{a.op !== "exists" && a.op !== "not_exists" && (
<input
className="ops-input ops-input-sm test-assert-expected"
placeholder="期望值"
value={a.expected}
onChange={(e) =>
onPatch({ assertions: step.assertions.map((x) => (x.id === a.id ? { ...x, expected: e.target.value } : x)) })
}
/>
)}
<button
className="ops-btn ops-btn-ghost px-1.5"
title={a.enabled ? "禁用" : "启用"}
onClick={() =>
onPatch({ assertions: step.assertions.map((x) => (x.id === a.id ? { ...x, enabled: !x.enabled } : x)) })
}
>
<Icon name={a.enabled ? "circle-check" : "circle-x"} size={12} className={a.enabled ? "ops-success" : "ops-faint"} />
</button>
<button
className="ops-btn ops-btn-ghost ops-danger px-1.5"
title="删除断言"
onClick={() => onPatch({ assertions: step.assertions.filter((x) => x.id !== a.id) })}
>
<Icon name="x" size={12} />
</button>
</div>
))}
<button className="ops-btn ops-btn-ghost mt-1 !py-0.5 text-[11px]" onClick={() => onPatch({ assertions: [...step.assertions, newAssertion()] })}>
<Icon name="plus" size={11} /> 添加断言
</button>
</div>}
{/* 变量提取 */}
{detailTab === "extracts" && <div>
<p className="ops-faint mb-4 text-xs">从 JSON 响应提取变量,供后续步骤以 {"{{变量名}}"} 引用。</p>
{step.extracts.length === 0 && (
<button
className="ops-btn ops-btn-ghost !py-0.5 text-[11px]"
onClick={() => onPatch({ extracts: [...step.extracts, newExtract()] })}
>
<Icon name="plus" size={11} /> 添加提取
</button>
)}
{step.extracts.map((ex) => (
<div key={ex.id} className="test-assert-row">
<input
className="ops-input ops-input-sm test-assert-locator"
placeholder="$.data.token"
value={ex.jsonpath}
onChange={(e) =>
onPatch({ extracts: step.extracts.map((x) => (x.id === ex.id ? { ...x, jsonpath: e.target.value } : x)) })
}
/>
<span className="ops-faint text-[11px]">→</span>
<input
className="ops-input ops-input-sm test-assert-expected"
placeholder="变量名 token"
value={ex.varName}
onChange={(e) =>
onPatch({ extracts: step.extracts.map((x) => (x.id === ex.id ? { ...x, varName: e.target.value } : x)) })
}
/>
<button
className="ops-btn ops-btn-ghost ops-danger px-1.5"
title="删除"
onClick={() => onPatch({ extracts: step.extracts.filter((x) => x.id !== ex.id) })}
>
<Icon name="x" size={12} />
</button>
</div>
))}
{step.extracts.length > 0 && (
<button className="ops-btn ops-btn-ghost mt-1 !py-0.5 text-[11px]" onClick={() => onPatch({ extracts: [...step.extracts, newExtract()] })}>
<Icon name="plus" size={11} /> 添加提取
</button>
)}
</div>}
</div>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { useTestActions } from "./useTestActions";
import type { TestRun } from "../../core/index";
import { useEffect, useState } from "react";
import { useWorkspace } from "../../workspaceStore";
import { cx } from "../../components/common";
import { Icon } from "../../components/icons";
import { Button } from "../../components/primitives";
import { AiCaseDialog } from "../../components/dialogs/AiCaseDialog";
import { ScheduleDialog } from "../../components/dialogs/ScheduleDialog";
import { CaseDetail } from "./CaseDetail";
import { installHttp8Regression } from "./regression";
import { WorkspaceSidebar } from "../../components/WorkspaceSidebar";
export function TestingView() {
const testCases = useWorkspace((s) => s.testCases);
const testRuns = useWorkspace((s) => s.testRuns);
const schedules = useWorkspace((s) => s.schedules);
const addTestCase = useWorkspace((s) => s.addTestCase);
const [selected, setSelected] = useState<string | null>(testCases[0]?.id ?? null);
const [showAi, setShowAi] = useState(false);
const [showSchedule, setShowSchedule] = useState(false);
const { busy: runAllBusy, runAll } = useTestActions();
const tc = testCases.find((c) => c.id === selected) ?? null;
const lastRunOf = (caseId: string): TestRun | undefined => testRuns.find((r) => r.caseId === caseId);
useEffect(() => {
if (!testCases.some((c) => c.id === selected)) setSelected(testCases[0]?.id ?? null);
}, [testCases, selected]);
return (
<div className="flex min-h-0 min-w-0 flex-1">
{/* 左栏:用例列表 */}
<WorkspaceSidebar label="测试" className="test-case-sidebar">
<div className="flex items-center gap-1.5 p-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
<Icon name="list-checks" size={15} className="ops-accent" />
<span className="text-[13px] font-semibold">场景用例</span>
<span className="ops-faint ml-auto text-[11px]">{testCases.length}</span>
</div>
<div className="p-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
<div className="grid grid-cols-2 gap-1.5">
<button className="ops-btn ops-btn-secondary !px-2" onClick={() => setSelected(addTestCase())}>
<Icon name="plus" size={13} /> 新建
</button>
<button className="ops-btn ops-btn-secondary !px-2" onClick={() => setShowAi(true)}>
<Icon name="bot" size={13} /> AI 生成
</button>
</div>
<details className="mt-2 text-xs"><summary className="ops-faint cursor-pointer px-1 py-1">示例与学习</summary><button className="ops-btn ops-btn-ghost mt-1 w-full justify-start !px-2" title="装载本地 Gin /v1/echo 八组合回归用例" onClick={() => {
const state = useWorkspace.getState();
if ((state.collections.some((c) => c.name === "示例集合") || state.testCases.some((c) => c.id === "tc-opsx-http8-regression")) && !confirm("装载示例会刷新已有的示例集合和回归用例。确定继续?")) return;
setSelected(installHttp8Regression());
}}><Icon name="play" size={13} /> 装载回归示例</button></details>
</div>
<div className="ops-scroll min-h-0 flex-1 p-2">
{testCases.length === 0 && (
<div className="ops-faint px-2 py-8 text-center text-xs leading-5">
还没有用例
<br />
点「新建」手动编排,或用「AI 生成」
</div>
)}
{testCases.map((c) => {
const last = lastRunOf(c.id);
const sched = schedules.find((s) => s.caseId === c.id && s.enabled);
return (
<button
key={c.id}
className={cx("ops-tree-row w-full text-left", selected === c.id && "ops-tree-row-active")}
onClick={() => setSelected(c.id)}
>
<span className="truncate text-[12px] font-medium">{c.name}</span>
<span className="ops-faint ml-auto shrink-0 text-[11px]">{c.steps.length} 步</span>
{sched && <Icon name="calendar-clock" size={11} className="ops-accent" />}
{last && (
<Icon
name={last.passed ? "circle-check" : "circle-x"}
size={12}
className={last.passed ? "ops-success" : "ops-danger"}
/>
)}
</button>
);
})}
</div>
<div className="space-y-1.5 p-2.5" style={{ borderTop: "1px solid var(--border)" }}>
<button className="ops-btn ops-btn-secondary w-full" onClick={() => setShowSchedule(true)}>
<Icon name="calendar-clock" size={13} /> 定时任务
{schedules.filter((s) => s.enabled).length > 0 && (
<span className="ops-count">{schedules.filter((s) => s.enabled).length}</span>
)}
</button>
<Button variant="primary" className="w-full" busy={runAllBusy} disabled={testCases.length === 0} onClick={runAll}>
<Icon name="play" size={13} /> {runAllBusy ? "运行中…" : "运行全部"}
</Button>
</div>
</WorkspaceSidebar>
{/* 右侧:用例详情 */}
{tc ? (
<CaseDetail key={tc.id} tc={tc} onOpenSchedule={() => setShowSchedule(true)} />
) : (
<div className="grid min-w-0 flex-1 place-items-center">
<div className="text-center">
<div className="ops-dim mb-2 flex justify-center"><Icon name="list-checks" size={40} /></div>
<div className="ops-dim text-sm font-semibold">自动化测试</div>
<div className="ops-faint mt-1 text-xs leading-5">
手动编排 · AI 生成 · 定时调度,三种方式共用同一套场景用例
<br />
左侧新建用例;侧栏底部「自动化测试」随时进出本视图
</div>
</div>
</div>
)}
{showAi && <AiCaseDialog onClose={() => setShowAi(false)} onSaved={(id) => setSelected(id)} />}
{showSchedule && <ScheduleDialog focusCaseId={selected} onClose={() => setShowSchedule(false)} />}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it, vi } from "vitest";
import { executeOnce, useTestExecution } from "./execution";
describe("shared test execution", () => {
it("shares a pending run across entry points and releases it after completion", async () => {
let resolve!: (value: string) => void;
const task = vi.fn(() => new Promise<string>((r) => { resolve = r; }));
const first = executeOnce("case-1", task);
const second = executeOnce("case-1", task);
expect(first).toBe(second);
expect(useTestExecution.getState().running.has("case-1")).toBe(true);
await Promise.resolve();
expect(task).toHaveBeenCalledTimes(1);
resolve("report");
await expect(first).resolves.toBe("report");
expect(useTestExecution.getState().running.has("case-1")).toBe(false);
await expect(executeOnce("case-1", async () => "new report")).resolves.toBe("new report");
});
it("clears failed runs and allows retry without clearing other active cases", async () => {
let resolve!: () => void;
const other = executeOnce("other", () => new Promise<void>((r) => { resolve = r; }));
await expect(executeOnce("failed", () => { throw new Error("transport unavailable"); }))
.rejects.toThrow("transport unavailable");
expect([...useTestExecution.getState().running]).toEqual(["other"]);
await expect(executeOnce("failed", async () => "retry")).resolves.toBe("retry");
resolve();
await other;
expect(useTestExecution.getState().running.size).toBe(0);
});
});
+23
View File
@@ -0,0 +1,23 @@
import { create } from "zustand";
export const useTestExecution = create<{ running: ReadonlySet<string>; batchBusy: boolean }>(() => ({
running: new Set(), batchBusy: false,
}));
const pending = new Map<string, Promise<unknown>>();
/** All entry points share the same in-flight run, including the scheduler. */
export function executeOnce<T>(caseId: string, execute: () => Promise<T>): Promise<T> {
const existing = pending.get(caseId);
if (existing) return existing as Promise<T>;
useTestExecution.setState((s) => ({ running: new Set([...s.running, caseId]) }));
const promise = Promise.resolve().then(execute).finally(() => {
pending.delete(caseId);
useTestExecution.setState((s) => {
const running = new Set(s.running);
running.delete(caseId);
return { running };
});
});
pending.set(caseId, promise);
return promise;
}
+32
View File
@@ -0,0 +1,32 @@
import { createSeedWorkspace } from "../../core/index";
import { useWorkspace } from "../../workspaceStore";
const REGRESSION_CASE_ID = "tc-opsx-http8-regression";
/** 装载 / 刷新本地 Gin echo 八组合回归用例(不整库清空) */
export function installHttp8Regression(): string {
const seed = createSeedWorkspace();
const state = useWorkspace.getState();
const seedCase = seed.testCases.find((c) => c.id === REGRESSION_CASE_ID) ?? seed.testCases[0]!;
const localEnv = seed.environments.find((e) => e.name === "本地") ?? seed.environments[0];
const hasCase = state.testCases.some((c) => c.id === REGRESSION_CASE_ID);
const hasCollection = state.collections.some((c) => c.name === "示例集合");
const localExisting = state.environments.find((e) => e.name === "本地");
state.loadWorkspaceData({
version: 2,
collections: hasCollection
? state.collections.map((c) => (c.name === "示例集合" ? seed.collections[0]! : c))
: [...state.collections, ...seed.collections],
environments: localExisting
? state.environments
: [...state.environments, ...seed.environments.filter((e) => e.name === "本地")],
activeEnvironmentId: localExisting?.id ?? localEnv?.id ?? state.activeEnvironmentId,
history: state.history,
settings: state.settings,
testCases: hasCase
? state.testCases.map((c) => (c.id === REGRESSION_CASE_ID ? seedCase : c))
: [...state.testCases, seedCase],
testRuns: state.testRuns,
schedules: state.schedules,
});
return seedCase.id;
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { reorderSteps } from "./stepOrder";
const steps = [{ id: "a", value: 1 }, { id: "b", value: 2 }, { id: "c", value: 3 }];
describe("test step ordering", () => {
it("moves a step forward and backward without losing its data", () => {
expect(reorderSteps(steps, "a", "c")).toEqual([steps[1], steps[2], steps[0]]);
expect(reorderSteps(steps, "c", "a")).toEqual([steps[2], steps[0], steps[1]]);
expect(steps.map((step) => step.id)).toEqual(["a", "b", "c"]);
});
it("ignores a missing or identical source or target", () => {
expect(reorderSteps(steps, "missing", "a")).toBe(steps);
expect(reorderSteps(steps, "a", "missing")).toBe(steps);
expect(reorderSteps(steps, "a", "a")).toBe(steps);
});
});
+10
View File
@@ -0,0 +1,10 @@
/** Move one step to the position occupied by another step, retaining every step's identity. */
export function reorderSteps<T extends { id: string }>(steps: T[], sourceId: string, targetId: string): T[] {
const source = steps.findIndex((step) => step.id === sourceId);
const target = steps.findIndex((step) => step.id === targetId);
if (source < 0 || target < 0 || source === target) return steps;
const reordered = [...steps];
const [moved] = reordered.splice(source, 1);
reordered.splice(target, 0, moved);
return reordered;
}
@@ -0,0 +1,20 @@
import type { Collection, RequestNode, HttpRequestData } from "../../core/index";
import { useMemo } from "react";
import { useWorkspace } from "../../workspaceStore";
export function useHttpNodeOptions(): { id: string; label: string; name: string; method: string }[] {
const collections = useWorkspace((s) => s.collections);
return useMemo(() => {
const out: { id: string; label: string; name: string; method: string }[] = [];
const walk = (nodes: Collection["children"], prefix: string): void => {
for (const n of nodes) {
if (n.type === "folder") walk(n.children, prefix ? `${prefix}/${n.name}` : n.name);
else if (n.kind === "http") {
const d = (n as RequestNode & { kind: "http" }).data as HttpRequestData;
out.push({ id: n.id, label: `${prefix ? `${prefix}/` : ""}${n.name}`, name: n.name, method: d.method });
}
}
};
for (const c of collections) walk(c.children, c.name);
return out;
}, [collections]);
}
+28
View File
@@ -0,0 +1,28 @@
import { runTestCase } from "../../testRunner";
import { useWorkspace } from "../../workspaceStore";
import { useTestExecution } from "./execution";
function reportError(error: unknown) {
useWorkspace.getState().showToast(error instanceof Error ? error.message : String(error), "error");
}
export function useTestActions(caseId?: string) {
const busy = useTestExecution((s) => caseId ? s.running.has(caseId) : s.batchBusy);
const run = async () => {
try {
if (caseId) await runTestCase(caseId, "manual");
} catch (error) { reportError(error); }
};
const runAll = async () => {
if (useTestExecution.getState().batchBusy) return;
useTestExecution.setState({ batchBusy: true });
const ids = useWorkspace.getState().testCases.map((c) => c.id);
try {
for (const id of ids) {
try { await runTestCase(id, "manual"); }
catch (error) { reportError(error); }
}
} finally { useTestExecution.setState({ batchBusy: false }); }
};
return { busy, run, runAll };
}
+33
View File
@@ -0,0 +1,33 @@
import { Logo } from "../../components/Logo";
import { useWorkspace } from "../../workspaceStore";
export function EmptyState() {
const setDialog = useWorkspace((s) => s.setDialog);
return (
<div className="grid min-h-0 flex-1 place-items-center">
<div className="text-center">
<div className="mb-3 opacity-80"><Logo size={56} /></div>
<div className="ops-dim mb-1 text-sm font-semibold">OpsxAPI · 接口工作台</div>
<div className="ops-faint mb-5 text-xs">
从左侧选择接口,或
<button className="ops-link mx-1" onClick={() => setDialog({ type: "import" })}>
导入数据
</button>
/
<button
className="ops-link ml-1"
onClick={() =>
setDialog({ type: "new", collectionId: useWorkspace.getState().collections[0]?.id ?? "", parentId: null })
}
>
新建请求
</button>
</div>
<div className="ops-faint text-[11px] leading-6">
HTTP · GraphQL · WebSocket · SSE · Socket.IO · MQTT
<br />
支持 OpenAPI / Postman / Apifox / cURL / HAR 等格式导入导出
</div>
</div>
</div>
);
}
@@ -0,0 +1,25 @@
import { useWorkspace } from "../../workspaceStore";
import { ExportDialog } from "../../components/dialogs/ExportDialog";
import { ImportDialog } from "../../components/dialogs/ImportDialog";
import { AuthDialog, TeamDialog } from "../../components/dialogs/AccountDialogs";
import {
EnvironmentsDialog,
NewItemDialog,
RenameDialog,
SettingsDialog,
} from "../../components/dialogs/WorkspaceDialogs";
export function WorkspaceDialogs() {
const dialog = useWorkspace((s) => s.dialog);
return <>
{dialog?.type === "new" && <NewItemDialog collectionId={dialog.collectionId} parentId={dialog.parentId} />}
{dialog?.type === "import" && <ImportDialog />}
{dialog?.type === "export" && <ExportDialog collectionId={dialog.collectionId} />}
{dialog?.type === "environments" && <EnvironmentsDialog />}
{dialog?.type === "settings" && <SettingsDialog tab={dialog.tab} />}
{dialog?.type === "rename" && <RenameDialog id={dialog.id} />}
{dialog?.type === "auth" && <AuthDialog />}
{dialog?.type === "team" && <TeamDialog />}
</>;
}
@@ -0,0 +1,26 @@
import { useEffect } from "react";
import { useWorkspace } from "../../workspaceStore";
import { closeSession } from "../../send";
import { startScheduler, stopScheduler } from "../../scheduler";
export function useWorkspaceLifecycle() {
const init = useWorkspace((s) => s.init);
useEffect(() => {
init();
}, [init]);
// 关闭标签页时断开实时会话
useEffect(() => {
return useWorkspace.subscribe((state, prev) => {
const closed = prev.openTabIds.filter((id) => !state.openTabIds.includes(id));
for (const id of closed) closeSession(id);
});
}, []);
// 定时任务调度器(应用运行期间常驻)
useEffect(() => {
startScheduler();
return () => stopScheduler();
}, []);
}
+3
View File
@@ -0,0 +1,3 @@
export { OpsxWorkspace } from "./App";
export { useWorkspace } from "./workspaceStore";
export type { Dialog, LiveMessage, RequestRuntime } from "./workspaceStore";
+2
View File
@@ -0,0 +1,2 @@
/** External links used by Settings → About (and similar). Keep URLs in one place. */
export const WECHAT_GROUP_URL = "https://docs.qq.com/doc/DTFdZeWJ6ZUdqTkZV";
Loaded 100 of 299 files, more files were not shown because too many files have changed in this diff. Show more