feat: OpsxAPI v1.0 全协议接口工作台
- 六协议调试:HTTP/GraphQL/WebSocket/SSE/Socket.IO/MQTT
- 集合树/多标签/多环境变量/历史记录,localStorage 持久化
- 导入:OpenAPI 2/3/3.1、Postman、Apifox、Apipost、cURL、HAR、
Insomnia、Hoppscotch、Markdown(自动识别,21 个单测覆盖)
- 导出:OpenAPI 3.0 JSON/YAML、Postman v2.1、Markdown 文档、
全量备份;单请求生成 cURL/fetch/Python/Go 代码
- Gin 服务:HTTP 代理、NDJSON 流式代理(SSE 透传)、
WebSocket 隧道(浏览器自定义握手 Header)
- 内置产品官网 /home 与用户文档站 /docs(SSR,随二进制交付)
- lucide 图标体系 + 品牌标识(Logo/favicon),明暗双主题
- 环境变量:彩色徽标切换器、管理弹窗、{{ 自动补全
This commit is contained in:
commit
dec66db5aa
182 files changed
+23377
No files matched your search
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@opsxapi/core",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"js-yaml": "^4.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.0.9"
|
||||
}
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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, "-");
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 节点 / 集合 / 环境的默认工厂。
|
||||
*/
|
||||
import type {
|
||||
AuthConfig,
|
||||
Collection,
|
||||
CollectionNode,
|
||||
Environment,
|
||||
FolderNode,
|
||||
GraphqlRequestData,
|
||||
HttpRequestData,
|
||||
KVRow,
|
||||
MqttRequestData,
|
||||
RequestKind,
|
||||
RequestNode,
|
||||
RequestDataOf,
|
||||
SocketIoRequestData,
|
||||
SseRequestData,
|
||||
WebsocketRequestData,
|
||||
WorkspaceData,
|
||||
} 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: "{{baseUrl}}/",
|
||||
query: [],
|
||||
headers: [],
|
||||
cookies: [],
|
||||
auth: { ...noAuth },
|
||||
bodyMode: "none",
|
||||
bodyRaw: "",
|
||||
bodyForm: [],
|
||||
} satisfies HttpRequestData;
|
||||
case "graphql":
|
||||
return {
|
||||
kind,
|
||||
url: "{{baseUrl}}/graphql",
|
||||
headers: [kv("Content-Type", "application/json")],
|
||||
auth: { ...noAuth },
|
||||
query: "query {\n \n}",
|
||||
variables: "{}",
|
||||
} satisfies GraphqlRequestData;
|
||||
case "websocket":
|
||||
return {
|
||||
kind,
|
||||
url: "wss://echo.websocket.events",
|
||||
headers: [],
|
||||
protocols: "",
|
||||
} satisfies WebsocketRequestData;
|
||||
case "sse":
|
||||
return {
|
||||
kind,
|
||||
url: "{{baseUrl}}/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 }[] = [],
|
||||
): Environment {
|
||||
return { id: newId(), name, 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 demo = createCollection("示例集合", [
|
||||
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.events" },
|
||||
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 envs = [
|
||||
createEnvironment("正式", [{ key: "baseUrl", value: "https://httpbin.org" }, { key: "token", value: "demo-token" }]),
|
||||
createEnvironment("本地", [{ key: "baseUrl", value: "http://127.0.0.1:8787" }, { key: "token", value: "dev-token" }]),
|
||||
];
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
collections: [demo],
|
||||
environments: envs,
|
||||
activeEnvironmentId: envs[0]?.id ?? null,
|
||||
history: [],
|
||||
settings: { theme: "dark", timeoutMs: 30000 },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
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("导入 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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) : "";
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* 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,
|
||||
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(op).parameters ?? asObj(asObj(doc.paths)[path]).parameters)) {
|
||||
const param = asObj(p);
|
||||
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 = asObj(p);
|
||||
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 = asObj(p);
|
||||
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 = asObj(p);
|
||||
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 = asObj(op.requestBody);
|
||||
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)");
|
||||
|
||||
return 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,
|
||||
});
|
||||
}
|
||||
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// 传输层契约(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,
|
||||
WorkspaceData,
|
||||
ImportResult,
|
||||
} 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";
|
||||
|
||||
// 导入
|
||||
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";
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* 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 HttpApiNode = RequestNodeBase & { kind: "http"; data: HttpRequestData };
|
||||
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;
|
||||
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 = "dark" | "light";
|
||||
|
||||
export type WorkspaceSettings = {
|
||||
theme: ThemeMode;
|
||||
/** 请求超时(毫秒) */
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
export type WorkspaceData = {
|
||||
version: 2;
|
||||
collections: Collection[];
|
||||
environments: Environment[];
|
||||
activeEnvironmentId: string | null;
|
||||
history: HistoryEntry[];
|
||||
settings: WorkspaceSettings;
|
||||
};
|
||||
|
||||
/** 导入结果:集合 + 建议写入环境的前缀变量(如 baseUrl) */
|
||||
export type ImportResult = {
|
||||
collections: Collection[];
|
||||
environmentVariables: { key: string; value: string }[];
|
||||
warnings: string[];
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@opsxapi/transport",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opsxapi/core": "*",
|
||||
"mqtt": "^5.10.1",
|
||||
"socket.io-client": "^4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tauri-apps/api": "^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@tauri-apps/api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxapi/core";
|
||||
|
||||
function buildUrl(spec: HttpSpec): string {
|
||||
const u = new URL(spec.url);
|
||||
if (spec.query) {
|
||||
for (const [k, v] of Object.entries(spec.query)) {
|
||||
if (v !== undefined && v !== "") u.searchParams.set(k, v);
|
||||
}
|
||||
}
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
function headersToRecord(h: Headers): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
h.forEach((v, k) => {
|
||||
out[k] = v;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export const browserTransport: Transport = {
|
||||
async http(spec: HttpSpec): Promise<HttpResult> {
|
||||
const started = performance.now();
|
||||
const init: RequestInit = {
|
||||
method: spec.method,
|
||||
headers: { ...spec.headers },
|
||||
signal: spec.timeoutMs
|
||||
? AbortSignal.timeout(spec.timeoutMs)
|
||||
: undefined,
|
||||
};
|
||||
if (spec.body && spec.body.mode !== "none" && spec.method !== "GET") {
|
||||
init.body = spec.body.raw ?? "";
|
||||
if (spec.body.mode === "json" && !("content-type" in Object.fromEntries(
|
||||
Object.entries(spec.headers).map(([k, v]) => [k.toLowerCase(), v]),
|
||||
))) {
|
||||
(init.headers as Record<string, string>)["Content-Type"] = "application/json";
|
||||
}
|
||||
}
|
||||
const res = await fetch(buildUrl(spec), init);
|
||||
const body = await res.text();
|
||||
const timeMs = Math.round(performance.now() - started);
|
||||
return {
|
||||
status: res.status,
|
||||
headers: headersToRecord(res.headers),
|
||||
body,
|
||||
timeMs,
|
||||
size: new TextEncoder().encode(body).length,
|
||||
};
|
||||
},
|
||||
|
||||
ws(spec: WsSpec): WsSession {
|
||||
const socket = new WebSocket(spec.url, spec.protocols);
|
||||
const messageCbs: Array<(data: string) => void> = [];
|
||||
const statusCbs: Array<(s: string) => void> = [];
|
||||
const emitStatus = (s: string) => statusCbs.forEach((cb) => cb(s));
|
||||
|
||||
emitStatus("connecting");
|
||||
socket.onopen = () => emitStatus("open");
|
||||
socket.onclose = () => emitStatus("closed");
|
||||
socket.onerror = () => emitStatus("error");
|
||||
socket.onmessage = (ev) => {
|
||||
const data = typeof ev.data === "string" ? ev.data : String(ev.data);
|
||||
messageCbs.forEach((cb) => cb(data));
|
||||
};
|
||||
|
||||
return {
|
||||
onMessage(cb) {
|
||||
messageCbs.push(cb);
|
||||
},
|
||||
onStatus(cb) {
|
||||
statusCbs.push(cb);
|
||||
},
|
||||
send(data) {
|
||||
socket.send(data);
|
||||
},
|
||||
close() {
|
||||
socket.close();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Transport } from "@opsxapi/core";
|
||||
import { browserTransport } from "./browser";
|
||||
import { ginProxyTransport } from "./ginProxy";
|
||||
import { tauriNativeTransport } from "./tauriNative";
|
||||
|
||||
export type TransportKind = "tauri-native" | "gin-proxy" | "browser";
|
||||
|
||||
export type DetectOptions = {
|
||||
ginBaseUrl?: string;
|
||||
/** Force a transport regardless of environment */
|
||||
force?: TransportKind;
|
||||
};
|
||||
|
||||
function isTauri(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
const w = window as unknown as { __TAURI_INTERNALS__?: unknown };
|
||||
return Boolean(w.__TAURI_INTERNALS__);
|
||||
}
|
||||
|
||||
export function detectTransport(opts: DetectOptions = {}): {
|
||||
kind: TransportKind;
|
||||
transport: Transport;
|
||||
} {
|
||||
if (opts.force === "browser") {
|
||||
return { kind: "browser", transport: browserTransport };
|
||||
}
|
||||
if (opts.force === "tauri-native") {
|
||||
return { kind: "tauri-native", transport: tauriNativeTransport };
|
||||
}
|
||||
if (opts.force === "gin-proxy") {
|
||||
const base = opts.ginBaseUrl ?? "";
|
||||
return { kind: "gin-proxy", transport: ginProxyTransport(base) };
|
||||
}
|
||||
|
||||
if (isTauri()) {
|
||||
return { kind: "tauri-native", transport: tauriNativeTransport };
|
||||
}
|
||||
|
||||
const base = opts.ginBaseUrl ?? "";
|
||||
return { kind: "gin-proxy", transport: ginProxyTransport(base) };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxapi/core";
|
||||
import { browserTransport } from "./browser";
|
||||
|
||||
/**
|
||||
* HTTP goes through Gin POST /v1/proxy/http.
|
||||
* WS (MVP): falls back to browser WebSocket — custom headers require
|
||||
* desktop/Gin tunnel later (Week 2).
|
||||
*/
|
||||
export function ginProxyTransport(baseUrl: string): Transport {
|
||||
const root = baseUrl.replace(/\/$/, "");
|
||||
|
||||
return {
|
||||
async http(spec: HttpSpec): Promise<HttpResult> {
|
||||
const res = await fetch(`${root}/v1/proxy/http`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(spec),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`gin-proxy HTTP ${res.status}: ${text}`);
|
||||
}
|
||||
return (await res.json()) as HttpResult;
|
||||
},
|
||||
|
||||
ws(spec: WsSpec): WsSession {
|
||||
// TODO(week2): custom headers need desktop/Gin WS tunnel.
|
||||
// For MVP, fall back to browser WebSocket (no custom headers).
|
||||
console.warn(
|
||||
"[opsxapi/transport] gin-proxy WS falls back to browser WebSocket; custom headers need desktop/Gin tunnel later",
|
||||
);
|
||||
return browserTransport.ws(spec);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export { browserTransport } from "./browser";
|
||||
export { ginProxyTransport } from "./ginProxy";
|
||||
export { tauriNativeTransport } from "./tauriNative";
|
||||
export { detectTransport } from "./detect";
|
||||
export type { TransportKind, DetectOptions } from "./detect";
|
||||
|
||||
export {
|
||||
SseParser,
|
||||
httpStreamViaGin,
|
||||
httpStreamDirect,
|
||||
openSse,
|
||||
} from "./streaming";
|
||||
export type {
|
||||
StreamStatus,
|
||||
StreamMeta,
|
||||
StreamSession,
|
||||
StreamCallbacks,
|
||||
SseEvent,
|
||||
SseOptions,
|
||||
} from "./streaming";
|
||||
|
||||
export { wsSmart, probeGin } from "./wsSmart";
|
||||
export type { WsSmartOptions, WsTunnelInfo } from "./wsSmart";
|
||||
|
||||
export { connectSocketIo } from "./socketio";
|
||||
export type { SocketIoSession, SocketIoStatus, SocketIoOptions } from "./socketio";
|
||||
|
||||
export { connectMqtt } from "./mqtt";
|
||||
export type { MqttSession, MqttStatus, MqttMessage, MqttOptions } from "./mqtt";
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* MQTT 客户端封装(动态加载 mqtt,浏览器走 ws/wss)。
|
||||
*/
|
||||
export type MqttStatus = "connecting" | "open" | "reconnecting" | "closed" | "error";
|
||||
|
||||
export type MqttMessage = {
|
||||
topic: string;
|
||||
payload: string;
|
||||
at: number;
|
||||
qos: number;
|
||||
};
|
||||
|
||||
export type MqttSession = {
|
||||
publish(topic: string, payload: string, qos?: 0 | 1 | 2): void;
|
||||
subscribe(topics: string[]): void;
|
||||
unsubscribe(topics: string[]): void;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
export type MqttOptions = {
|
||||
brokerUrl: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
clientId?: string;
|
||||
/** 初始订阅 */
|
||||
topics: string[];
|
||||
qos?: 0 | 1 | 2;
|
||||
onMessage(msg: MqttMessage): void;
|
||||
onStatus?(s: MqttStatus, detail?: string): void;
|
||||
onError?(msg: string): void;
|
||||
};
|
||||
|
||||
export function connectMqtt(opts: MqttOptions): MqttSession {
|
||||
type MqttClient = {
|
||||
on(ev: string, cb: (...args: unknown[]) => void): void;
|
||||
subscribe(topic: string | string[], opts?: { qos?: number }, cb?: (err: unknown) => void): void;
|
||||
unsubscribe(topic: string | string[], cb?: () => void): void;
|
||||
publish(topic: string, message: string | Uint8Array, opts?: { qos?: number }, cb?: (err: unknown) => void): void;
|
||||
end(force?: boolean): void;
|
||||
};
|
||||
let client: MqttClient | null = null;
|
||||
let disposed = false;
|
||||
|
||||
const topics = opts.topics.map((t) => t.trim()).filter(Boolean);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
opts.onStatus?.("connecting");
|
||||
const mqttMod = await import("mqtt");
|
||||
const connect = (mqttMod as unknown as { connect: typeof mqttMod.default.connect }).connect ?? mqttMod.default.connect;
|
||||
const url = opts.brokerUrl.trim();
|
||||
if (!/^(wss?|mqtts?):\/\//i.test(url)) {
|
||||
throw new Error("浏览器端 MQTT 需 ws:// 或 wss:// 地址(例如 wss://broker.emqx.io:8084/mqtt)");
|
||||
}
|
||||
client = connect(url, {
|
||||
clientId: opts.clientId || `opsxapi-${Math.random().toString(16).slice(2, 10)}`,
|
||||
username: opts.username || undefined,
|
||||
password: opts.password || undefined,
|
||||
clean: true,
|
||||
reconnectPeriod: 3000,
|
||||
connectTimeout: 10_000,
|
||||
}) as MqttClient;
|
||||
|
||||
client.on("connect", () => {
|
||||
opts.onStatus?.("open");
|
||||
if (topics.length) client?.subscribe(topics, { qos: opts.qos ?? 0 });
|
||||
});
|
||||
client.on("reconnect", () => opts.onStatus?.("reconnecting"));
|
||||
client.on("close", () => opts.onStatus?.("closed"));
|
||||
client.on("error", (...args: unknown[]) => {
|
||||
const err = args[0];
|
||||
opts.onError?.(err instanceof Error ? err.message : String(err));
|
||||
opts.onStatus?.("error");
|
||||
});
|
||||
client.on("message", (...args: unknown[]) => {
|
||||
const topic = String(args[0]);
|
||||
const payload = args[1] as Uint8Array;
|
||||
const packet = args[2] as { qos?: number } | undefined;
|
||||
const qos = packet?.qos ?? 0;
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder().decode(payload);
|
||||
} catch {
|
||||
text = Array.from(payload ?? []).join(",");
|
||||
}
|
||||
opts.onMessage({ topic, payload: text, at: Date.now(), qos });
|
||||
});
|
||||
|
||||
if (disposed) client.end(true);
|
||||
} catch (e) {
|
||||
opts.onError?.(`mqtt.js 加载/连接失败:${e instanceof Error ? e.message : String(e)}`);
|
||||
opts.onStatus?.("error");
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
publish(topic, payload, qos) {
|
||||
if (!client) {
|
||||
opts.onError?.("MQTT 尚未连接");
|
||||
return;
|
||||
}
|
||||
client.publish(topic.trim(), payload, { qos: qos ?? opts.qos ?? 0 }, (err) => {
|
||||
if (err) opts.onError?.(`发布失败:${String(err)}`);
|
||||
});
|
||||
},
|
||||
subscribe(list) {
|
||||
const t = list.map((x) => x.trim()).filter(Boolean);
|
||||
if (t.length) client?.subscribe(t, { qos: opts.qos ?? 0 });
|
||||
},
|
||||
unsubscribe(list) {
|
||||
const t = list.map((x) => x.trim()).filter(Boolean);
|
||||
if (t.length) client?.unsubscribe(t);
|
||||
},
|
||||
close() {
|
||||
disposed = true;
|
||||
client?.end(true);
|
||||
opts.onStatus?.("closed");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Socket.IO 客户端封装(动态加载 socket.io-client)。
|
||||
*/
|
||||
export type SocketIoStatus = "connecting" | "open" | "closed" | "error";
|
||||
|
||||
export type SocketIoSession = {
|
||||
emit(event: string, args: unknown[]): void;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
export type SocketIoOptions = {
|
||||
url: string;
|
||||
/** 附加选项 JSON:{ path?, transports?, auth?, ... } */
|
||||
optionsJson?: string;
|
||||
/** 监听事件列表;空数组 = onAny 全收 */
|
||||
listenEvents: string[];
|
||||
onEvent(event: string, args: unknown[]): void;
|
||||
onStatus?(s: SocketIoStatus, detail?: string): void;
|
||||
onError?(msg: string): void;
|
||||
};
|
||||
|
||||
type IoSocket = {
|
||||
emit(ev: string, ...args: unknown[]): void;
|
||||
on(ev: string, cb: (...args: unknown[]) => void): void;
|
||||
onAny(cb: (ev: string, ...args: unknown[]) => void): void;
|
||||
disconnect(): void;
|
||||
};
|
||||
|
||||
export function connectSocketIo(opts: SocketIoOptions): SocketIoSession {
|
||||
let socket: IoSocket | null = null;
|
||||
let disposed = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
opts.onStatus?.("connecting");
|
||||
const { io } = await import("socket.io-client");
|
||||
const extra: Record<string, unknown> = {};
|
||||
if (opts.optionsJson?.trim()) {
|
||||
try {
|
||||
Object.assign(extra, JSON.parse(opts.optionsJson));
|
||||
} catch {
|
||||
opts.onError?.("连接选项不是合法 JSON,已忽略");
|
||||
}
|
||||
}
|
||||
const s: IoSocket = io(opts.url, {
|
||||
transports: ["websocket", "polling"],
|
||||
reconnection: false,
|
||||
...extra,
|
||||
}) as unknown as IoSocket;
|
||||
socket = s;
|
||||
|
||||
s.on("connect", () => opts.onStatus?.("open"));
|
||||
s.on("disconnect", (...args: unknown[]) =>
|
||||
opts.onStatus?.("closed", String(args[0] ?? "")),
|
||||
);
|
||||
s.on("connect_error", (...args: unknown[]) => {
|
||||
const err = args[0];
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
opts.onError?.(msg);
|
||||
opts.onStatus?.("error", msg);
|
||||
});
|
||||
|
||||
const events = opts.listenEvents.map((e) => e.trim()).filter(Boolean);
|
||||
if (events.length === 0 || events.includes("*")) {
|
||||
s.onAny((ev, ...args) => opts.onEvent(ev, args));
|
||||
} else {
|
||||
for (const ev of events) {
|
||||
s.on(ev, (...args: unknown[]) => opts.onEvent(ev, args));
|
||||
}
|
||||
}
|
||||
if (disposed) s.disconnect();
|
||||
} catch (e) {
|
||||
opts.onError?.(`socket.io-client 加载/连接失败:${e instanceof Error ? e.message : String(e)}`);
|
||||
opts.onStatus?.("error");
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
emit(event, args) {
|
||||
if (!socket) {
|
||||
opts.onError?.("Socket.IO 尚未连接");
|
||||
return;
|
||||
}
|
||||
socket.emit(event, ...args);
|
||||
},
|
||||
close() {
|
||||
disposed = true;
|
||||
socket?.disconnect();
|
||||
opts.onStatus?.("closed");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* 流式 HTTP / SSE 客户端。
|
||||
* - 直连模式:fetch + ReadableStream(目标站需允许 CORS)
|
||||
* - 代理模式:Gin POST /v1/proxy/http/stream(NDJSON,穿透 SSE/chunked)
|
||||
*/
|
||||
import type { HttpSpec } from "@opsxapi/core";
|
||||
|
||||
export type StreamStatus = "connecting" | "open" | "closed" | "error";
|
||||
|
||||
export type StreamMeta = {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
export type StreamSession = {
|
||||
abort(): void;
|
||||
};
|
||||
|
||||
export type StreamCallbacks = {
|
||||
onMeta?(meta: StreamMeta): void;
|
||||
onChunk?(text: string): void;
|
||||
onEnd?(info: { timeMs: number; size: number }): void;
|
||||
onStatus?(status: StreamStatus): void;
|
||||
onError?(message: string): void;
|
||||
};
|
||||
|
||||
/** SSE 帧 */
|
||||
export type SseEvent = {
|
||||
event: string;
|
||||
data: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
/** 增量 SSE 解析器:喂任意分片,吐完整事件 */
|
||||
export class SseParser {
|
||||
private buffer = "";
|
||||
private event = "message";
|
||||
private dataLines: string[] = [];
|
||||
private lastId: string | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly emit: (ev: SseEvent) => void,
|
||||
private readonly onId?: (id: string) => void,
|
||||
) {}
|
||||
|
||||
feed(text: string): void {
|
||||
this.buffer += text;
|
||||
let idx: number;
|
||||
while ((idx = this.buffer.indexOf("\n")) >= 0) {
|
||||
const line = this.buffer.slice(0, idx).replace(/\r$/, "");
|
||||
this.buffer = this.buffer.slice(idx + 1);
|
||||
this.handleLine(line);
|
||||
}
|
||||
// 兼容无结尾换行的流结束
|
||||
}
|
||||
|
||||
flush(): void {
|
||||
if (this.buffer) {
|
||||
this.handleLine(this.buffer.replace(/\r$/, ""));
|
||||
this.buffer = "";
|
||||
}
|
||||
this.dispatch();
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
if (line === "") {
|
||||
this.dispatch();
|
||||
return;
|
||||
}
|
||||
if (line.startsWith(":")) return; // 注释/心跳
|
||||
const colon = line.indexOf(":");
|
||||
const field = colon < 0 ? line : line.slice(0, colon);
|
||||
let value = colon < 0 ? "" : line.slice(colon + 1);
|
||||
if (value.startsWith(" ")) value = value.slice(1);
|
||||
switch (field) {
|
||||
case "event":
|
||||
this.event = value || "message";
|
||||
break;
|
||||
case "data":
|
||||
this.dataLines.push(value);
|
||||
break;
|
||||
case "id":
|
||||
this.lastId = value;
|
||||
this.onId?.(value);
|
||||
break;
|
||||
default:
|
||||
break; // retry 等忽略
|
||||
}
|
||||
}
|
||||
|
||||
private dispatch(): void {
|
||||
if (this.dataLines.length === 0) return;
|
||||
const ev: SseEvent = {
|
||||
event: this.event,
|
||||
data: this.dataLines.join("\n"),
|
||||
id: this.lastId,
|
||||
};
|
||||
this.event = "message";
|
||||
this.dataLines = [];
|
||||
this.emit(ev);
|
||||
}
|
||||
}
|
||||
|
||||
/** 通过 Gin 流式代理发送(NDJSON 协议) */
|
||||
export async function httpStreamViaGin(
|
||||
ginBase: string,
|
||||
spec: HttpSpec,
|
||||
cb: StreamCallbacks,
|
||||
): Promise<StreamSession> {
|
||||
const controller = new AbortController();
|
||||
cb.onStatus?.("connecting");
|
||||
const started = performance.now();
|
||||
try {
|
||||
const res = await fetch(`${ginBase}/v1/proxy/http/stream`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(spec),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`代理流式请求失败 ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
await pumpNdjson(res.body, cb, started);
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted) {
|
||||
cb.onStatus?.("closed");
|
||||
return { abort: () => controller.abort() };
|
||||
}
|
||||
cb.onError?.(e instanceof Error ? e.message : String(e));
|
||||
cb.onStatus?.("error");
|
||||
}
|
||||
return { abort: () => controller.abort() };
|
||||
}
|
||||
|
||||
async function pumpNdjson(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
cb: StreamCallbacks,
|
||||
started: number,
|
||||
): Promise<void> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let size = 0;
|
||||
let metaSeen = false;
|
||||
|
||||
const handleLine = (line: string) => {
|
||||
if (!line.trim()) return;
|
||||
let ev: {
|
||||
type: string;
|
||||
status?: number;
|
||||
headers?: Record<string, string>;
|
||||
data?: string;
|
||||
message?: string;
|
||||
timeMs?: number;
|
||||
size?: number;
|
||||
};
|
||||
try {
|
||||
ev = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
switch (ev.type) {
|
||||
case "meta":
|
||||
metaSeen = true;
|
||||
cb.onMeta?.({ status: ev.status ?? 0, headers: ev.headers ?? {} });
|
||||
cb.onStatus?.("open");
|
||||
break;
|
||||
case "chunk": {
|
||||
size += ev.size ?? 0;
|
||||
if (ev.data) {
|
||||
const binary = atob(ev.data);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
cb.onChunk?.(decoder.decode(bytes, { stream: true }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "end":
|
||||
cb.onEnd?.({ timeMs: ev.timeMs ?? Math.round(performance.now() - started), size: ev.size ?? size });
|
||||
cb.onStatus?.("closed");
|
||||
break;
|
||||
case "error":
|
||||
cb.onError?.(ev.message ?? "代理流错误");
|
||||
cb.onStatus?.("error");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let idx: number;
|
||||
while ((idx = buffer.indexOf("\n")) >= 0) {
|
||||
handleLine(buffer.slice(0, idx));
|
||||
buffer = buffer.slice(idx + 1);
|
||||
}
|
||||
}
|
||||
if (buffer) handleLine(buffer);
|
||||
if (!metaSeen) {
|
||||
cb.onEnd?.({ timeMs: Math.round(performance.now() - started), size });
|
||||
cb.onStatus?.("closed");
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** 直连流式 fetch(CORS 允许时) */
|
||||
export async function httpStreamDirect(spec: HttpSpec, cb: StreamCallbacks): Promise<StreamSession> {
|
||||
const controller = new AbortController();
|
||||
cb.onStatus?.("connecting");
|
||||
const started = performance.now();
|
||||
const url = new URL(spec.url);
|
||||
if (spec.query) {
|
||||
for (const [k, v] of Object.entries(spec.query)) {
|
||||
if (v !== "") url.searchParams.set(k, v);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const init: RequestInit = {
|
||||
method: spec.method,
|
||||
headers: { ...spec.headers },
|
||||
signal: controller.signal,
|
||||
};
|
||||
if (spec.body && spec.body.mode !== "none" && spec.method !== "GET") {
|
||||
init.body = spec.body.raw ?? "";
|
||||
}
|
||||
const res = await fetch(url.toString(), init);
|
||||
const headers: Record<string, string> = {};
|
||||
res.headers.forEach((v, k) => {
|
||||
headers[k] = v;
|
||||
});
|
||||
cb.onMeta?.({ status: res.status, headers });
|
||||
cb.onStatus?.("open");
|
||||
|
||||
if (!res.body) {
|
||||
const text = await res.text();
|
||||
cb.onChunk?.(text);
|
||||
cb.onEnd?.({ timeMs: Math.round(performance.now() - started), size: text.length });
|
||||
cb.onStatus?.("closed");
|
||||
return { abort: () => controller.abort() };
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let size = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value?.length ?? 0;
|
||||
cb.onChunk?.(decoder.decode(value, { stream: true }));
|
||||
}
|
||||
cb.onEnd?.({ timeMs: Math.round(performance.now() - started), size });
|
||||
cb.onStatus?.("closed");
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted) {
|
||||
cb.onStatus?.("closed");
|
||||
} else {
|
||||
cb.onError?.(e instanceof Error ? e.message : String(e));
|
||||
cb.onStatus?.("error");
|
||||
}
|
||||
}
|
||||
return { abort: () => controller.abort() };
|
||||
}
|
||||
|
||||
export type SseOptions = {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
query?: Record<string, string>;
|
||||
lastEventId?: string;
|
||||
/** 强制走 Gin 代理(目标站 CORS 不允许时) */
|
||||
forceProxy?: boolean;
|
||||
ginBase?: string;
|
||||
onEvent(ev: SseEvent): void;
|
||||
onStatus?(s: StreamStatus, meta?: StreamMeta): void;
|
||||
onError?(msg: string, willFallback: boolean): void;
|
||||
};
|
||||
|
||||
/** SSE 会话:先试直连,失败自动切代理 */
|
||||
export function openSse(opts: SseOptions): StreamSession {
|
||||
const controller = { aborted: false, abortFns: [] as Array<() => void> };
|
||||
const run = async () => {
|
||||
const parser = new SseParser((ev) => opts.onEvent(ev));
|
||||
|
||||
const callbacks: StreamCallbacks = {
|
||||
onMeta: (meta) => opts.onStatus?.("open", meta),
|
||||
onChunk: (text) => parser.feed(text),
|
||||
onEnd: () => {
|
||||
parser.flush();
|
||||
opts.onStatus?.("closed");
|
||||
},
|
||||
onError: (msg) => {
|
||||
opts.onError?.(msg, false);
|
||||
opts.onStatus?.("error");
|
||||
},
|
||||
};
|
||||
|
||||
const specHeaders = { ...opts.headers };
|
||||
if (opts.lastEventId) specHeaders["Last-Event-ID"] = opts.lastEventId;
|
||||
|
||||
if (!opts.forceProxy) {
|
||||
let direct: StreamSession | null = null;
|
||||
let directFailed = false;
|
||||
const directCb: StreamCallbacks = {
|
||||
...callbacks,
|
||||
onError: (msg) => {
|
||||
directFailed = true;
|
||||
opts.onError?.(msg, true);
|
||||
},
|
||||
};
|
||||
direct = await httpStreamDirect(
|
||||
{
|
||||
method: "GET",
|
||||
url: opts.url,
|
||||
headers: specHeaders,
|
||||
query: opts.query,
|
||||
body: { mode: "none" },
|
||||
},
|
||||
directCb,
|
||||
);
|
||||
controller.abortFns.push(() => direct?.abort());
|
||||
// 直连迅速失败(连接层错误)→ 代理兜底
|
||||
if (!directFailed) return;
|
||||
}
|
||||
if (controller.aborted) return;
|
||||
const proxy = await httpStreamViaGin(
|
||||
opts.ginBase ?? "",
|
||||
{
|
||||
method: "GET",
|
||||
url: opts.url,
|
||||
headers: specHeaders,
|
||||
query: opts.query,
|
||||
body: { mode: "none" },
|
||||
},
|
||||
callbacks,
|
||||
);
|
||||
controller.abortFns.push(() => proxy.abort());
|
||||
};
|
||||
void run();
|
||||
return {
|
||||
abort() {
|
||||
controller.aborted = true;
|
||||
for (const fn of controller.abortFns) fn();
|
||||
opts.onStatus?.("closed");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxapi/core";
|
||||
|
||||
async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
try {
|
||||
const { invoke: tauriInvoke } = await import("@tauri-apps/api/core");
|
||||
return await tauriInvoke<T>(cmd, args);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new Error(
|
||||
`tauri-native invoke("${cmd}") failed (are you running inside OpsxAPI desktop?): ${msg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const tauriNativeTransport: Transport = {
|
||||
async http(spec: HttpSpec): Promise<HttpResult> {
|
||||
return invoke<HttpResult>("http_send", { spec });
|
||||
},
|
||||
|
||||
ws(spec: WsSpec): WsSession {
|
||||
const messageCbs: Array<(data: string) => void> = [];
|
||||
const statusCbs: Array<(s: string) => void> = [];
|
||||
let sessionId: string | null = null;
|
||||
|
||||
const emitStatus = (s: string) => statusCbs.forEach((cb) => cb(s));
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
emitStatus("connecting");
|
||||
sessionId = await invoke<string>("ws_connect", { spec });
|
||||
emitStatus("open");
|
||||
// Full event listen pipeline is Week 2; stub keeps UI usable.
|
||||
} catch (e) {
|
||||
emitStatus("error");
|
||||
messageCbs.forEach((cb) =>
|
||||
cb(`[ws_connect error] ${e instanceof Error ? e.message : String(e)}`),
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
onMessage(cb) {
|
||||
messageCbs.push(cb);
|
||||
},
|
||||
onStatus(cb) {
|
||||
statusCbs.push(cb);
|
||||
},
|
||||
send(data) {
|
||||
if (!sessionId) {
|
||||
messageCbs.forEach((cb) => cb("[ws] not connected"));
|
||||
return;
|
||||
}
|
||||
void invoke("ws_send", { sessionId, data }).catch((e) => {
|
||||
messageCbs.forEach((cb) =>
|
||||
cb(`[ws_send error] ${e instanceof Error ? e.message : String(e)}`),
|
||||
);
|
||||
});
|
||||
},
|
||||
close() {
|
||||
if (!sessionId) return;
|
||||
void invoke("ws_close", { sessionId }).finally(() => {
|
||||
sessionId = null;
|
||||
emitStatus("closed");
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 智能 WebSocket:无自定义 Header → 浏览器直连;有 Header → 借道 Gin WS 隧道。
|
||||
*/
|
||||
import type { WsSession, WsSpec } from "@opsxapi/core";
|
||||
|
||||
export type WsTunnelInfo = {
|
||||
mode: "direct" | "tunnel";
|
||||
/** tunnel 模式的会话 id */
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
export type WsSmartOptions = {
|
||||
/** Gin 基地址(默认同源 /v1 反代) */
|
||||
ginBase?: string;
|
||||
/** 有自定义 header 时强制走隧道(默认自动) */
|
||||
forceTunnel?: boolean;
|
||||
onInfo?(info: WsTunnelInfo): void;
|
||||
};
|
||||
|
||||
type Spec = WsSpec & { headers?: Record<string, string> };
|
||||
|
||||
export function wsSmart(spec: Spec, opts: WsSmartOptions = {}): WsSession {
|
||||
const headers = spec.headers ?? {};
|
||||
const hasCustomHeaders = Object.keys(headers).length > 0;
|
||||
if (hasCustomHeaders || opts.forceTunnel) {
|
||||
return tunnelSession(spec, headers, opts);
|
||||
}
|
||||
return directSession(spec);
|
||||
}
|
||||
|
||||
function directSession(spec: Spec): WsSession {
|
||||
const protocols = spec.protocols?.filter(Boolean);
|
||||
const socket = new WebSocket(spec.url, protocols?.length ? protocols : undefined);
|
||||
return wrapSocket(socket);
|
||||
}
|
||||
|
||||
function wrapSocket(socket: WebSocket): WsSession {
|
||||
const messageCbs: Array<(data: string) => void> = [];
|
||||
const statusCbs: Array<(s: string) => void> = [];
|
||||
const emitStatus = (s: string) => statusCbs.forEach((cb) => cb(s));
|
||||
|
||||
socket.onopen = () => emitStatus("open");
|
||||
socket.onclose = () => emitStatus("closed");
|
||||
socket.onerror = () => emitStatus("error");
|
||||
socket.onmessage = (ev) => {
|
||||
let data: string;
|
||||
if (typeof ev.data === "string") {
|
||||
data = ev.data;
|
||||
} else if (ev.data instanceof ArrayBuffer) {
|
||||
data = new TextDecoder().decode(ev.data);
|
||||
} else if (ev.data instanceof Blob) {
|
||||
// Blob 异步读,转同步回调
|
||||
void ev.data.text().then((t) => messageCbs.forEach((cb) => cb(t)));
|
||||
return;
|
||||
} else {
|
||||
data = String(ev.data);
|
||||
}
|
||||
messageCbs.forEach((cb) => cb(data));
|
||||
};
|
||||
|
||||
return {
|
||||
onMessage(cb) {
|
||||
messageCbs.push(cb);
|
||||
},
|
||||
onStatus(cb) {
|
||||
statusCbs.push(cb);
|
||||
},
|
||||
send(data) {
|
||||
socket.send(data);
|
||||
},
|
||||
close() {
|
||||
socket.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function tunnelSession(spec: Spec, headers: Record<string, string>, opts: WsSmartOptions): WsSession {
|
||||
const ginBase = opts.ginBase ?? "";
|
||||
const messageCbs: Array<(data: string) => void> = [];
|
||||
const statusCbs: Array<(s: string) => void> = [];
|
||||
const emitStatus = (s: string) => statusCbs.forEach((cb) => cb(s));
|
||||
|
||||
let socket: WebSocket | null = null;
|
||||
let sessionId: string | null = null;
|
||||
let disposed = false;
|
||||
|
||||
const wsBase = ginBase
|
||||
.replace(/^http:\/\//i, "ws://")
|
||||
.replace(/^https:\/\//i, "wss://");
|
||||
|
||||
emitStatus("connecting");
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(`${ginBase}/v1/proxy/ws/connect`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: spec.url,
|
||||
headers,
|
||||
protocols: spec.protocols ?? [],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`WS 隧道连接失败 ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
const data = (await res.json()) as { sessionId: string };
|
||||
sessionId = data.sessionId;
|
||||
if (disposed) {
|
||||
void closeTunnel(ginBase, sessionId);
|
||||
return;
|
||||
}
|
||||
opts.onInfo?.({ mode: "tunnel", sessionId });
|
||||
|
||||
socket = new WebSocket(`${wsBase}/v1/proxy/ws/attach?sessionId=${encodeURIComponent(sessionId)}`);
|
||||
socket.onmessage = (ev) => {
|
||||
if (typeof ev.data !== "string") return;
|
||||
// 隧道控制帧(json)与透传数据区分
|
||||
if (ev.data.startsWith('{"type":"open"}')) {
|
||||
emitStatus("open");
|
||||
return;
|
||||
}
|
||||
if (ev.data.startsWith('{"type":"closed"')) {
|
||||
emitStatus("closed");
|
||||
return;
|
||||
}
|
||||
messageCbs.forEach((cb) => cb(ev.data as string));
|
||||
};
|
||||
socket.onclose = () => emitStatus("closed");
|
||||
socket.onerror = () => emitStatus("error");
|
||||
} catch (e) {
|
||||
messageCbs.forEach((cb) => cb(`[ws tunnel] ${e instanceof Error ? e.message : String(e)}`));
|
||||
emitStatus("error");
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
onMessage(cb) {
|
||||
messageCbs.push(cb);
|
||||
},
|
||||
onStatus(cb) {
|
||||
statusCbs.push(cb);
|
||||
},
|
||||
send(data) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(data);
|
||||
} else {
|
||||
messageCbs.forEach((cb) => cb("[ws tunnel] 未连接,无法发送"));
|
||||
}
|
||||
},
|
||||
close() {
|
||||
disposed = true;
|
||||
socket?.close();
|
||||
if (sessionId) void closeTunnel(ginBase, sessionId);
|
||||
emitStatus("closed");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function closeTunnel(ginBase: string, sessionId: string): Promise<void> {
|
||||
try {
|
||||
await fetch(`${ginBase}/v1/proxy/ws/close`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
} catch {
|
||||
/* 后台会话回收兜底 */
|
||||
}
|
||||
}
|
||||
|
||||
/** Gin 是否可达(用于 UI 提示) */
|
||||
let ginProbeCache: { base: string; ok: boolean; at: number } | null = null;
|
||||
|
||||
export async function probeGin(base = "", maxAgeMs = 15_000): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
if (ginProbeCache && now - ginProbeCache.at < maxAgeMs && ginProbeCache.base === base) {
|
||||
return ginProbeCache.ok;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(1500) });
|
||||
const ok = res.ok;
|
||||
ginProbeCache = { base, ok, at: now };
|
||||
return ok;
|
||||
} catch {
|
||||
ginProbeCache = { base, ok: false, at: now };
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@opsxapi/ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opsxapi/core": "*",
|
||||
"@opsxapi/transport": "*",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* OpsxAPI 工作台入口:组装顶栏 / 侧栏 / 标签页 / 编辑器 / 对话框。
|
||||
*/
|
||||
import { findNode } from "@opsxapi/core";
|
||||
import { useEffect } from "react";
|
||||
import { closeSession } from "./send";
|
||||
import { Icon } from "./components/icons";
|
||||
import { Logo } from "./components/Logo";
|
||||
import { useWorkspace } from "./workspaceStore";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
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 { ExportDialog } from "./components/dialogs/ExportDialog";
|
||||
import { ImportDialog } from "./components/dialogs/ImportDialog";
|
||||
import {
|
||||
EnvironmentsDialog,
|
||||
NewItemDialog,
|
||||
RenameDialog,
|
||||
SettingsDialog,
|
||||
} from "./components/dialogs/WorkspaceDialogs";
|
||||
|
||||
export function OpsxWorkspace() {
|
||||
const init = useWorkspace((s) => s.init);
|
||||
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 dialog = useWorkspace((s) => s.dialog);
|
||||
const openTab = useWorkspace((s) => s.openTab);
|
||||
|
||||
useEffect(() => {
|
||||
init();
|
||||
}, [init]);
|
||||
|
||||
// 关闭标签页时断开实时会话
|
||||
useEffect(() => {
|
||||
useWorkspace.subscribe((state, prev) => {
|
||||
const closed = prev.openTabIds.filter((id) => !state.openTabIds.includes(id));
|
||||
for (const id of closed) closeSession(id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const activeRef = activeTabId ? findNode(collections, activeTabId) : null;
|
||||
const activeNode = activeRef?.node;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen min-h-[640px] flex-col overflow-hidden">
|
||||
<TopBar />
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<Sidebar />
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<TabBar />
|
||||
{inited && activeNode === undefined && (
|
||||
<EmptyState onQuick={(kind) => collections[0] && openTab(collections[0].id)} />
|
||||
)}
|
||||
{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 onQuick={() => undefined} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{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 />}
|
||||
{dialog?.type === "rename" && <RenameDialog id={dialog.id} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ onQuick }: { onQuick: (kind: string) => void }) {
|
||||
const setDialog = useWorkspace((s) => s.setDialog);
|
||||
void onQuick;
|
||||
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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* 左侧集合树:搜索 / 目录 / 请求 / 右键菜单 / 拖拽移动 / 历史记录。
|
||||
*/
|
||||
import type { Collection, CollectionNode, RequestKind } from "@opsxapi/core";
|
||||
import { KIND_LABELS, findNode, searchTree } from "@opsxapi/core";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useWorkspace } from "../workspaceStore";
|
||||
import { ContextMenu, cx, formatTime, type MenuItem } from "./common";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
import { Logo } from "./Logo";
|
||||
|
||||
const KIND_ICONS: Record<RequestKind, IconName> = {
|
||||
http: "globe",
|
||||
graphql: "hexagon",
|
||||
websocket: "zap",
|
||||
sse: "radio",
|
||||
socketio: "plug-zap",
|
||||
mqtt: "mailbox",
|
||||
};
|
||||
|
||||
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 [showHistory, setShowHistory] = useState(true);
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null);
|
||||
|
||||
const hitIds = useMemo(() => searchTree(collections, search), [collections, search]);
|
||||
|
||||
return (
|
||||
<aside className="ops-panel flex w-64 shrink-0 flex-col" style={{ borderRight: "1px solid var(--border)" }}>
|
||||
<div className="p-2.5" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<input
|
||||
className="ops-input ops-input-sm"
|
||||
placeholder="搜索接口 / 目录…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ops-scroll min-h-0 flex-1 p-2">
|
||||
{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>
|
||||
|
||||
{/* 历史 */}
|
||||
<div style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<button
|
||||
className="ops-faint flex w-full items-center justify-between px-3 py-2 text-[11px] font-semibold uppercase tracking-wider"
|
||||
onClick={() => setShowHistory((v) => !v)}
|
||||
>
|
||||
<span>历史记录 · {history.length}</span>
|
||||
<span>{showHistory ? "▾" : "▸"}</span>
|
||||
</button>
|
||||
{showHistory && (
|
||||
<div className="ops-scroll max-h-44 px-2 pb-2">
|
||||
{history.length === 0 && <div className="ops-faint px-2 py-3 text-center text-[11px]">暂无记录</div>}
|
||||
{history.slice(0, 30).map((h) => (
|
||||
<button
|
||||
key={h.id}
|
||||
className="ops-tree-row w-full !py-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-[10px]">{formatTime(h.at)}</span>
|
||||
{h.method && <span className={`ops-method-${h.method} w-12 shrink-0 text-[10px] font-bold`}>{h.method}</span>}
|
||||
<span className="truncate text-[11px]">{h.label}</span>
|
||||
{h.status !== undefined && (
|
||||
<span className={cx("ml-auto text-[10px]", h.ok ? "ops-success" : "ops-danger")}>{h.status}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{history.length > 0 && (
|
||||
<button className="ops-btn ops-btn-ghost w-full py-1 text-[11px]" onClick={clearHistory}>
|
||||
清空历史
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ContextMenu menu={menu} onClose={() => setMenu(null)} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
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 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="truncate">{node.name}</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 ? (
|
||||
<Icon name={KIND_ICONS[requestNode.kind]} size={13} className="shrink-0 ops-dim" title={KIND_LABELS[requestNode.kind]} />
|
||||
) : null}
|
||||
<span className={cx("min-w-0 flex-1 truncate", isFolder && "font-medium")}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 请求标签页栏。
|
||||
*/
|
||||
import { KIND_LABELS, findNode } from "@opsxapi/core";
|
||||
import { useState } from "react";
|
||||
import { useWorkspace } from "../workspaceStore";
|
||||
import { ContextMenu, cx, type MenuItem } from "./common";
|
||||
|
||||
const KIND_TAB_ICON: Record<string, string> = {
|
||||
http: "",
|
||||
graphql: "◈",
|
||||
websocket: "⚡",
|
||||
sse: "📡",
|
||||
socketio: "🔌",
|
||||
mqtt: "📮",
|
||||
};
|
||||
|
||||
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 [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]}>{KIND_TAB_ICON[node.kind]}</span>
|
||||
) : null}
|
||||
<span className="max-w-48 truncate">{node.name}</span>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 顶栏:logo / 新建 / 导入导出 / 环境 / 主题 / 设置。
|
||||
*/
|
||||
import { detectTransport } from "@opsxapi/transport";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useWorkspace } from "../workspaceStore";
|
||||
import { ContextMenu, cx, type MenuItem } from "./common";
|
||||
import { EnvBadge } from "./EnvBadge";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
import { Logo } from "./Logo";
|
||||
|
||||
const NEW_ITEMS: { kind: string; label: string; icon: IconName }[] = [
|
||||
{ kind: "http", label: "HTTP 请求", icon: "globe" },
|
||||
{ kind: "graphql", label: "GraphQL", icon: "hexagon" },
|
||||
{ kind: "websocket", label: "WebSocket", icon: "zap" },
|
||||
{ kind: "sse", label: "SSE", icon: "radio" },
|
||||
{ kind: "socketio", label: "Socket.IO", icon: "plug-zap" },
|
||||
{ kind: "mqtt", label: "MQTT", icon: "mailbox" },
|
||||
];
|
||||
|
||||
export function TopBar() {
|
||||
const collections = useWorkspace((s) => s.collections);
|
||||
const environments = useWorkspace((s) => s.environments);
|
||||
const activeEnvironmentId = useWorkspace((s) => s.activeEnvironmentId);
|
||||
const settings = useWorkspace((s) => s.settings);
|
||||
const setActiveEnvironment = useWorkspace((s) => s.setActiveEnvironment);
|
||||
const setTheme = useWorkspace((s) => s.setTheme);
|
||||
const setDialog = useWorkspace((s) => s.setDialog);
|
||||
const addRequest = useWorkspace((s) => s.addRequest);
|
||||
const addFolder = useWorkspace((s) => s.addFolder);
|
||||
|
||||
const [newMenu, setNewMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [exportMenu, setExportMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const transportKind = useMemo(() => detectTransport({ ginBaseUrl: "" }).kind, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "OpsxAPI · 接口工作台";
|
||||
}, []);
|
||||
|
||||
const firstCollection = collections[0];
|
||||
|
||||
const quickCreate = (kind: string) => {
|
||||
if (!firstCollection) return;
|
||||
if (kind === "folder") addFolder(firstCollection.id, null);
|
||||
else addRequest(firstCollection.id, null, kind as never);
|
||||
};
|
||||
|
||||
const newItems: MenuItem[] = [
|
||||
...NEW_ITEMS.map((i) => ({
|
||||
label: i.label,
|
||||
icon: <Icon name={i.icon} size={14} />,
|
||||
onClick: () => quickCreate(i.kind),
|
||||
})),
|
||||
{ type: "sep" as const },
|
||||
{ label: "新建目录", icon: <Icon name="folder-plus" size={14} />, onClick: () => quickCreate("folder") },
|
||||
{ type: "sep" as const },
|
||||
{ label: "选择位置新建…", icon: <Icon name="library" size={14} />, onClick: () => setDialog({ type: "new", collectionId: firstCollection?.id ?? "", parentId: null }) },
|
||||
];
|
||||
|
||||
const activeEnv = environments.find((e) => e.id === activeEnvironmentId);
|
||||
|
||||
const [envMenu, setEnvMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const envItems: MenuItem[] = [
|
||||
...environments.map((env) => ({
|
||||
label: (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<EnvBadge envId={env.id} name={env.name} size={20} />
|
||||
<span className="truncate">{env.name}</span>
|
||||
{env.id === activeEnvironmentId ? (
|
||||
<span className="ml-auto pl-3 text-[10px] ops-success">● 当前</span>
|
||||
) : (
|
||||
<span className="ops-faint ml-auto pl-3 text-[10px]">
|
||||
{env.variables.filter((v) => v.enabled && v.key).length} 变量
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
onClick: () => setActiveEnvironment(env.id),
|
||||
})),
|
||||
{ type: "sep" as const },
|
||||
{
|
||||
label: "不使用环境",
|
||||
icon: <Icon name="circle-slash" size={14} />,
|
||||
onClick: () => setActiveEnvironment(null),
|
||||
},
|
||||
{
|
||||
label: "管理环境…",
|
||||
icon: <Icon name="settings" size={14} />,
|
||||
onClick: () => setDialog({ type: "environments" }),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<header
|
||||
className="ops-panel ops-divider flex h-12 shrink-0 items-center justify-between gap-3 px-4"
|
||||
style={{ zIndex: 30 }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Logo size={26} />
|
||||
<span className="text-[14px] font-bold tracking-tight">OpsxAPI</span>
|
||||
</div>
|
||||
<span className="ops-chip" style={{ padding: "1px 8px", fontSize: 10 }}>
|
||||
HTTP · GraphQL · WS · SSE · Socket.IO · MQTT
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="ops-btn ops-btn-primary"
|
||||
onClick={(e) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setNewMenu({ x: rect.left, y: rect.bottom + 4 });
|
||||
}}
|
||||
disabled={!firstCollection}
|
||||
>
|
||||
<Icon name="plus" size={14} /> 新建
|
||||
</button>
|
||||
<button className="ops-btn ops-btn-secondary" onClick={() => setDialog({ type: "import" })}>
|
||||
<Icon name="download" size={14} /> 导入
|
||||
</button>
|
||||
<button
|
||||
className="ops-btn ops-btn-secondary"
|
||||
onClick={(e) => {
|
||||
if (collections.length === 1) {
|
||||
setDialog({ type: "export", collectionId: collections[0].id });
|
||||
return;
|
||||
}
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setExportMenu({ x: rect.left, y: rect.bottom + 4 });
|
||||
}}
|
||||
disabled={collections.length === 0}
|
||||
>
|
||||
<Icon name="upload" size={14} /> 导出
|
||||
</button>
|
||||
|
||||
<div className="mx-1 h-5 w-px" style={{ background: "var(--border)" }} />
|
||||
|
||||
<button
|
||||
className="ops-envswitch"
|
||||
onClick={(e) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setEnvMenu((m) => (m ? null : { x: rect.left, y: rect.bottom + 4 }));
|
||||
}}
|
||||
title="切换环境"
|
||||
>
|
||||
{activeEnv ? (
|
||||
<>
|
||||
<EnvBadge envId={activeEnv.id} name={activeEnv.name} size={18} />
|
||||
<span className="max-w-28 truncate">{activeEnv.name}</span>
|
||||
<span className="ops-faint text-[10px]">
|
||||
{activeEnv.variables.filter((v) => v.enabled && v.key).length} 变量
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="ops-faint">不使用环境</span>
|
||||
)}
|
||||
<Icon name="chevron-down" size={13} className="ops-faint" />
|
||||
</button>
|
||||
<button className="ops-btn ops-btn-ghost" onClick={() => setDialog({ type: "environments" })} title="环境管理">
|
||||
<Icon name="settings" size={14} /> 环境
|
||||
</button>
|
||||
|
||||
<div className="mx-1 h-5 w-px" style={{ background: "var(--border)" }} />
|
||||
|
||||
<button
|
||||
className="ops-btn ops-btn-ghost"
|
||||
onClick={() => setTheme(settings.theme === "dark" ? "light" : "dark")}
|
||||
title="切换主题"
|
||||
>
|
||||
<Icon name={settings.theme === "dark" ? "moon" : "sun"} size={15} />
|
||||
</button>
|
||||
<a
|
||||
className="ops-btn ops-btn-ghost"
|
||||
href="/docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="用户文档"
|
||||
>
|
||||
<Icon name="book-open" size={14} /> 文档
|
||||
</a>
|
||||
<button className="ops-btn ops-btn-ghost" onClick={() => setDialog({ type: "settings" })} title="设置">
|
||||
<Icon name="ellipsis" size={16} />
|
||||
</button>
|
||||
<span className={cx("ops-chip")} style={{ padding: "1px 8px", fontSize: 10 }} title="请求通道">
|
||||
{transportKind}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ContextMenu
|
||||
menu={newMenu ? { x: newMenu.x, y: newMenu.y, items: newItems } : null}
|
||||
onClose={() => setNewMenu(null)}
|
||||
/>
|
||||
<ContextMenu
|
||||
menu={envMenu ? { x: envMenu.x, y: envMenu.y, items: envItems } : null}
|
||||
onClose={() => setEnvMenu(null)}
|
||||
/>
|
||||
<ContextMenu
|
||||
menu={
|
||||
exportMenu
|
||||
? {
|
||||
x: exportMenu.x,
|
||||
y: exportMenu.y,
|
||||
items: collections.map((c) => ({
|
||||
label: c.name,
|
||||
onClick: () => setDialog({ type: "export", collectionId: c.id }),
|
||||
})),
|
||||
}
|
||||
: null
|
||||
}
|
||||
onClose={() => setExportMenu(null)}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* 基础组件:Modal / 右键菜单 / JSON 高亮 / KV 编辑器 / 复制按钮。
|
||||
*/
|
||||
import type { KVRow } from "@opsxapi/core";
|
||||
import { kv } from "@opsxapi/core";
|
||||
import { Icon } from "./icons";
|
||||
import { VarHintInput } from "./VarHintInput";
|
||||
import { useEffect, 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;
|
||||
const close = () => onClose();
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("click", close);
|
||||
window.addEventListener("resize", close);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.removeEventListener("click", close);
|
||||
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 }) {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch {
|
||||
return <span className={className}>{text}</span>;
|
||||
}
|
||||
return <pre className={cx("ops-code ops-code-no-bg m-0", className)}>{renderJson(value, 0)}</pre>;
|
||||
}
|
||||
|
||||
function renderJson(value: unknown, indent: number, key?: string): ReactNode {
|
||||
const pad = " ".repeat(indent);
|
||||
const padEnd = " ".repeat(Math.max(0, indent - 1));
|
||||
const keyPart = key !== undefined ? <span className="ops-json-key">{JSON.stringify(key)}: </span> : null;
|
||||
|
||||
if (value === null) {
|
||||
return (
|
||||
<div key={key}>
|
||||
{pad}
|
||||
{keyPart}
|
||||
<span className="ops-json-null">null</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return (
|
||||
<div key={key}>
|
||||
{pad}
|
||||
{keyPart}
|
||||
<span className="ops-json-str">{JSON.stringify(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return (
|
||||
<div key={key}>
|
||||
{pad}
|
||||
{keyPart}
|
||||
<span className="ops-json-num">{String(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return (
|
||||
<div key={key}>
|
||||
{pad}
|
||||
{keyPart}
|
||||
<span className="ops-json-bool">{String(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return (
|
||||
<div key={key}>
|
||||
{pad}
|
||||
{keyPart}[]
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={key}>
|
||||
{pad}
|
||||
{keyPart}<span>{"["}</span>
|
||||
{value.map((v, i) => (
|
||||
<div key={i}>{renderJson(v, indent + 1)}</div>
|
||||
))}
|
||||
{padEnd}<span>]</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// object
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div key={key}>
|
||||
{pad}
|
||||
{keyPart}{"{}"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={key}>
|
||||
{pad}
|
||||
{keyPart}<span>{"{"}</span>
|
||||
{entries.map(([k, v]) => (
|
||||
<div key={k}>{renderJson(v, indent + 1, k)}</div>
|
||||
))}
|
||||
{padEnd}<span>{"}"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 复制按钮 ---------------- */
|
||||
|
||||
export function CopyButton({ text, label = "复制" }: { text: string; label?: string }) {
|
||||
const [done, setDone] = useState(false);
|
||||
return (
|
||||
<button
|
||||
className="ops-btn ops-btn-secondary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setDone(true);
|
||||
setTimeout(() => setDone(false), 1200);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}}
|
||||
>
|
||||
{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,
|
||||
}: {
|
||||
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;
|
||||
}) {
|
||||
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>
|
||||
))}
|
||||
<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")}`;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 导出对话框:OpenAPI JSON/YAML、Postman、Markdown、全量备份。
|
||||
*/
|
||||
import { countRequests, exportBackup, exportCollectionAs, type ExportFormat } from "@opsxapi/core";
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* 导入对话框:粘贴 / 文件 / URL(Swagger URL)三种来源 + 格式自动识别。
|
||||
*/
|
||||
import type { Collection } from "@opsxapi/core";
|
||||
import {
|
||||
FORMAT_LABELS,
|
||||
detectImportFormat,
|
||||
importByFormat,
|
||||
countRequests,
|
||||
type ImportFormat,
|
||||
} from "@opsxapi/core";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useWorkspace } from "../../workspaceStore";
|
||||
import { Modal, cx } from "../common";
|
||||
import { Icon } from "../icons";
|
||||
|
||||
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 [source, setSource] = useState<Source>("paste");
|
||||
const [text, setText] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [format, setFormat] = useState<ImportFormat | "auto">("auto");
|
||||
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 = (): ImportFormat => {
|
||||
if (format !== "auto") return format;
|
||||
if (!text.trim()) throw new Error("请先粘贴内容 / 选择文件 / 填写 URL");
|
||||
const d = detectImportFormat(text);
|
||||
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();
|
||||
const result = importByFormat(content, fmt);
|
||||
const requestCount = result.collections.reduce((acc, c) => acc + countRequests(c), 0);
|
||||
importCollections(result.collections, result.environmentVariables);
|
||||
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");
|
||||
// 经 Gin 代理拉取,避开 CORS
|
||||
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}`);
|
||||
}
|
||||
const 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)}>
|
||||
完成
|
||||
</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>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* 工作区对话框:新建项 / 重命名 / 环境管理 / 设置。
|
||||
*/
|
||||
import type { RequestKind } from "@opsxapi/core";
|
||||
import { KIND_LABELS, walk } from "@opsxapi/core";
|
||||
import { useState } from "react";
|
||||
import { useWorkspace } from "../../workspaceStore";
|
||||
import { KVEditor, Modal, cx } from "../common";
|
||||
import { EnvBadge } from "../EnvBadge";
|
||||
import { Icon, type IconName } from "../icons";
|
||||
|
||||
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="text-xl ops-dim"><Icon name={k.icon} size={22} /></div>
|
||||
<div className="mt-1 text-xs font-semibold">
|
||||
{k.kind === "folder" ? "目录" : KIND_LABELS[k.kind]}
|
||||
</div>
|
||||
<div className="ops-faint mt-0.5 text-[10px]">{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="780px"
|
||||
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="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="ops-hint">
|
||||
<Icon name="info" size={14} />
|
||||
<span>
|
||||
在 URL、参数、Header、Body、鉴权中输入 <b>{"{{变量名}}"}</b> 引用下方变量;
|
||||
顶部切换环境即全局替换。
|
||||
<a href="/docs/env" target="_blank" rel="noopener noreferrer" className="ops-link ml-1">
|
||||
查看文档 →
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<EnvBadge envId={env.id} name={env.name} size={28} />
|
||||
<input
|
||||
className="ops-input flex-1"
|
||||
value={env.name}
|
||||
onChange={(e) => updateEnvironment(env.id, { name: e.target.value })}
|
||||
placeholder="环境名称"
|
||||
/>
|
||||
{isActive ? (
|
||||
<button className="ops-btn ops-btn-secondary" onClick={() => setActiveEnvironment(null)}>
|
||||
取消使用
|
||||
</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"
|
||||
title="复制环境"
|
||||
onClick={() => setSelected(duplicateEnvironment(env.id))}
|
||||
>
|
||||
<Icon name="copy" size={13} /> 复制
|
||||
</button>
|
||||
<button
|
||||
className="ops-btn ops-btn-ghost ops-danger"
|
||||
title="删除环境"
|
||||
onClick={() => {
|
||||
removeEnvironment(env.id);
|
||||
setSelected(useWorkspace.getState().environments[0]?.id ?? "");
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" size={13} /> 删除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<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-44"
|
||||
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">
|
||||
暂无变量,点击右上「添加变量」;推荐先定义 baseUrl 与 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
|
||||
/>
|
||||
)}
|
||||
{search && filteredVars.length === 0 && env.variables.length > 0 && (
|
||||
<div className="ops-faint py-6 text-center text-xs">没有匹配的变量</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="ops-faint grid h-full place-items-center text-sm">
|
||||
{environments.length === 0 ? "左侧点击「新建环境」开始" : "选择左侧环境"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsDialog() {
|
||||
const settings = useWorkspace((s) => s.settings);
|
||||
const setTheme = useWorkspace((s) => s.setTheme);
|
||||
const setTimeoutMs = useWorkspace((s) => s.setTimeoutMs);
|
||||
const resetWorkspace = useWorkspace((s) => s.resetWorkspace);
|
||||
const setDialog = useWorkspace((s) => s.setDialog);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="设置"
|
||||
onClose={() => setDialog(null)}
|
||||
footer={
|
||||
<button className="ops-btn ops-btn-primary" onClick={() => setDialog(null)}>
|
||||
完成
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="max-w-md space-y-5">
|
||||
<div>
|
||||
<label className="ops-label">主题</label>
|
||||
<div className="flex gap-1.5">
|
||||
{(["dark", "light"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={cx("ops-chip", settings.theme === t && "ops-chip-active")}
|
||||
onClick={() => setTheme(t)}
|
||||
>
|
||||
{t === "dark" ? "🌙 深色" : "☀️ 浅色"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ops-label">请求超时(毫秒)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="ops-input w-40"
|
||||
value={settings.timeoutMs}
|
||||
min={1000}
|
||||
step={1000}
|
||||
onChange={(e) => setTimeoutMs(Number(e.target.value) || 30000)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ops-label">数据</label>
|
||||
<div className="ops-faint mb-2 text-[11px] leading-5">
|
||||
所有集合与环境保存在浏览器 localStorage;全量备份请在「导出 → 全量备份」中生成。
|
||||
</div>
|
||||
<button
|
||||
className="ops-btn ops-btn-danger"
|
||||
onClick={() => {
|
||||
if (confirm("确定重置工作区?将恢复到初始示例数据(当前数据不可恢复,建议先导出备份)")) {
|
||||
resetWorkspace();
|
||||
setDialog(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" size={13} /> 重置工作区
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* GraphQL 编辑器:Query / Variables / Headers / Auth + 响应。
|
||||
*/
|
||||
import type { GraphqlApiNode, GraphqlRequestData, KVRow } from "@opsxapi/core";
|
||||
import { useState } from "react";
|
||||
import { sendGraphqlRequest } from "../../send";
|
||||
import { useWorkspace } from "../../workspaceStore";
|
||||
import { KVEditor, cx } from "../common";
|
||||
import { Icon } from "../icons";
|
||||
import { VarHintInput } from "../VarHintInput";
|
||||
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>
|
||||
<VarHintInput
|
||||
className="ops-input flex-1 font-mono text-[13px]"
|
||||
placeholder="https://api.example.com/graphql 或 {{baseUrl}}/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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* HTTP 请求编辑器:方法/URL/Send + Params/Body/Headers/Cookies/Auth/说明 + 代码生成。
|
||||
*/
|
||||
import type { HttpApiNode, HttpRequestData, KVRow } from "@opsxapi/core";
|
||||
import {
|
||||
BODY_MODE_LABELS,
|
||||
RAW_BODY_MODES,
|
||||
REQUEST_METHODS,
|
||||
exportCurl,
|
||||
generateCode,
|
||||
type CodeTarget,
|
||||
} from "@opsxapi/core";
|
||||
import { replaceVariables } from "@opsxapi/core";
|
||||
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 { VarHintInput } from "../VarHintInput";
|
||||
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 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 resolvedUrl = 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) }),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* URL 栏 */}
|
||||
<div className="flex shrink-0 flex-wrap 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>
|
||||
<VarHintInput
|
||||
className="ops-input flex-1 font-mono text-[13px]"
|
||||
placeholder="https://api.example.com/v1/resource 或 {{baseUrl}}/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>
|
||||
<button
|
||||
className="ops-btn ops-btn-secondary"
|
||||
onClick={(e) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setCodeMenu({ x: rect.left, y: rect.bottom + 4 });
|
||||
}}
|
||||
title="生成请求代码"
|
||||
>
|
||||
<Icon name="code" size={13} /> 代码
|
||||
</button>
|
||||
</div>
|
||||
{data.url.includes("{{") && (
|
||||
<div className="ops-faint px-4 py-1 text-[11px]" style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
实际地址:<span className="ops-accent font-mono">{resolvedUrl}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 请求 tabs */}
|
||||
<div className="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 shrink-0 p-3.5" style={{ maxHeight: "42%", minHeight: 120 }}>
|
||||
{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>
|
||||
|
||||
<ResponsePanel nodeId={node.id} />
|
||||
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- Body ---------------- */
|
||||
|
||||
function BodyEditor({
|
||||
data,
|
||||
patch,
|
||||
}: {
|
||||
data: HttpRequestData;
|
||||
patch: (p: Partial<HttpRequestData>) => void;
|
||||
}) {
|
||||
const mode = data.bodyMode as BodyTab;
|
||||
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>
|
||||
)}
|
||||
</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 "key": "value"\n}'
|
||||
: mode === "xml"
|
||||
? "<root>\n <item>value</item>\n</root>"
|
||||
: "原始文本内容"
|
||||
}
|
||||
value={data.bodyRaw}
|
||||
onChange={(e) => patch({ bodyRaw: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(mode === "urlencoded" || mode === "form-data") && (
|
||||
<KVEditor
|
||||
rows={data.bodyForm}
|
||||
onChange={(rows) => patch({ bodyForm: rows })}
|
||||
keyPlaceholder="字段名"
|
||||
valuePlaceholder="值"
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === "form-data" && (
|
||||
<div className="ops-faint text-[11px]">当前支持文本字段;文件上传请用 cURL / 代码生成。</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { exportCurl };
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* MQTT 工作台:broker 配置 + 订阅 + 发布 + 消息日志。
|
||||
*/
|
||||
import type { MqttApiNode, MqttRequestData } from "@opsxapi/core";
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* HTTP / GraphQL 响应面板:Pretty / Raw / 响应头 + 状态条。
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import { useWorkspace } from "../../workspaceStore";
|
||||
import { CopyButton, JsonView, cx, formatBytes } from "../common";
|
||||
|
||||
export function ResponsePanel({ nodeId }: { nodeId: string }) {
|
||||
const runtime = useWorkspace((s) => s.runtime[nodeId]);
|
||||
const setRuntime = useWorkspace((s) => s.setRuntime);
|
||||
const tab = runtime?.responseTab ?? "body";
|
||||
const result = runtime?.result ?? null;
|
||||
const error = runtime?.error ?? null;
|
||||
const loading = runtime?.loading ?? false;
|
||||
|
||||
const contentType = useMemo(() => {
|
||||
const ct = result?.headers?.["content-type"] ?? "";
|
||||
return ct.toLowerCase();
|
||||
}, [result]);
|
||||
|
||||
const isJson = contentType.includes("json") || (!contentType && looksLikeJson(result?.body ?? ""));
|
||||
const prettyBody = useMemo(() => {
|
||||
if (!result?.body) return "";
|
||||
if (isJson) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(result.body), null, 2);
|
||||
} catch {
|
||||
return result.body;
|
||||
}
|
||||
}
|
||||
return result.body;
|
||||
}, [result, isJson]);
|
||||
|
||||
return (
|
||||
<section className="flex min-h-0 flex-1 flex-col" style={{ borderTop: "1px solid var(--border)" }}>
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between px-3"
|
||||
style={{ borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
<div className="flex">
|
||||
{(
|
||||
[
|
||||
["body", "响应体"],
|
||||
["raw", "Raw"],
|
||||
["headers", "响应头"],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
className={cx("ops-tab", tab === key && "ops-tab-active")}
|
||||
onClick={() => setRuntime(nodeId, { responseTab: key })}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pr-1 text-xs">
|
||||
{result ? (
|
||||
<>
|
||||
<span>
|
||||
状态{" "}
|
||||
<b className={result.status < 400 ? "ops-success" : "ops-danger"}>{result.status}</b>
|
||||
</span>
|
||||
<span className="ops-dim">
|
||||
耗时 <b>{result.timeMs} ms</b>
|
||||
</span>
|
||||
<span className="ops-dim">
|
||||
大小 <b>{formatBytes(result.size)}</b>
|
||||
</span>
|
||||
{contentType && (
|
||||
<span className="ops-faint hidden truncate lg:inline" style={{ maxWidth: 200 }}>
|
||||
{result.headers["content-type"]}
|
||||
</span>
|
||||
)}
|
||||
<CopyButton text={result.body} label="复制体" />
|
||||
</>
|
||||
) : (
|
||||
<span className="ops-faint">Status · Time · Size</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ops-scroll min-h-0 flex-1 p-3">
|
||||
{loading && <div className="ops-dim animate-pulse py-6 text-center text-sm">请求发送中…</div>}
|
||||
{!loading && error && (
|
||||
<div
|
||||
className="ops-msg ops-msg-err mb-3 !font-sans text-[13px]"
|
||||
style={{ whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && !result && (
|
||||
<div className="ops-faint grid h-full place-items-center text-sm">
|
||||
发送请求后这里显示响应
|
||||
</div>
|
||||
)}
|
||||
{!loading && result && tab === "body" && (
|
||||
<div className="ops-code h-full overflow-auto">
|
||||
{isJson ? <JsonView text={prettyBody} /> : <pre className="m-0 whitespace-pre-wrap">{result.body}</pre>}
|
||||
</div>
|
||||
)}
|
||||
{!loading && result && tab === "raw" && (
|
||||
<pre className="ops-code m-0 h-full overflow-auto whitespace-pre-wrap break-all">{result.body}</pre>
|
||||
)}
|
||||
{!loading && result && tab === "headers" && (
|
||||
<table className="ops-code w-full border-collapse text-left">
|
||||
<tbody>
|
||||
{Object.entries(result.headers).map(([k, v]) => (
|
||||
<tr key={k} className="align-top">
|
||||
<td className="ops-accent pr-4 whitespace-nowrap">{k}</td>
|
||||
<td className="break-all">{v}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeJson(s: string): boolean {
|
||||
const t = s.trimStart();
|
||||
return t.startsWith("{") || t.startsWith("[");
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Socket.IO 工作台:连接配置 + 事件监听 + emit 发送。
|
||||
*/
|
||||
import type { SocketIoApiNode, SocketIoRequestData } from "@opsxapi/core";
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* SSE 工作台:连接配置 + 事件流日志。
|
||||
*/
|
||||
import type { KVRow, SseApiNode, SseRequestData } from "@opsxapi/core";
|
||||
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 { VarHintInput } from "../VarHintInput";
|
||||
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>
|
||||
<VarHintInput
|
||||
className="ops-input flex-1 font-mono text-[13px]"
|
||||
placeholder="https://stream.example.com/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 };
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* WebSocket 工作台。
|
||||
*/
|
||||
import type { KVRow, WebsocketApiNode, WebsocketRequestData } from "@opsxapi/core";
|
||||
import { useState } from "react";
|
||||
import { connectWs, wsSend } from "../../send";
|
||||
import { useWorkspace } from "../../workspaceStore";
|
||||
import { KVEditor, cx } from "../common";
|
||||
import { Icon } from "../icons";
|
||||
import { VarHintInput } from "../VarHintInput";
|
||||
import { Composer, LiveLog, LiveStatus } from "./liveCommon";
|
||||
|
||||
const TABS = ["消息", "握手配置", "说明"] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
|
||||
export function WebsocketEditor({ node }: { node: WebsocketApiNode }) {
|
||||
const data = node.data;
|
||||
const update = useWorkspace((s) => s.updateRequestData);
|
||||
const runtime = useWorkspace((s) => s.runtime[node.id]);
|
||||
const updateDesc = useWorkspace((s) => s.updateNodeDescription);
|
||||
const [tab, setTab] = useState<Tab>("消息");
|
||||
const [input, setInput] = useState('{\n "op": "ping"\n}');
|
||||
|
||||
const patch = (p: Partial<WebsocketRequestData>) => 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="zap" size={13} /> WS</span>
|
||||
<VarHintInput
|
||||
className="ops-input flex-1 font-mono text-[13px]"
|
||||
placeholder="wss://echo.websocket.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={() => connectWs(node)}
|
||||
>
|
||||
<Icon name={connected ? "unplug" : "plug-zap"} size={13} />
|
||||
{runtime?.status === "connecting" ? "连接中…" : connected ? "断开" : "连接"}
|
||||
</button>
|
||||
<LiveStatus nodeId={node.id} />
|
||||
</div>
|
||||
|
||||
{/* tabs */}
|
||||
<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={input}
|
||||
onChange={setInput}
|
||||
disabled={!connected}
|
||||
onSend={() => wsSend(node.id, input)}
|
||||
sendLabel="发送消息"
|
||||
/>
|
||||
</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"
|
||||
placeholder="graphql-transport-ws, json"
|
||||
value={data.protocols}
|
||||
onChange={(e) => patch({ protocols: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ops-label">握手 Header(浏览器直连不支持自定义 Header,将自动走 Gin 隧道)</label>
|
||||
<KVEditor rows={data.headers} onChange={(rows: KVRow[]) => patch({ headers: rows })} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 实时协议编辑器共用件:消息日志 / 发送器 / 状态栏。
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useWorkspace } from "../../workspaceStore";
|
||||
import { StatusDot, cx, formatTime } from "../common";
|
||||
|
||||
/** 稳定空数组:避免 useSyncExternalStore 因新引用无限重渲染 */
|
||||
const EMPTY_MESSAGES: never[] = [];
|
||||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}, [messages.length]);
|
||||
|
||||
return (
|
||||
<div className="ops-card flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between px-3 py-2"
|
||||
style={{ borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
<div className="text-xs font-semibold">消息日志 · {messages.length}</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="ops-btn ops-btn-ghost py-0.5" onClick={() => clearLive(nodeId)}>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={ref} className="ops-scroll min-h-0 flex-1 space-y-1.5 p-3" style={{ background: "var(--code-bg)" }}>
|
||||
{messages.length === 0 && (
|
||||
<div className="ops-faint grid h-full place-items-center text-xs">连接后收发消息会显示在这里</div>
|
||||
)}
|
||||
{messages.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-[10px] 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Composer({
|
||||
value,
|
||||
onChange,
|
||||
onSend,
|
||||
disabled,
|
||||
placeholder = "消息内容(JSON / 文本)",
|
||||
sendLabel = "发送",
|
||||
extra,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onSend: () => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
sendLabel?: string;
|
||||
extra?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="ops-card flex shrink-0 flex-col overflow-hidden">
|
||||
{extra}
|
||||
<div className="p-2.5">
|
||||
<textarea
|
||||
className="ops-code min-h-24 w-full resize-y"
|
||||
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]">⌘/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,119 @@
|
||||
/**
|
||||
* 内置 SVG 图标(源自 lucide,ISC License:https://lucide.dev)
|
||||
* 统一 24 网格 / currentColor 描边,尺寸由 size 控制。
|
||||
*/
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
export type IconName =
|
||||
| "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"
|
||||
| "play"
|
||||
| "unplug"
|
||||
| "eraser"
|
||||
| "code"
|
||||
| "clock"
|
||||
| "settings"
|
||||
| "arrow-left-right"
|
||||
| "shield-check"
|
||||
| "search"
|
||||
| "send"
|
||||
| "circle-slash"
|
||||
| "info";
|
||||
|
||||
const ICONS: Record<IconName, string> = {
|
||||
"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\" />",
|
||||
"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] }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { OpsxWorkspace } from "./App";
|
||||
export { useWorkspace } from "./workspaceStore";
|
||||
export type { Dialog, LiveMessage, RequestRuntime } from "./workspaceStore";
|
||||
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* 发送/连接逻辑:HTTP、GraphQL、WebSocket、SSE、Socket.IO、MQTT。
|
||||
* 实时会话注册表不进 React 状态,只把消息/状态写入 store。
|
||||
*/
|
||||
import type {
|
||||
GraphqlRequestData,
|
||||
HttpRequestData,
|
||||
HttpSpec,
|
||||
RequestNode,
|
||||
} from "@opsxapi/core";
|
||||
import { replaceVariables } from "@opsxapi/core";
|
||||
import {
|
||||
connectMqtt,
|
||||
connectSocketIo,
|
||||
openSse,
|
||||
wsSmart,
|
||||
type MqttSession,
|
||||
type SocketIoSession,
|
||||
type StreamSession,
|
||||
} from "@opsxapi/transport";
|
||||
import type { WsSession } from "@opsxapi/core";
|
||||
import { activeEnvMap, useWorkspace } from "./workspaceStore";
|
||||
|
||||
type Session = { close(): void };
|
||||
|
||||
const sessions = new Map<string, Session>();
|
||||
|
||||
export function closeSession(id: string): void {
|
||||
sessions.get(id)?.close();
|
||||
sessions.delete(id);
|
||||
}
|
||||
|
||||
export function hasSession(id: string): boolean {
|
||||
return sessions.has(id);
|
||||
}
|
||||
|
||||
/** KV 行 → 对象(含变量替换) */
|
||||
function kvMap(
|
||||
rows: { key: string; value: string; enabled: boolean }[],
|
||||
env: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const r of rows) {
|
||||
if (!r.enabled || !r.key.trim()) continue;
|
||||
out[replaceVariables(r.key.trim(), env)] = replaceVariables(r.value, env);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function authHeaders(
|
||||
auth: HttpRequestData["auth"],
|
||||
env: Record<string, string>,
|
||||
): { headers: Record<string, string>; query: Record<string, string> } {
|
||||
const headers: Record<string, string> = {};
|
||||
const query: Record<string, string> = {};
|
||||
switch (auth.mode) {
|
||||
case "bearer":
|
||||
if (auth.bearer) headers.Authorization = `Bearer ${replaceVariables(auth.bearer, env)}`;
|
||||
break;
|
||||
case "basic": {
|
||||
const raw = `${replaceVariables(auth.basicUser ?? "", env)}:${replaceVariables(auth.basicPass ?? "", env)}`;
|
||||
try {
|
||||
headers.Authorization = `Basic ${btoa(raw)}`;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "apikey":
|
||||
if (auth.apiKeyName) {
|
||||
const value = replaceVariables(auth.apiKeyValue ?? "", env);
|
||||
if (auth.apiKeyIn === "query") query[auth.apiKeyName] = value;
|
||||
else headers[auth.apiKeyName] = value;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return { headers, query };
|
||||
}
|
||||
|
||||
/** 手工构建 multipart/form-data 原始体 */
|
||||
function buildMultipart(rows: HttpRequestData["bodyForm"], env: Record<string, string>): string {
|
||||
const boundary = `----OpsxAPI${Math.random().toString(36).slice(2)}`;
|
||||
const parts: string[] = [];
|
||||
for (const row of rows) {
|
||||
if (!row.enabled || !row.key.trim()) continue;
|
||||
parts.push(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="${row.key}"\r\n\r\n${replaceVariables(row.value, env)}\r\n`,
|
||||
);
|
||||
}
|
||||
parts.push(`--${boundary}--\r\n`);
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
export function buildHttpSpec(
|
||||
data: HttpRequestData,
|
||||
env: Record<string, string>,
|
||||
timeoutMs: number,
|
||||
): HttpSpec {
|
||||
const { headers: authH, query: authQ } = authHeaders(data.auth, env);
|
||||
const headers = { ...kvMap(data.headers, env), ...authH };
|
||||
|
||||
const cookie = kvMap(data.cookies, env);
|
||||
const cookieLine = Object.entries(cookie).map(([k, v]) => `${k}=${v}`).join("; ");
|
||||
if (cookieLine && !("cookie" in headers)) headers.Cookie = cookieLine;
|
||||
|
||||
let body: NonNullable<HttpSpec["body"]> = { mode: "none" };
|
||||
if (data.bodyMode === "urlencoded") {
|
||||
const encoded = Object.entries(kvMap(data.bodyForm, env))
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
||||
.join("&");
|
||||
body = { mode: "urlencoded", raw: encoded };
|
||||
if (!("content-type" in headers)) headers["Content-Type"] = "application/x-www-form-urlencoded";
|
||||
} else if (data.bodyMode === "form-data") {
|
||||
const raw = buildMultipart(data.bodyForm, env);
|
||||
body = { mode: "form-data", raw };
|
||||
const m = raw.match(/^----\S+/);
|
||||
headers["Content-Type"] = `multipart/form-data${m ? `; boundary=${m[0]}` : ""}`;
|
||||
} else if (data.bodyMode !== "none") {
|
||||
body = { mode: data.bodyMode, raw: replaceVariables(data.bodyRaw, env) };
|
||||
if (data.bodyMode === "json" && !("content-type" in headers)) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
if (data.bodyMode === "xml" && !("content-type" in headers)) {
|
||||
headers["Content-Type"] = "application/xml";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
method: data.method,
|
||||
url: replaceVariables(data.url, env),
|
||||
headers,
|
||||
query: { ...kvMap(data.query, env), ...authQ },
|
||||
body,
|
||||
timeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
async function getTransport() {
|
||||
const { detectTransport } = await import("@opsxapi/transport");
|
||||
const { kind, transport } = detectTransport({ ginBaseUrl: "" });
|
||||
return { kind, transport };
|
||||
}
|
||||
|
||||
async function sendHttpLike(
|
||||
id: string,
|
||||
label: string,
|
||||
spec: HttpSpec,
|
||||
url: string,
|
||||
): Promise<void> {
|
||||
const store = useWorkspace.getState();
|
||||
store.setRuntime(id, { loading: true, error: null });
|
||||
const started = performance.now();
|
||||
const { transport } = await getTransport();
|
||||
try {
|
||||
const result = await transport.http(spec);
|
||||
useWorkspace.getState().setRuntime(id, { loading: false, result });
|
||||
useWorkspace.getState().addHistory({
|
||||
id: crypto.randomUUID(),
|
||||
at: Date.now(),
|
||||
kind: "http",
|
||||
label,
|
||||
method: spec.method,
|
||||
url,
|
||||
status: result.status,
|
||||
durationMs: result.timeMs,
|
||||
ok: result.status < 400,
|
||||
nodeId: id,
|
||||
});
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
useWorkspace.getState().setRuntime(id, { loading: false, error: message });
|
||||
useWorkspace.getState().addHistory({
|
||||
id: crypto.randomUUID(),
|
||||
at: Date.now(),
|
||||
kind: "http",
|
||||
label,
|
||||
method: spec.method,
|
||||
url,
|
||||
ok: false,
|
||||
nodeId: id,
|
||||
});
|
||||
void started;
|
||||
}
|
||||
}
|
||||
|
||||
export function sendHttpRequest(node: RequestNode & { kind: "http" }): void {
|
||||
const env = activeEnvMap();
|
||||
const { timeoutMs } = useWorkspace.getState().settings;
|
||||
const spec = buildHttpSpec(node.data, env, timeoutMs);
|
||||
void sendHttpLike(node.id, node.name, spec, node.data.url);
|
||||
}
|
||||
|
||||
export function sendGraphqlRequest(node: RequestNode & { kind: "graphql" }): void {
|
||||
const env = activeEnvMap();
|
||||
const { timeoutMs } = useWorkspace.getState().settings;
|
||||
const data = node.data as GraphqlRequestData;
|
||||
let variablesRaw: string = "{}";
|
||||
try {
|
||||
variablesRaw = JSON.stringify(JSON.parse(replaceVariables(data.variables || "{}", env)));
|
||||
} catch {
|
||||
variablesRaw = replaceVariables(data.variables || "{}", env);
|
||||
}
|
||||
const payload = JSON.stringify(
|
||||
{ query: replaceVariables(data.query, env), variables: JSON.parse(variablesRaw) },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const headers = kvMap(data.headers, env);
|
||||
const { headers: authH } = authHeaders(data.auth, env);
|
||||
Object.assign(headers, authH);
|
||||
if (!("content-type" in headers)) headers["Content-Type"] = "application/json";
|
||||
const spec: HttpSpec = {
|
||||
method: "POST",
|
||||
url: replaceVariables(data.url, env),
|
||||
headers,
|
||||
query: {},
|
||||
body: { mode: "json", raw: payload },
|
||||
timeoutMs,
|
||||
};
|
||||
void sendHttpLike(node.id, node.name, spec, data.url);
|
||||
}
|
||||
|
||||
/* ---------------- WebSocket ---------------- */
|
||||
|
||||
export function connectWs(node: RequestNode & { kind: "websocket" }): void {
|
||||
const store = useWorkspace.getState();
|
||||
if (sessions.has(node.id)) {
|
||||
closeSession(node.id);
|
||||
return;
|
||||
}
|
||||
const env = activeEnvMap();
|
||||
const data = node.data;
|
||||
const url = replaceVariables(data.url, env);
|
||||
const headers = kvMap(data.headers, env);
|
||||
const protocols = data.protocols
|
||||
.split(",")
|
||||
.map((s) => replaceVariables(s.trim(), env))
|
||||
.filter(Boolean);
|
||||
|
||||
store.setRuntime(node.id, { status: "connecting", startedAt: Date.now(), error: null });
|
||||
store.pushLive(node.id, "system", `正在连接 ${url}${Object.keys(headers).length ? "(自定义 Header 走 Gin 隧道)" : ""}`);
|
||||
|
||||
const session = wsSmart({ url, headers, protocols }, {
|
||||
ginBase: "",
|
||||
onInfo: (info) => {
|
||||
if (info.mode === "tunnel") {
|
||||
store.pushLive(node.id, "system", `已通过 Gin 隧道建立(会话 ${info.sessionId?.slice(0, 8)}…)`);
|
||||
}
|
||||
},
|
||||
});
|
||||
sessions.set(node.id, session);
|
||||
session.onStatus((s) => {
|
||||
useWorkspace.getState().setRuntime(node.id, { status: s });
|
||||
if (s === "error") useWorkspace.getState().pushLive(node.id, "error", "连接出错");
|
||||
if (s === "closed") {
|
||||
useWorkspace.getState().pushLive(node.id, "system", "连接已关闭");
|
||||
sessions.delete(node.id);
|
||||
}
|
||||
});
|
||||
session.onMessage((msg) => {
|
||||
useWorkspace.getState().pushLive(node.id, "in", msg);
|
||||
});
|
||||
}
|
||||
|
||||
export function wsSend(id: string, text: string): void {
|
||||
const session = sessions.get(id);
|
||||
if (!session) return;
|
||||
(session as WsSession).send(text);
|
||||
useWorkspace.getState().pushLive(id, "out", text);
|
||||
}
|
||||
|
||||
/* ---------------- SSE ---------------- */
|
||||
|
||||
export function connectSse(node: RequestNode & { kind: "sse" }): void {
|
||||
const store = useWorkspace.getState();
|
||||
if (sessions.has(node.id)) {
|
||||
closeSession(node.id);
|
||||
return;
|
||||
}
|
||||
const env = activeEnvMap();
|
||||
const data = node.data;
|
||||
const url = replaceVariables(data.url, env);
|
||||
const { headers: authH, query: authQ } = authHeaders(data.auth, env);
|
||||
const headers = { ...kvMap(data.headers, env), ...authH };
|
||||
const query = { ...kvMap(data.query, env), ...authQ };
|
||||
|
||||
store.setRuntime(node.id, { status: "connecting", startedAt: Date.now(), error: null });
|
||||
store.pushLive(node.id, "system", `正在连接 ${url}`);
|
||||
|
||||
const session = openSse({
|
||||
url,
|
||||
headers,
|
||||
query,
|
||||
lastEventId: data.lastEventId ? replaceVariables(data.lastEventId, env) : undefined,
|
||||
ginBase: "",
|
||||
onEvent: (ev) => {
|
||||
let text = ev.data;
|
||||
if (data.dataOnly && ev.event === "message") {
|
||||
text = tryFormatJson(ev.data);
|
||||
} else {
|
||||
text = `event: ${ev.event}\n${tryFormatJson(ev.data)}`;
|
||||
}
|
||||
useWorkspace.getState().pushLive(node.id, "in", text, ev.event);
|
||||
},
|
||||
onStatus: (s, meta) => {
|
||||
useWorkspace.getState().setRuntime(node.id, { status: s });
|
||||
if (s === "open" && meta) {
|
||||
useWorkspace
|
||||
.getState()
|
||||
.pushLive(node.id, "system", `已连接 · HTTP ${meta.status} ${meta.headers["content-type"] ?? ""}`);
|
||||
}
|
||||
if (s === "closed") {
|
||||
useWorkspace.getState().pushLive(node.id, "system", "连接已结束");
|
||||
}
|
||||
},
|
||||
onError: (msg, willFallback) => {
|
||||
if (willFallback) {
|
||||
useWorkspace.getState().pushLive(node.id, "system", `直连失败(${msg.slice(0, 120)}),切换 Gin 代理…`);
|
||||
} else {
|
||||
useWorkspace.getState().pushLive(node.id, "error", msg);
|
||||
}
|
||||
},
|
||||
});
|
||||
const wrapper: Session = { close: () => session.abort() };
|
||||
sessions.set(node.id, wrapper);
|
||||
}
|
||||
|
||||
/* ---------------- Socket.IO ---------------- */
|
||||
|
||||
export function connectSocketIoSession(node: RequestNode & { kind: "socketio" }): void {
|
||||
const store = useWorkspace.getState();
|
||||
if (sessions.has(node.id)) {
|
||||
closeSession(node.id);
|
||||
return;
|
||||
}
|
||||
const env = activeEnvMap();
|
||||
const data = node.data;
|
||||
const url = replaceVariables(data.url, env);
|
||||
|
||||
store.setRuntime(node.id, { status: "connecting", startedAt: Date.now(), error: null });
|
||||
store.pushLive(node.id, "system", `正在连接 ${url}`);
|
||||
|
||||
const session = connectSocketIo({
|
||||
url,
|
||||
optionsJson: data.options,
|
||||
listenEvents: data.listenEvents,
|
||||
onEvent: (event, args) => {
|
||||
const text = args.length === 1 ? prettyMaybe(args[0]) : JSON.stringify(args, null, 2);
|
||||
useWorkspace.getState().pushLive(node.id, "in", text, event);
|
||||
},
|
||||
onStatus: (s, detail) => {
|
||||
useWorkspace.getState().setRuntime(node.id, { status: s });
|
||||
if (s === "open") useWorkspace.getState().pushLive(node.id, "system", `已连接 ${detail ? `(${detail})` : ""}`);
|
||||
if (s === "closed") {
|
||||
useWorkspace.getState().pushLive(node.id, "system", `已断开${detail ? `:${detail}` : ""}`);
|
||||
sessions.delete(node.id);
|
||||
}
|
||||
},
|
||||
onError: (msg) => useWorkspace.getState().pushLive(node.id, "error", msg),
|
||||
});
|
||||
sessions.set(node.id, session as unknown as Session);
|
||||
}
|
||||
|
||||
export function socketIoEmit(id: string, event: string, argsJson: string): boolean {
|
||||
const session = sessions.get(id);
|
||||
if (!session) return false;
|
||||
let args: unknown[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(argsJson || "[]");
|
||||
args = Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch {
|
||||
useWorkspace.getState().pushLive(id, "error", "参数不是合法 JSON");
|
||||
return false;
|
||||
}
|
||||
(session as SocketIoSession).emit(event, args);
|
||||
useWorkspace.getState().pushLive(id, "out", JSON.stringify({ event, args }, null, 2), event);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ---------------- MQTT ---------------- */
|
||||
|
||||
export function connectMqttSession(node: RequestNode & { kind: "mqtt" }): void {
|
||||
const store = useWorkspace.getState();
|
||||
if (sessions.has(node.id)) {
|
||||
closeSession(node.id);
|
||||
return;
|
||||
}
|
||||
const env = activeEnvMap();
|
||||
const data = node.data;
|
||||
const url = replaceVariables(data.brokerUrl, env);
|
||||
const topics = data.subscribeTopics.split(",").map((t) => replaceVariables(t.trim(), env)).filter(Boolean);
|
||||
|
||||
store.setRuntime(node.id, { status: "connecting", startedAt: Date.now(), error: null });
|
||||
store.pushLive(node.id, "system", `正在连接 ${url}${topics.length ? `,订阅 ${topics.join(", ")}` : ""}`);
|
||||
|
||||
const session = connectMqtt({
|
||||
brokerUrl: url,
|
||||
username: replaceVariables(data.username, env),
|
||||
password: replaceVariables(data.password, env),
|
||||
clientId: replaceVariables(data.clientId, env),
|
||||
topics,
|
||||
qos: data.qos,
|
||||
onMessage: (msg) => {
|
||||
useWorkspace.getState().pushLive(node.id, "in", prettyMaybeText(msg.payload), msg.topic);
|
||||
},
|
||||
onStatus: (s, detail) => {
|
||||
useWorkspace.getState().setRuntime(node.id, { status: s });
|
||||
if (s === "open") useWorkspace.getState().pushLive(node.id, "system", `已连接 broker${detail ? `:${detail}` : ""}`);
|
||||
if (s === "reconnecting") useWorkspace.getState().pushLive(node.id, "system", "正在重连…");
|
||||
if (s === "closed") {
|
||||
useWorkspace.getState().pushLive(node.id, "system", "连接已关闭");
|
||||
sessions.delete(node.id);
|
||||
}
|
||||
},
|
||||
onError: (msg) => useWorkspace.getState().pushLive(node.id, "error", msg),
|
||||
});
|
||||
sessions.set(node.id, session as unknown as Session);
|
||||
}
|
||||
|
||||
export function mqttPublish(id: string, node: RequestNode & { kind: "mqtt" }): void {
|
||||
const session = sessions.get(id) as (MqttSession & { close(): void }) | undefined;
|
||||
if (!session) return;
|
||||
const env = activeEnvMap();
|
||||
const topic = replaceVariables(node.data.publishTopic, env);
|
||||
const payload = replaceVariables(node.data.publishPayload, env);
|
||||
session.publish(topic, payload, node.data.qos);
|
||||
useWorkspace.getState().pushLive(id, "out", prettyMaybeText(payload), topic);
|
||||
}
|
||||
|
||||
function tryFormatJson(s: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(s), null, 2);
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
function prettyMaybe(v: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(v, null, 2);
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
|
||||
function prettyMaybeText(s: string): string {
|
||||
return tryFormatJson(s);
|
||||
}
|
||||
|
||||
export type { StreamSession };
|
||||
@@ -0,0 +1,695 @@
|
||||
/**
|
||||
* 工作台主 store:集合 / 环境 / 标签页 / 历史 / 设置 / 运行时状态。
|
||||
* 持久化:localStorage(debounce),实时会话等运行时数据不落盘。
|
||||
*/
|
||||
import type {
|
||||
Collection,
|
||||
CollectionNode,
|
||||
Environment,
|
||||
FolderNode,
|
||||
HistoryEntry,
|
||||
HttpRequestData,
|
||||
RequestKind,
|
||||
RequestNode,
|
||||
ThemeMode,
|
||||
WorkspaceData,
|
||||
WorkspaceSettings,
|
||||
} from "@opsxapi/core";
|
||||
import {
|
||||
createRequest,
|
||||
createSeedWorkspace,
|
||||
defaultRequestData,
|
||||
deepCopyNode,
|
||||
findNode,
|
||||
insertIntoCollection,
|
||||
kv,
|
||||
moveNode,
|
||||
newId,
|
||||
uniqueName,
|
||||
} from "@opsxapi/core";
|
||||
import { create } from "zustand";
|
||||
|
||||
const STORAGE_KEY = "opsxapi.workspace.v2";
|
||||
|
||||
export type LiveMessage = {
|
||||
id: string;
|
||||
dir: "in" | "out" | "system" | "error";
|
||||
at: number;
|
||||
text: string;
|
||||
/** mqtt topic / socketio event / sse event */
|
||||
topic?: string;
|
||||
};
|
||||
|
||||
export type RequestRuntime = {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/** http/graphql 响应 */
|
||||
result: {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
timeMs: number;
|
||||
size: number;
|
||||
} | null;
|
||||
responseTab: "body" | "headers" | "raw";
|
||||
/** 实时协议状态 */
|
||||
status: string;
|
||||
messages: LiveMessage[];
|
||||
sent: number;
|
||||
received: number;
|
||||
startedAt: number | null;
|
||||
};
|
||||
|
||||
export type Dialog =
|
||||
| { type: "new"; collectionId: string; parentId: string | null }
|
||||
| { type: "import" }
|
||||
| { type: "export"; collectionId: string }
|
||||
| { type: "environments" }
|
||||
| { type: "settings" }
|
||||
| { type: "rename"; id: string }
|
||||
| { type: "confirm"; title: string; message: string; action: () => void };
|
||||
|
||||
export function emptyRuntime(): RequestRuntime {
|
||||
return {
|
||||
loading: false,
|
||||
error: null,
|
||||
result: null,
|
||||
responseTab: "body",
|
||||
status: "closed",
|
||||
messages: [],
|
||||
sent: 0,
|
||||
received: 0,
|
||||
startedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
type PersistedShape = Pick<
|
||||
WorkspaceData,
|
||||
"collections" | "environments" | "activeEnvironmentId" | "history" | "settings"
|
||||
>;
|
||||
|
||||
type WorkspaceStore = PersistedShape & {
|
||||
openTabIds: string[];
|
||||
activeTabId: string | null;
|
||||
search: string;
|
||||
expanded: Record<string, boolean>;
|
||||
dialog: Dialog | null;
|
||||
runtime: Record<string, RequestRuntime>;
|
||||
|
||||
init(): void;
|
||||
|
||||
addCollection(name?: string): string;
|
||||
renameNode(id: string, name: string): void;
|
||||
removeNodeById(id: string): void;
|
||||
duplicateNodeById(id: string): void;
|
||||
moveNodeTo(id: string, collectionId: string, parentId: string | null): void;
|
||||
addFolder(collectionId: string, parentId: string | null, name?: string): string;
|
||||
addRequest(collectionId: string, parentId: string | null, kind: RequestKind, name?: string): string;
|
||||
updateRequestData<K extends RequestKind>(
|
||||
id: string,
|
||||
patch: Partial<Extract<RequestNode, { kind: K }>["data"]>,
|
||||
): void;
|
||||
updateNodeDescription(id: string, description: string): void;
|
||||
importCollections(collections: Collection[], envVars: { key: string; value: string }[]): string[];
|
||||
loadWorkspaceData(data: WorkspaceData): void;
|
||||
resetWorkspace(): void;
|
||||
|
||||
openTab(id: string): void;
|
||||
closeTab(id: string): void;
|
||||
closeOtherTabs(id: string): void;
|
||||
setActiveTab(id: string | null): void;
|
||||
|
||||
setActiveEnvironment(id: string | null): void;
|
||||
addEnvironment(name?: string): string;
|
||||
duplicateEnvironment(id: string): string;
|
||||
removeEnvironment(id: string): void;
|
||||
updateEnvironment(id: string, patch: Partial<Omit<Environment, "id">>): void;
|
||||
addEnvVariable(envId: string): void;
|
||||
updateEnvVariable(envId: string, rowId: string, patch: { key?: string; value?: string; enabled?: boolean }): void;
|
||||
removeEnvVariable(envId: string, rowId: string): void;
|
||||
|
||||
setTheme(theme: ThemeMode): void;
|
||||
setTimeoutMs(ms: number): void;
|
||||
|
||||
setRuntime(id: string, patch: Partial<RequestRuntime>): void;
|
||||
pushLive(id: string, dir: LiveMessage["dir"], text: string, topic?: string): void;
|
||||
clearLive(id: string): void;
|
||||
resetRuntime(id: string): void;
|
||||
|
||||
addHistory(entry: HistoryEntry): void;
|
||||
clearHistory(): void;
|
||||
setSearch(term: string): void;
|
||||
toggleExpanded(id: string, force?: boolean): void;
|
||||
setDialog(dialog: Dialog | null): void;
|
||||
};
|
||||
|
||||
/** 自愈历史坏数据:请求节点缺 data 时按 kind 补默认值(保名称与说明) */
|
||||
function sanitizeCollections(collections: Collection[]): Collection[] {
|
||||
const fix = (nodes: CollectionNode[]): CollectionNode[] =>
|
||||
nodes.map((n) => {
|
||||
if (n.type === "folder") return { ...n, children: fix(n.children) };
|
||||
if (n.type === "request" && (!n.data || !n.kind)) {
|
||||
const kind = (n.kind ?? "http") as RequestKind;
|
||||
return {
|
||||
...n,
|
||||
kind,
|
||||
data: { ...defaultRequestData(kind) },
|
||||
} as RequestNode;
|
||||
}
|
||||
return n;
|
||||
});
|
||||
return collections.map((c) => ({ ...c, children: fix(c.children) }));
|
||||
}
|
||||
|
||||
function loadPersisted(): WorkspaceData | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const data = JSON.parse(raw) as Partial<WorkspaceData>;
|
||||
if (!Array.isArray(data.collections) || !Array.isArray(data.environments)) return null;
|
||||
return {
|
||||
version: 2,
|
||||
collections: sanitizeCollections(data.collections),
|
||||
environments: data.environments,
|
||||
activeEnvironmentId: data.activeEnvironmentId ?? data.environments[0]?.id ?? null,
|
||||
history: Array.isArray(data.history) ? data.history : [],
|
||||
settings: {
|
||||
theme: data.settings?.theme === "light" ? "light" : "dark",
|
||||
timeoutMs: data.settings?.timeoutMs ?? 30000,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function schedulePersist(state: WorkspaceStore): void {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
try {
|
||||
const data: PersistedShape & { version: number } = {
|
||||
version: 2,
|
||||
collections: state.collections,
|
||||
environments: state.environments,
|
||||
activeEnvironmentId: state.activeEnvironmentId,
|
||||
history: state.history.slice(0, 100),
|
||||
settings: state.settings,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
} catch (e) {
|
||||
console.warn("[opsxapi] 工作区保存失败", e);
|
||||
}
|
||||
}, 400);
|
||||
}
|
||||
|
||||
export function applyThemeToDocument(theme: ThemeMode): void {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.dataset.theme = theme;
|
||||
}
|
||||
|
||||
export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
||||
const persist = () => schedulePersist(get());
|
||||
|
||||
return {
|
||||
collections: [],
|
||||
environments: [],
|
||||
activeEnvironmentId: null,
|
||||
history: [],
|
||||
settings: { theme: "dark", timeoutMs: 30000 },
|
||||
|
||||
openTabIds: [],
|
||||
activeTabId: null,
|
||||
search: "",
|
||||
expanded: {},
|
||||
dialog: null,
|
||||
|
||||
init() {
|
||||
const data = loadPersisted() ?? createSeedWorkspace();
|
||||
set({
|
||||
collections: data.collections,
|
||||
environments: data.environments,
|
||||
activeEnvironmentId: data.activeEnvironmentId,
|
||||
history: data.history,
|
||||
settings: data.settings,
|
||||
});
|
||||
applyThemeToDocument(data.settings.theme);
|
||||
// 展开全部目录
|
||||
const expanded: Record<string, boolean> = {};
|
||||
for (const c of data.collections) {
|
||||
walkFolders(c.children, (f) => {
|
||||
expanded[f.id] = true;
|
||||
});
|
||||
}
|
||||
set({ expanded });
|
||||
},
|
||||
|
||||
addCollection(name) {
|
||||
const collection: Collection = {
|
||||
id: newId(),
|
||||
type: "collection",
|
||||
name: name || "新建集合",
|
||||
children: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
set({ collections: [...get().collections, collection] });
|
||||
persist();
|
||||
return collection.id;
|
||||
},
|
||||
|
||||
renameNode(id, name) {
|
||||
set({
|
||||
collections: get().collections.map((c) =>
|
||||
c.id === id
|
||||
? { ...c, name, updatedAt: Date.now() }
|
||||
: renameIn(c, id, name),
|
||||
),
|
||||
});
|
||||
persist();
|
||||
},
|
||||
|
||||
removeNodeById(id) {
|
||||
const state = get();
|
||||
const openTabIds = state.openTabIds.filter((t) => t !== id);
|
||||
const activeTabId =
|
||||
state.activeTabId === id ? (openTabIds[openTabIds.length - 1] ?? null) : state.activeTabId;
|
||||
const collections = state.collections.map((c) => removeFrom(c, id));
|
||||
set({ collections, openTabIds, activeTabId });
|
||||
persist();
|
||||
},
|
||||
|
||||
duplicateNodeById(id) {
|
||||
const state = get();
|
||||
const ref = findNode(state.collections, id);
|
||||
if (!ref) return;
|
||||
const copy = deepCopyNode(ref.node, (name) => `${name} 副本`);
|
||||
const collections = insertIntoCollection(
|
||||
state.collections,
|
||||
ref.collection.id,
|
||||
ref.parent?.id ?? null,
|
||||
copy,
|
||||
);
|
||||
set({ collections });
|
||||
if (copy.type === "request") get().openTab(copy.id);
|
||||
persist();
|
||||
},
|
||||
|
||||
moveNodeTo(id, collectionId, parentId) {
|
||||
set({ collections: moveNode(get().collections, id, collectionId, parentId) });
|
||||
persist();
|
||||
},
|
||||
|
||||
addFolder(collectionId, parentId, name) {
|
||||
const state = get();
|
||||
const siblings = parentChildren(state.collections, collectionId, parentId);
|
||||
const folder: FolderNode = {
|
||||
id: newId(),
|
||||
type: "folder",
|
||||
name: uniqueName(siblings, name ?? "新建目录"),
|
||||
children: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
set({
|
||||
collections: insertIntoCollection(state.collections, collectionId, parentId, folder),
|
||||
expanded: { ...state.expanded, [folder.id]: true },
|
||||
});
|
||||
persist();
|
||||
return folder.id;
|
||||
},
|
||||
|
||||
addRequest(collectionId, parentId, kind, name) {
|
||||
const state = get();
|
||||
const siblings = parentChildren(state.collections, collectionId, parentId);
|
||||
const node: RequestNode = createRequest(kind, uniqueName(siblings, name ?? defaultNames(kind)));
|
||||
set({ collections: insertIntoCollection(state.collections, collectionId, parentId, node) });
|
||||
get().openTab(node.id);
|
||||
persist();
|
||||
return node.id;
|
||||
},
|
||||
|
||||
updateRequestData(id, patch) {
|
||||
set({
|
||||
collections: get().collections.map((c) => patchRequestIn(c, id, patch)),
|
||||
});
|
||||
persist();
|
||||
},
|
||||
|
||||
updateNodeDescription(id, description) {
|
||||
set({
|
||||
collections: get().collections.map((c) => descRequestIn(c, id, description)),
|
||||
});
|
||||
persist();
|
||||
},
|
||||
|
||||
importCollections(collections, envVars) {
|
||||
const state = get();
|
||||
const existingNames = new Set(state.collections.map((c) => c.name));
|
||||
const renamed = collections.map((c) => {
|
||||
if (!existingNames.has(c.name)) return c;
|
||||
let i = 2;
|
||||
while (existingNames.has(`${c.name} ${i}`)) i++;
|
||||
return { ...c, name: `${c.name} ${i}` };
|
||||
});
|
||||
// 环境变量并入当前环境
|
||||
let environments = state.environments;
|
||||
let activeEnvironmentId = state.activeEnvironmentId;
|
||||
if (envVars.length) {
|
||||
const active = environments.find((e) => e.id === activeEnvironmentId) ?? environments[0];
|
||||
if (active) {
|
||||
const known = new Set(active.variables.map((v) => v.key));
|
||||
for (const v of envVars) {
|
||||
if (!known.has(v.key)) {
|
||||
active.variables.push(kv(v.key, v.value));
|
||||
known.add(v.key);
|
||||
}
|
||||
}
|
||||
environments = environments.map((e) => (e.id === active.id ? { ...active } : e));
|
||||
} else {
|
||||
const env: Environment = {
|
||||
id: newId(),
|
||||
name: "默认环境",
|
||||
variables: envVars.map((v) => kv(v.key, v.value)),
|
||||
};
|
||||
environments = [...environments, env];
|
||||
activeEnvironmentId = env.id;
|
||||
}
|
||||
}
|
||||
set({ collections: [...state.collections, ...renamed], environments, activeEnvironmentId });
|
||||
persist();
|
||||
return renamed.map((c) => c.id);
|
||||
},
|
||||
|
||||
loadWorkspaceData(data) {
|
||||
set({
|
||||
collections: data.collections,
|
||||
environments: data.environments,
|
||||
activeEnvironmentId: data.activeEnvironmentId ?? data.environments[0]?.id ?? null,
|
||||
history: data.history ?? [],
|
||||
settings: data.settings,
|
||||
openTabIds: [],
|
||||
activeTabId: null,
|
||||
});
|
||||
applyThemeToDocument(data.settings.theme);
|
||||
persist();
|
||||
},
|
||||
|
||||
resetWorkspace() {
|
||||
const seed = createSeedWorkspace();
|
||||
get().loadWorkspaceData(seed);
|
||||
},
|
||||
|
||||
openTab(id) {
|
||||
const state = get();
|
||||
if (state.openTabIds.includes(id)) {
|
||||
set({ activeTabId: id });
|
||||
return;
|
||||
}
|
||||
set({ openTabIds: [...state.openTabIds, id], activeTabId: id });
|
||||
},
|
||||
|
||||
closeTab(id) {
|
||||
const state = get();
|
||||
const openTabIds = state.openTabIds.filter((t) => t !== id);
|
||||
set({
|
||||
openTabIds,
|
||||
activeTabId:
|
||||
state.activeTabId === id ? (openTabIds[openTabIds.length - 1] ?? null) : state.activeTabId,
|
||||
});
|
||||
},
|
||||
|
||||
closeOtherTabs(id) {
|
||||
set({ openTabIds: [id], activeTabId: id });
|
||||
},
|
||||
|
||||
setActiveTab(id) {
|
||||
set({ activeTabId: id });
|
||||
},
|
||||
|
||||
setActiveEnvironment(id) {
|
||||
set({ activeEnvironmentId: id });
|
||||
persist();
|
||||
},
|
||||
|
||||
addEnvironment(name) {
|
||||
const env: Environment = {
|
||||
id: newId(),
|
||||
name: name || `环境 ${get().environments.length + 1}`,
|
||||
variables: [],
|
||||
};
|
||||
set({ environments: [...get().environments, env] });
|
||||
persist();
|
||||
return env.id;
|
||||
},
|
||||
|
||||
duplicateEnvironment(id) {
|
||||
const state = get();
|
||||
const source = state.environments.find((e) => e.id === id);
|
||||
if (!source) return "";
|
||||
let name = `${source.name} 副本`;
|
||||
let i = 2;
|
||||
const names = new Set(state.environments.map((e) => e.name));
|
||||
while (names.has(name)) name = `${source.name} 副本 ${i++}`;
|
||||
const copy: Environment = {
|
||||
id: newId(),
|
||||
name,
|
||||
variables: source.variables.map((v) => kv(v.key, v.value, v.enabled, v.description)),
|
||||
};
|
||||
set({ environments: [...state.environments, copy] });
|
||||
persist();
|
||||
return copy.id;
|
||||
},
|
||||
|
||||
removeEnvironment(id) {
|
||||
const state = get();
|
||||
const environments = state.environments.filter((e) => e.id !== id);
|
||||
set({
|
||||
environments,
|
||||
activeEnvironmentId:
|
||||
state.activeEnvironmentId === id ? (environments[0]?.id ?? null) : state.activeEnvironmentId,
|
||||
});
|
||||
persist();
|
||||
},
|
||||
|
||||
updateEnvironment(id, patch) {
|
||||
set({
|
||||
environments: get().environments.map((e) => (e.id === id ? { ...e, ...patch } : e)),
|
||||
});
|
||||
persist();
|
||||
},
|
||||
|
||||
addEnvVariable(envId) {
|
||||
set({
|
||||
environments: get().environments.map((e) =>
|
||||
e.id === envId ? { ...e, variables: [...e.variables, kv()] } : e,
|
||||
),
|
||||
});
|
||||
persist();
|
||||
},
|
||||
|
||||
updateEnvVariable(envId, rowId, patch) {
|
||||
set({
|
||||
environments: get().environments.map((e) =>
|
||||
e.id === envId
|
||||
? {
|
||||
...e,
|
||||
variables: e.variables.map((v) => (v.id === rowId ? { ...v, ...patch } : v)),
|
||||
}
|
||||
: e,
|
||||
),
|
||||
});
|
||||
persist();
|
||||
},
|
||||
|
||||
removeEnvVariable(envId, rowId) {
|
||||
set({
|
||||
environments: get().environments.map((e) =>
|
||||
e.id === envId ? { ...e, variables: e.variables.filter((v) => v.id !== rowId) } : e,
|
||||
),
|
||||
});
|
||||
persist();
|
||||
},
|
||||
|
||||
setTheme(theme) {
|
||||
set({ settings: { ...get().settings, theme } });
|
||||
applyThemeToDocument(theme);
|
||||
persist();
|
||||
},
|
||||
|
||||
setTimeoutMs(ms) {
|
||||
set({ settings: { ...get().settings, timeoutMs: ms } });
|
||||
persist();
|
||||
},
|
||||
|
||||
setRuntime(id, patch) {
|
||||
const state = get();
|
||||
set({
|
||||
runtime: {
|
||||
...state.runtime,
|
||||
[id]: { ...(state.runtime[id] ?? emptyRuntime()), ...patch },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
pushLive(id, dir, text, topic) {
|
||||
const state = get();
|
||||
const prev = state.runtime[id] ?? emptyRuntime();
|
||||
const msg: LiveMessage = { id: newId(), dir, at: Date.now(), text, topic };
|
||||
const messages = [...prev.messages, msg].slice(-500);
|
||||
set({
|
||||
runtime: {
|
||||
...state.runtime,
|
||||
[id]: {
|
||||
...prev,
|
||||
messages,
|
||||
sent: dir === "out" ? prev.sent + 1 : prev.sent,
|
||||
received: dir === "in" ? prev.received + 1 : prev.received,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
clearLive(id) {
|
||||
const state = get();
|
||||
const prev = state.runtime[id] ?? emptyRuntime();
|
||||
set({ runtime: { ...state.runtime, [id]: { ...prev, messages: [] } } });
|
||||
},
|
||||
|
||||
resetRuntime(id) {
|
||||
const state = get();
|
||||
set({ runtime: { ...state.runtime, [id]: emptyRuntime() } });
|
||||
},
|
||||
|
||||
addHistory(entry) {
|
||||
set({ history: [entry, ...get().history].slice(0, 100) });
|
||||
persist();
|
||||
},
|
||||
|
||||
clearHistory() {
|
||||
set({ history: [] });
|
||||
persist();
|
||||
},
|
||||
|
||||
setSearch(term) {
|
||||
set({ search: term });
|
||||
},
|
||||
|
||||
toggleExpanded(id, force) {
|
||||
const expanded = { ...get().expanded };
|
||||
expanded[id] = force ?? !expanded[id];
|
||||
set({ expanded });
|
||||
},
|
||||
|
||||
setDialog(dialog) {
|
||||
set({ dialog });
|
||||
},
|
||||
|
||||
runtime: {} as Record<string, RequestRuntime>,
|
||||
};
|
||||
});
|
||||
|
||||
// ---------- 纯帮助函数 ----------
|
||||
|
||||
function walkFolders(nodes: CollectionNode[], fn: (f: FolderNode) => void): void {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "folder") {
|
||||
fn(node);
|
||||
walkFolders(node.children, fn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renameIn(collection: Collection, id: string, name: string): Collection {
|
||||
const visit = (nodes: CollectionNode[]): CollectionNode[] =>
|
||||
nodes.map((n) => {
|
||||
if (n.id === id) return { ...n, name, updatedAt: Date.now() };
|
||||
if (n.type === "folder") return { ...n, children: visit(n.children) };
|
||||
return n;
|
||||
});
|
||||
return { ...collection, children: visit(collection.children) };
|
||||
}
|
||||
|
||||
function removeFrom(collection: Collection, id: string): Collection {
|
||||
const visit = (nodes: CollectionNode[]): CollectionNode[] =>
|
||||
nodes
|
||||
.filter((n) => n.id !== id)
|
||||
.map((n) => (n.type === "folder" ? { ...n, children: visit(n.children) } : n));
|
||||
return { ...collection, children: visit(collection.children) };
|
||||
}
|
||||
|
||||
function patchRequestIn(
|
||||
collection: Collection,
|
||||
id: string,
|
||||
patch: Record<string, unknown>,
|
||||
): Collection {
|
||||
const visit = (nodes: CollectionNode[]): CollectionNode[] =>
|
||||
nodes.map((n) => {
|
||||
if (n.id === id && n.type === "request") {
|
||||
const merged = { ...n.data, ...patch } as RequestNode["data"];
|
||||
return { ...n, data: merged, updatedAt: Date.now() } as CollectionNode;
|
||||
}
|
||||
if (n.type === "folder") return { ...n, children: visit(n.children) };
|
||||
return n;
|
||||
});
|
||||
return { ...collection, children: visit(collection.children) };
|
||||
}
|
||||
|
||||
function descRequestIn(collection: Collection, id: string, description: string): Collection {
|
||||
const visit = (nodes: CollectionNode[]): CollectionNode[] =>
|
||||
nodes.map((n) => {
|
||||
if (n.id === id) return { ...n, description: description || undefined, updatedAt: Date.now() };
|
||||
if (n.type === "folder") return { ...n, children: visit(n.children) };
|
||||
return n;
|
||||
});
|
||||
return { ...collection, children: visit(collection.children) };
|
||||
}
|
||||
|
||||
function parentChildren(
|
||||
collections: Collection[],
|
||||
collectionId: string,
|
||||
parentId: string | null,
|
||||
): CollectionNode[] {
|
||||
const collection = collections.find((c) => c.id === collectionId);
|
||||
if (!collection) return [];
|
||||
if (parentId === null) return collection.children;
|
||||
let foundChildren: CollectionNode[] | null = null;
|
||||
const visit = (nodes: CollectionNode[]) => {
|
||||
for (const n of nodes) {
|
||||
if (n.id === parentId) {
|
||||
if (n.type === "folder") foundChildren = n.children;
|
||||
return;
|
||||
}
|
||||
if (n.type === "folder") visit(n.children);
|
||||
}
|
||||
};
|
||||
visit(collection.children);
|
||||
return foundChildren ?? collection.children;
|
||||
}
|
||||
|
||||
function defaultNames(kind: RequestKind): string {
|
||||
const labels: Record<RequestKind, string> = {
|
||||
http: "新建 HTTP 接口",
|
||||
graphql: "新建 GraphQL",
|
||||
websocket: "新建 WebSocket",
|
||||
sse: "新建 SSE",
|
||||
socketio: "新建 Socket.IO",
|
||||
mqtt: "新建 MQTT",
|
||||
};
|
||||
return labels[kind];
|
||||
}
|
||||
|
||||
/** 便捷选择器:当前环境变量表 */
|
||||
export function activeEnvMap(): Record<string, string> {
|
||||
const state = useWorkspace.getState();
|
||||
const active = state.environments.find((e) => e.id === state.activeEnvironmentId);
|
||||
const out: Record<string, string> = {};
|
||||
for (const v of active?.variables ?? []) {
|
||||
if (v.enabled && v.key) out[v.key] = v.value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 便捷选择器:请求体构建辅助(http/graphql 共用) */
|
||||
export type { HttpRequestData };
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user