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

298 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}