/** * 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 ( setDialog(null)} width="620px" footer={ 仅本机 · 限流 20 QPS / 60 QPM(超限 429) 下线 / 作废 试一试 {busy ? "发布中…" : "发布 / 更新"} } > Mock 默认只在本机 127.0.0.1:8787 提供联调,不上官网/文档站。 {useCloud ? "当前走云端空间隔离。" : "当前本地空间,无需登录即可发布。"} 本机联调地址 e.currentTarget.select()} /> { if (!mockUrl) return; void navigator.clipboard.writeText(mockUrl); setResult({ ok: true, message: "已复制本机联调地址" }); }} > 复制 {method} {urlPath} · 非方法可用 ?_method={method} 测试 · 发布包含当前集合全部 HTTP 接口 有效期(可选 TTL) setTtlSeconds(Number(e.target.value) || 0)}> 不过期 1 小时 6 小时 24 小时 7 天 到期后旧链接自动失效;作废可立刻让旧 token 4xx。 状态码 setStatus(Number(e.target.value) || 200)} /> 延迟 ms setDelayMs(Number(e.target.value) || 0)} /> 响应模板预览 {template.slice(0, 2000)} 模板取自当前请求的 JSON Body(开启智能 Mock 时自动烘焙 @ 表达式);改 Body 后重新发布即可生效。 {result && ( {result.message} )} ); } 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(); 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; }
127.0.0.1:8787
?_method={method}
{template.slice(0, 2000)}