桌面应用图标替换为 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:
logan committed 2026-09-20 00:14:32 +08:00
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) {