桌面应用图标替换为 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
This commit is contained in:
1 parent
71a6d986a4
commit
39f4d5addc
161 files changed
+3289
-515
No files matched your search
@@ -1,15 +1,34 @@
|
||||
/**
|
||||
* Mock 链接弹窗:把当前接口发布到本地 Mock 服务(/mock/{空间ID}{路径}),
|
||||
* 发布后可复制 URL、浏览器试一试;响应体支持 @表达式按次随机生成。
|
||||
* Mock 本机联调弹窗:发布到本机 Mock 服务(/mock/{token}{路径})。
|
||||
* 默认不绑云端账号;响应体支持 @表达式按次随机生成。
|
||||
*/
|
||||
import { smartFillJson } from "@opsxapi/core";
|
||||
import type { HttpApiNode, MockSettings } from "@opsxapi/core";
|
||||
import { useState } from "react";
|
||||
import { publishMock } from "../../account";
|
||||
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);
|
||||
@@ -17,16 +36,17 @@ export function MockLinkDialog({ node }: { node: HttpApiNode }) {
|
||||
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);
|
||||
|
||||
// 路径:取请求 URL 的 path 部分(前置 URL 模式下即相对路径)
|
||||
const urlPath = extractPath(node.data.url);
|
||||
const method = node.data.method;
|
||||
|
||||
// 模板:JSON Body(智能填充烘焙 @表达式);非 JSON 用空对象
|
||||
let template = "{}";
|
||||
if (node.data.bodyMode === "json" && node.data.bodyRaw.trim()) {
|
||||
template = node.data.bodyRaw;
|
||||
@@ -36,24 +56,34 @@ export function MockLinkDialog({ node }: { node: HttpApiNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
const mockSeg = space?.mockToken ?? (space ? String(space.id) : "");
|
||||
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 () => {
|
||||
if (!account.token || !space) return;
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
try {
|
||||
// 全量发布:集合内全部 HTTP 接口(同空间共用一组 Mock 端点)
|
||||
const endpoints = collectEndpoints(collections, settings);
|
||||
// 当前接口应用弹窗选择的状态码与延迟
|
||||
const mine = endpoints.find((e) => e.method === method && e.path === urlPath);
|
||||
if (mine) {
|
||||
mine.status = status;
|
||||
mine.delayMs = delayMs;
|
||||
}
|
||||
await publishMock(account.token, space.id, endpoints);
|
||||
setResult({ ok: true, message: `已发布 ${endpoints.length} 个接口到 /mock/${space.id}` });
|
||||
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 {
|
||||
@@ -62,7 +92,10 @@ export function MockLinkDialog({ node }: { node: HttpApiNode }) {
|
||||
};
|
||||
|
||||
const preview = async () => {
|
||||
if (!mockUrl) return;
|
||||
if (!mockUrl) {
|
||||
setResult({ ok: false, message: "请先发布,生成本机联调地址" });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
try {
|
||||
@@ -76,19 +109,41 @@ export function MockLinkDialog({ node }: { node: HttpApiNode }) {
|
||||
}
|
||||
};
|
||||
|
||||
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="Mock 链接"
|
||||
title="本机联调地址"
|
||||
onClose={() => setDialog(null)}
|
||||
width="620px"
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="ops-faint text-[11px]">本地 Mock 服务 · 限流 20 QPS / 60 QPM(超限 429)</span>
|
||||
<span className="ops-faint text-[11px]">仅本机 · 限流 20 QPS / 60 QPM(超限 429)</span>
|
||||
<div className="flex gap-2">
|
||||
<button className="ops-btn ops-btn-secondary" onClick={preview} disabled={!space || busy}>
|
||||
<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={!space || busy}>
|
||||
<button className="ops-btn ops-btn-primary" onClick={publish} disabled={busy}>
|
||||
<Icon name="upload" size={13} /> {busy ? "发布中…" : "发布 / 更新"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -96,83 +151,84 @@ export function MockLinkDialog({ node }: { node: HttpApiNode }) {
|
||||
}
|
||||
>
|
||||
<div className="space-y-3.5">
|
||||
{!account.user ? (
|
||||
<div className="space-y-3">
|
||||
<div className="ops-hint">
|
||||
<Icon name="info" size={14} />
|
||||
<span>Mock 链接挂在云端空间下(按空间隔离),请先登录并切换到个人 / 团队空间。</span>
|
||||
</div>
|
||||
<button className="ops-btn ops-btn-secondary" onClick={() => setDialog({ type: "auth" })}>
|
||||
<Icon name="shield-check" size={13} /> 登录 / 注册
|
||||
<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>
|
||||
) : !space ? (
|
||||
<div className="ops-hint">
|
||||
<Icon name="info" size={14} />
|
||||
<span>当前在本地空间。请通过顶栏账号胶囊切换到「我的空间」或团队空间后再发布 Mock。</span>
|
||||
<div className="ops-faint mt-1 text-[11px]">
|
||||
{method} {urlPath} · 非方法可用 <code>?_method={method}</code> 测试 · 发布包含当前集合全部 HTTP 接口
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="ops-label">Mock 地址(前端联调直接请求它)</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 !px-2"
|
||||
title="复制"
|
||||
onClick={() => void navigator.clipboard.writeText(mockUrl)}
|
||||
>
|
||||
<Icon name="copy" size={13} />
|
||||
</button>
|
||||
{method === "GET" && (
|
||||
<a className="ops-btn ops-btn-ghost !px-2" href={mockUrl} target="_blank" rel="noopener noreferrer" title="浏览器打开">
|
||||
<Icon name="external-link" size={13} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="ops-faint mt-1 text-[11px]">
|
||||
{method} {urlPath} · 非方法可用 <code>?_method={method}</code> 测试 · 发布包含当前空间集合内全部 HTTP 接口
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="ops-label">响应状态码</label>
|
||||
<select className="ops-input" value={status} onChange={(e) => setStatus(Number(e.target.value))}>
|
||||
{[200, 201, 204, 400, 401, 403, 404, 429, 500].map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ops-label">延迟(毫秒,模拟慢接口)</label>
|
||||
<input
|
||||
className="ops-input"
|
||||
type="number"
|
||||
min={0}
|
||||
max={5000}
|
||||
step={100}
|
||||
value={delayMs}
|
||||
onChange={(e) => setDelayMs(Math.max(0, Math.min(5000, Number(e.target.value) || 0)))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ops-label">响应模板(@表达式每次请求随机生成)</label>
|
||||
<textarea className="ops-code min-h-36 w-full resize-y !text-[12px]" readOnly value={template} />
|
||||
<div className="ops-faint mt-1 text-[11px]">
|
||||
模板取自当前请求的 JSON Body(开启智能 Mock 时自动烘焙 @ 表达式);改 Body 后重新发布即可生效。
|
||||
</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-lg px-3 py-2 text-[12px] font-mono", result.ok ? "ops-success" : "ops-danger")}
|
||||
style={{ border: "1px solid var(--border)" }}>
|
||||
<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>
|
||||
)}
|
||||
@@ -182,11 +238,16 @@ export function MockLinkDialog({ node }: { node: HttpApiNode }) {
|
||||
}
|
||||
|
||||
function extractPath(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return "/";
|
||||
const m = /^https?:\/\/[^/]+(\/[^\s?#]*)?/.exec(trimmed);
|
||||
if (m) return m[1] || "/";
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
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(
|
||||
@@ -194,9 +255,14 @@ function collectEndpoints(
|
||||
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>(); // method+path 去重(保留靠后的目录项)
|
||||
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 } }[]) {
|
||||
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) {
|
||||
|
||||
Reference in new issue
Block a user