- icon.svg 取自 opsx-icon-options/api/B-brackets.svg,全套光栅重生成: icon.icns(iconutil)、icon.ico(PNG-in-ICO 6 尺寸)、16-1024 PNG、Windows Square*/StoreLogo - 替换 Tauri 脚手架默认五角星图标;GUI 重启实测 Dock 图标已生效 - 服务全量重启:server 8787(官网/文档 200)+ web 5173 + 桌面 GUI
298 lines
11 KiB
TypeScript
298 lines
11 KiB
TypeScript
/**
|
||
* Mock 本机联调弹窗:发布到本机 Mock 服务(/mock/{token}{路径})。
|
||
* 默认不绑云端账号;响应体支持 @表达式按次随机生成。
|
||
*/
|
||
import { smartFillJson } from "@opsxapi/core";
|
||
import type { HttpApiNode, MockSettings } from "@opsxapi/core";
|
||
import { useEffect, useState } from "react";
|
||
import { publishLocalMock, publishMock, revokeLocalMock, revokeMock } from "../../account";
|
||
import { useWorkspace } from "../../workspaceStore";
|
||
import { Modal, cx } from "../common";
|
||
import { Icon } from "../icons";
|
||
|
||
const LOCAL_TOKEN_KEY = "opsxapi.localMockToken.v1";
|
||
|
||
function loadLocalToken(): string {
|
||
try {
|
||
return localStorage.getItem(LOCAL_TOKEN_KEY) || "";
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function saveLocalToken(token: string) {
|
||
try {
|
||
if (!token) localStorage.removeItem(LOCAL_TOKEN_KEY);
|
||
else localStorage.setItem(LOCAL_TOKEN_KEY, token);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
export function MockLinkDialog({ node }: { node: HttpApiNode }) {
|
||
const account = useWorkspace((s) => s.account);
|
||
const settings = useWorkspace((s) => s.settings);
|
||
const collections = useWorkspace((s) => s.collections);
|
||
const setDialog = useWorkspace((s) => s.setDialog);
|
||
|
||
const space = account.spaces.find((sp) => sp.id === account.activeSpaceId) ?? null;
|
||
const useCloud = !!(account.token && space);
|
||
const [localToken, setLocalToken] = useState(loadLocalToken);
|
||
const [status, setStatus] = useState(200);
|
||
const [delayMs, setDelayMs] = useState(0);
|
||
const [busy, setBusy] = useState(false);
|
||
const [ttlSeconds, setTtlSeconds] = useState(0);
|
||
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||
|
||
const urlPath = extractPath(node.data.url);
|
||
const method = node.data.method;
|
||
|
||
let template = "{}";
|
||
if (node.data.bodyMode === "json" && node.data.bodyRaw.trim()) {
|
||
template = node.data.bodyRaw;
|
||
if (settings.mock.enabled && settings.mock.smart) {
|
||
const filled = smartFillJson(template, settings.mock);
|
||
if (filled.filled > 0) template = filled.text;
|
||
}
|
||
}
|
||
|
||
const mockSeg = useCloud
|
||
? space?.mockToken ?? (space ? String(space.id) : "")
|
||
: localToken;
|
||
const mockUrl = mockSeg ? `${location.origin.replace(/:\d+$/, ":8787")}/mock/${mockSeg}${urlPath}` : "";
|
||
|
||
useEffect(() => {
|
||
setLocalToken(loadLocalToken());
|
||
}, []);
|
||
|
||
const publish = async () => {
|
||
setBusy(true);
|
||
setResult(null);
|
||
try {
|
||
const endpoints = collectEndpoints(collections, settings);
|
||
const mine = endpoints.find((e) => e.method === method && e.path === urlPath);
|
||
if (mine) {
|
||
mine.status = status;
|
||
mine.delayMs = delayMs;
|
||
}
|
||
if (useCloud && account.token && space) {
|
||
await publishMock(account.token, space.id, endpoints, ttlSeconds);
|
||
setResult({ ok: true, message: `已发布 ${endpoints.length} 个接口到本机联调(云端空间)` });
|
||
} else {
|
||
const r = await publishLocalMock(endpoints, ttlSeconds);
|
||
saveLocalToken(r.token);
|
||
setLocalToken(r.token);
|
||
setResult({ ok: true, message: `已发布 ${endpoints.length} 个接口到本机联调(本地空间)` });
|
||
}
|
||
} catch (e) {
|
||
setResult({ ok: false, message: e instanceof Error ? e.message : String(e) });
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const preview = async () => {
|
||
if (!mockUrl) {
|
||
setResult({ ok: false, message: "请先发布,生成本机联调地址" });
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
setResult(null);
|
||
try {
|
||
const res = await fetch(mockUrl, { method: "GET" });
|
||
const text = await res.text();
|
||
setResult({ ok: res.ok, message: `HTTP ${res.status} · ${text.slice(0, 400)}` });
|
||
} catch (e) {
|
||
setResult({ ok: false, message: e instanceof Error ? e.message : String(e) });
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const revoke = async () => {
|
||
setBusy(true);
|
||
setResult(null);
|
||
try {
|
||
if (useCloud && account.token && space) {
|
||
await revokeMock(account.token, space.id);
|
||
} else {
|
||
await revokeLocalMock();
|
||
saveLocalToken("");
|
||
setLocalToken("");
|
||
}
|
||
setResult({ ok: true, message: "已作废:旧链接立即失效,需重新发布才可用" });
|
||
} catch (e) {
|
||
setResult({ ok: false, message: e instanceof Error ? e.message : String(e) });
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Modal
|
||
title="本机联调地址"
|
||
onClose={() => setDialog(null)}
|
||
width="620px"
|
||
footer={
|
||
<div className="flex w-full items-center justify-between">
|
||
<span className="ops-faint text-[11px]">仅本机 · 限流 20 QPS / 60 QPM(超限 429)</span>
|
||
<div className="flex gap-2">
|
||
<button className="ops-btn ops-btn-ghost ops-danger" onClick={revoke} disabled={busy || !mockUrl} title="作废当前 token,旧链接立刻 4xx">
|
||
下线 / 作废
|
||
</button>
|
||
<button className="ops-btn ops-btn-secondary" onClick={preview} disabled={busy || !mockUrl}>
|
||
<Icon name="play" size={13} /> 试一试
|
||
</button>
|
||
<button className="ops-btn ops-btn-primary" onClick={publish} disabled={busy}>
|
||
<Icon name="upload" size={13} /> {busy ? "发布中…" : "发布 / 更新"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="space-y-3.5">
|
||
<div className="ops-hint">
|
||
<Icon name="info" size={14} />
|
||
<span>
|
||
Mock 默认只在本机 <code>127.0.0.1:8787</code> 提供联调,不上官网/文档站。
|
||
{useCloud ? "当前走云端空间隔离。" : "当前本地空间,无需登录即可发布。"}
|
||
</span>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="ops-label">本机联调地址</label>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
className="ops-input flex-1 font-mono !text-[12px]"
|
||
readOnly
|
||
value={mockUrl || "发布后显示本机地址"}
|
||
onFocus={(e) => e.currentTarget.select()}
|
||
/>
|
||
<button
|
||
className="ops-btn ops-btn-secondary"
|
||
disabled={!mockUrl}
|
||
onClick={() => {
|
||
if (!mockUrl) return;
|
||
void navigator.clipboard.writeText(mockUrl);
|
||
setResult({ ok: true, message: "已复制本机联调地址" });
|
||
}}
|
||
>
|
||
复制
|
||
</button>
|
||
</div>
|
||
<div className="ops-faint mt-1 text-[11px]">
|
||
{method} {urlPath} · 非方法可用 <code>?_method={method}</code> 测试 · 发布包含当前集合全部 HTTP 接口
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<label className="block space-y-1">
|
||
<span className="ops-label">有效期(可选 TTL)</span>
|
||
<select className="ops-input w-full" value={ttlSeconds} onChange={(e) => setTtlSeconds(Number(e.target.value) || 0)}>
|
||
<option value={0}>不过期</option>
|
||
<option value={3600}>1 小时</option>
|
||
<option value={21600}>6 小时</option>
|
||
<option value={86400}>24 小时</option>
|
||
<option value={604800}>7 天</option>
|
||
</select>
|
||
<div className="ops-faint text-[11px]">到期后旧链接自动失效;作废可立刻让旧 token 4xx。</div>
|
||
</label>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<label className="block space-y-1">
|
||
<span className="ops-label">状态码</span>
|
||
<input
|
||
className="ops-input w-full"
|
||
type="number"
|
||
value={status}
|
||
onChange={(e) => setStatus(Number(e.target.value) || 200)}
|
||
/>
|
||
</label>
|
||
<label className="block space-y-1">
|
||
<span className="ops-label">延迟 ms</span>
|
||
<input
|
||
className="ops-input w-full"
|
||
type="number"
|
||
value={delayMs}
|
||
onChange={(e) => setDelayMs(Number(e.target.value) || 0)}
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="ops-label mb-1">响应模板预览</div>
|
||
<pre className={cx("ops-code max-h-40 overflow-auto text-[11px]")}>{template.slice(0, 2000)}</pre>
|
||
<div className="ops-faint mt-1 text-[11px]">
|
||
模板取自当前请求的 JSON Body(开启智能 Mock 时自动烘焙 @ 表达式);改 Body 后重新发布即可生效。
|
||
</div>
|
||
</div>
|
||
|
||
{result && (
|
||
<div className={cx("rounded border px-2.5 py-1.5 text-[12px]", result.ok ? "ops-hint" : "")} style={!result.ok ? { color: "var(--ops-danger)" } : undefined}>
|
||
{result.message}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
function extractPath(url: string): string {
|
||
try {
|
||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||
return new URL(url).pathname || "/";
|
||
}
|
||
} catch {
|
||
/* fallthrough */
|
||
}
|
||
const q = url.indexOf("?");
|
||
const path = (q >= 0 ? url.slice(0, q) : url).trim() || "/";
|
||
return path.startsWith("/") ? path : `/${path}`;
|
||
}
|
||
|
||
function collectEndpoints(
|
||
collections: { children: unknown[] }[],
|
||
settings: { mock: MockSettings },
|
||
): { method: string; path: string; status: number; contentType: string; body: string; delayMs: number }[] {
|
||
const out: { method: string; path: string; status: number; contentType: string; body: string; delayMs: number }[] = [];
|
||
const seen = new Map<string, number>();
|
||
const walk = (nodes: unknown[]): void => {
|
||
for (const n of nodes as {
|
||
type?: string;
|
||
kind?: string;
|
||
children?: unknown[];
|
||
data?: { url?: string; method?: string; bodyMode?: string; bodyRaw?: string };
|
||
}[]) {
|
||
if (n.type === "folder") {
|
||
walk(n.children ?? []);
|
||
} else if (n.type === "request" && n.kind === "http" && n.data) {
|
||
let body = "{}";
|
||
if (n.data.bodyMode === "json" && n.data.bodyRaw?.trim()) {
|
||
body = n.data.bodyRaw;
|
||
if (settings.mock.enabled && settings.mock.smart) {
|
||
const filled = smartFillJson(body, settings.mock);
|
||
if (filled.filled > 0) body = filled.text;
|
||
}
|
||
}
|
||
const ep = {
|
||
method: n.data.method ?? "GET",
|
||
path: extractPath(n.data.url ?? ""),
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body,
|
||
delayMs: 0,
|
||
};
|
||
const key = `${ep.method} ${ep.path}`;
|
||
const prev = seen.get(key);
|
||
if (prev !== undefined) out[prev] = ep;
|
||
else {
|
||
seen.set(key, out.length);
|
||
out.push(ep);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
for (const c of collections) walk(c.children);
|
||
return out;
|
||
}
|