设置中心与 Mock 数据功能

- 顶栏:移除 tauri-native 传输徽章;⋯ 更多菜单重做(设置/Mock/环境/主题/文档/官网/重置)
- 设置弹窗重写:左侧导航(外观/网络/Mock 数据/数据管理/关于)+ 分组卡片 + 开关控件
- Mock 引擎(packages/core/src/mock.ts):Mock.js 常用语法子集 + 智能字段匹配
  - 61 条默认规则(通配符/精确/正则 × string/number/integer/boolean)
  - 发送链路接入:Query/JSON Body/urlencoded/form-data/GraphQL Variables
  - Body 工具栏:智能填充(生成可编辑 @表达式模板)+ Mock 预览
  - 设置页规则表 CRUD + 表达式速查表即时预览
- 文档新增「Mock 数据」页(/docs/mock),Manifest 顺序调整
- 新增 16 个 Mock 单测(总 37 前端用例全绿)
This commit is contained in:
logan committed 2026-09-18 01:35:54 +08:00
1 parent 6307a017a3
commit 9a019408ab
18 files changed
+1684 -104

No files matched your search

+80 -9
View File
@@ -1,10 +1,9 @@
/**
* 顶栏:logo / 新建 / 导入导出 / 环境 / 主题 / 设置。
* 顶栏:logo / 新建 / 导入导出 / 环境 / 主题 / 文档 / 设置 / 更多。
*/
import { detectTransport } from "@opsxapi/transport";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { useWorkspace } from "../workspaceStore";
import { ContextMenu, cx, type MenuItem } from "./common";
import { ContextMenu, type MenuItem } from "./common";
import { EnvBadge } from "./EnvBadge";
import { Icon, type IconName } from "./icons";
import { Logo } from "./Logo";
@@ -31,7 +30,7 @@ export function TopBar() {
const [newMenu, setNewMenu] = useState<{ x: number; y: number } | null>(null);
const [exportMenu, setExportMenu] = useState<{ x: number; y: number } | null>(null);
const transportKind = useMemo(() => detectTransport({ ginBaseUrl: "" }).kind, []);
const [moreMenu, setMoreMenu] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
document.title = "OpsxAPI · 接口工作台";
@@ -90,6 +89,63 @@ export function TopBar() {
},
];
const moreItems: MenuItem[] = [
{
label: "设置…",
icon: <Icon name="settings" size={14} />,
onClick: () => setDialog({ type: "settings" }),
},
{
label: "Mock 数据…",
icon: <Icon name="flask-conical" size={14} />,
onClick: () => setDialog({ type: "settings", tab: "mock" }),
},
{
label: "环境管理…",
icon: <Icon name="arrow-left-right" size={14} />,
onClick: () => setDialog({ type: "environments" }),
},
{
label: settings.theme === "dark" ? "切换到浅色主题" : "切换到深色主题",
icon: <Icon name={settings.theme === "dark" ? "sun" : "moon"} size={14} />,
onClick: () => setTheme(settings.theme === "dark" ? "light" : "dark"),
},
{ type: "sep" as const },
{
label: (
<span className="flex items-center gap-2">
用户文档
<Icon name="external-link" size={11} className="ops-faint" />
</span>
),
icon: <Icon name="book-open" size={14} />,
onClick: () => window.open("/docs", "_blank", "noopener,noreferrer"),
},
{
label: (
<span className="flex items-center gap-2">
官网与价格
<Icon name="external-link" size={11} className="ops-faint" />
</span>
),
icon: <Icon name="globe" size={14} />,
onClick: () => window.open("/home", "_blank", "noopener,noreferrer"),
},
{ type: "sep" as const },
{
label: "重置工作区…",
icon: <Icon name="trash" size={14} />,
danger: true,
onClick: () =>
setDialog({
type: "confirm",
title: "重置工作区",
message: "将恢复到初始示例数据,当前集合 / 环境 / 历史会被覆盖(建议先在「导出」中生成全量备份)。确定继续吗?",
action: () => useWorkspace.getState().resetWorkspace(),
}),
},
];
return (
<header
className="ops-panel ops-divider flex h-12 shrink-0 items-center justify-between gap-3 px-4"
@@ -179,14 +235,29 @@ export function TopBar() {
>
<Icon name="book-open" size={14} /> 文档
</a>
<button className="ops-btn ops-btn-ghost" onClick={() => setDialog({ type: "settings" })} title="设置">
<button
className="ops-btn ops-btn-ghost"
onClick={() => setDialog({ type: "settings" })}
title="设置"
>
<Icon name="settings" size={15} />
</button>
<button
className="ops-btn ops-btn-ghost"
title="更多"
onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setMoreMenu({ x: rect.right, y: rect.bottom + 4 });
}}
>
<Icon name="ellipsis" size={16} />
</button>
<span className={cx("ops-chip")} style={{ padding: "1px 8px", fontSize: 10 }} title="请求通道">
{transportKind}
</span>
</div>
<ContextMenu
menu={moreMenu ? { x: moreMenu.x, y: moreMenu.y, items: moreItems } : null}
onClose={() => setMoreMenu(null)}
/>
<ContextMenu
menu={newMenu ? { x: newMenu.x, y: newMenu.y, items: newItems } : null}
onClose={() => setNewMenu(null)}
@@ -2,12 +2,13 @@
* 工作区对话框:新建项 / 重命名 / 环境管理 / 设置。
*/
import type { RequestKind } from "@opsxapi/core";
import { KIND_LABELS, walk } from "@opsxapi/core";
import { generateMock, KIND_LABELS, MOCK_CHEATSHEET, walk } from "@opsxapi/core";
import { useState } from "react";
import { useWorkspace } from "../../workspaceStore";
import { KVEditor, Modal, cx } from "../common";
import { EnvBadge } from "../EnvBadge";
import { Icon, type IconName } from "../icons";
import { Logo } from "../Logo";
const KIND_MENU: { kind: RequestKind | "folder"; icon: IconName; desc: string }[] = [
{ kind: "http", icon: "globe", desc: "REST 接口调试" },
@@ -364,67 +365,448 @@ export function EnvironmentsDialog() {
);
}
export function SettingsDialog() {
/* ---------------- 设置弹窗(左侧导航 + 分区) ---------------- */
type SettingsTab = "appearance" | "network" | "mock" | "data" | "about";
const SETTINGS_TABS: { id: SettingsTab; label: string; desc: string; icon: IconName }[] = [
{ id: "appearance", label: "外观", desc: "主题与界面", icon: "palette" },
{ id: "network", label: "网络", desc: "请求与超时", icon: "network" },
{ id: "mock", label: "Mock 数据", desc: "智能匹配规则", icon: "flask-conical" },
{ id: "data", label: "数据管理", desc: "工作区与备份", icon: "database" },
{ id: "about", label: "关于", desc: "版本与链接", icon: "info" },
];
function SettingRow({
label,
desc,
children,
}: {
label: string;
desc?: React.ReactNode;
children?: React.ReactNode;
}) {
return (
<div className="set-row">
<div className="min-w-0 flex-1">
<div className="text-[13px] font-medium">{label}</div>
{desc ? <div className="ops-faint mt-0.5 text-[11px] leading-5">{desc}</div> : null}
</div>
<div className="set-row-control">{children}</div>
</div>
);
}
function SettingGroup({ title, children }: { title?: string; children: React.ReactNode }) {
return (
<section className="set-group">
{title ? <div className="set-group-title">{title}</div> : null}
{children}
</section>
);
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
className={cx("set-toggle", checked && "on")}
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
>
<span className="set-toggle-dot" />
</button>
);
}
export function SettingsDialog({ tab }: { tab?: SettingsTab }) {
const settings = useWorkspace((s) => s.settings);
const environments = useWorkspace((s) => s.environments);
const collections = useWorkspace((s) => s.collections);
const setTheme = useWorkspace((s) => s.setTheme);
const setTimeoutMs = useWorkspace((s) => s.setTimeoutMs);
const setMockOption = useWorkspace((s) => s.setMockOption);
const updateMockRule = useWorkspace((s) => s.updateMockRule);
const addMockRule = useWorkspace((s) => s.addMockRule);
const removeMockRule = useWorkspace((s) => s.removeMockRule);
const resetMockRules = useWorkspace((s) => s.resetMockRules);
const resetWorkspace = useWorkspace((s) => s.resetWorkspace);
const setDialog = useWorkspace((s) => s.setDialog);
const [active, setActive] = useState<SettingsTab>(tab ?? "appearance");
const [ruleSearch, setRuleSearch] = useState("");
const [previewRule, setPreviewRule] = useState("");
const mock = settings.mock;
const filteredRules = mock.rules.filter((r) => {
const q = ruleSearch.trim().toLowerCase();
if (!q) return true;
return (
r.pattern.toLowerCase().includes(q) ||
r.rule.toLowerCase().includes(q) ||
(r.note ?? "").toLowerCase().includes(q)
);
});
const requestCount = collections.reduce(
(acc, c) => acc + (c.children.filter((n) => n.type === "request").length ? 1 : 0),
0,
);
let totalRequests = 0;
for (const c of collections) {
walk(c.children, (n) => {
if (n.type === "request") totalRequests++;
});
}
void requestCount;
const currentMeta = SETTINGS_TABS.find((t) => t.id === active)!;
return (
<Modal
title="设置"
onClose={() => setDialog(null)}
width="860px"
footer={
<button className="ops-btn ops-btn-primary" onClick={() => setDialog(null)}>
完成
</button>
<div className="flex w-full items-center justify-between">
<span className="ops-faint text-[11px]">修改即时生效并自动保存</span>
<button className="ops-btn ops-btn-primary" onClick={() => setDialog(null)}>
完成
</button>
</div>
}
>
<div className="max-w-md space-y-5">
<div>
<label className="ops-label">主题</label>
<div className="flex gap-1.5">
{(["dark", "light"] as const).map((t) => (
<button
key={t}
className={cx("ops-chip", settings.theme === t && "ops-chip-active")}
onClick={() => setTheme(t)}
>
{t === "dark" ? "🌙 深色" : "☀️ 浅色"}
</button>
))}
</div>
</div>
<div className="set-dialog">
<aside className="set-side">
<div className="set-side-head">设置</div>
{SETTINGS_TABS.map((t) => (
<button
key={t.id}
className={cx("set-side-item", active === t.id && "active")}
onClick={() => setActive(t.id)}
>
<span className={cx("set-side-icon", active === t.id && "active")}>
<Icon name={t.icon} size={15} />
</span>
<span className="min-w-0 flex-1 text-left">
<span className="block truncate text-[13px] font-medium leading-5">{t.label}</span>
<span className="ops-faint block truncate text-[11px] leading-4">{t.desc}</span>
</span>
</button>
))}
</aside>
<div>
<label className="ops-label">请求超时(毫秒)</label>
<input
type="number"
className="ops-input w-40"
value={settings.timeoutMs}
min={1000}
step={1000}
onChange={(e) => setTimeoutMs(Number(e.target.value) || 30000)}
/>
</div>
<div>
<label className="ops-label">数据</label>
<div className="ops-faint mb-2 text-[11px] leading-5">
所有集合与环境保存在浏览器 localStorage;全量备份请在「导出 → 全量备份」中生成。
<div className="set-main">
<div className="set-main-head">
<div className="text-[16px] font-semibold">{currentMeta.label}</div>
<div className="ops-faint mt-0.5 text-[12px]">{currentMeta.desc}</div>
</div>
<div className="set-main-body">
{active === "appearance" && (
<>
<SettingGroup title="主题">
<SettingRow label="界面主题" desc="深色适合长时间调试,浅色适合投屏演示">
<div className="flex gap-1.5">
{(["dark", "light"] as const).map((t) => (
<button
key={t}
className={cx(
"ops-btn ops-btn-secondary",
settings.theme === t && "ops-chip-active",
)}
onClick={() => setTheme(t)}
>
<Icon name={t === "dark" ? "moon" : "sun"} size={13} />
{t === "dark" ? "深色" : "浅色"}
</button>
))}
</div>
</SettingRow>
</SettingGroup>
<SettingGroup title="界面">
<SettingRow
label="环境徽标颜色"
desc="每个环境按名称生成固定色块,便于在顶栏快速识别"
>
<div className="flex items-center gap-1">
{environments.slice(0, 6).map((e) => (
<EnvBadge key={e.id} envId={e.id} name={e.name} size={22} />
))}
</div>
</SettingRow>
</SettingGroup>
</>
)}
{active === "network" && (
<>
<SettingGroup title="请求">
<SettingRow label="请求超时(毫秒)" desc="HTTP / GraphQL 请求的超时上限,超时自动中断">
<input
type="number"
className="ops-input ops-input-sm w-32"
value={settings.timeoutMs}
min={1000}
step={1000}
onChange={(e) => setTimeoutMs(Number(e.target.value) || 30000)}
/>
</SettingRow>
<SettingRow label="代理转发" desc="浏览器直连受限时,桌面版自动经本地服务转发请求">
<span className="ops-chip" style={{ padding: "2px 10px" }}>自动</span>
</SettingRow>
</SettingGroup>
<SettingGroup title="实时连接">
<SettingRow
label="WebSocket / SSE / Socket.IO / MQTT"
desc="关闭标签页自动断开连接;会话不落盘,刷新后重新连接"
>
<span className="ops-faint text-[11px]">自动管理</span>
</SettingRow>
</SettingGroup>
</>
)}
{active === "mock" && (
<>
<SettingGroup>
<SettingRow
label="启用 Mock 数据"
desc="发送时解析参数与 Body 中的 @mock 表达式(如 @cname、@integer(1,100))"
>
<Toggle checked={mock.enabled} onChange={(v) => setMockOption("enabled", v)} />
</SettingRow>
<SettingRow
label="智能 Mock 匹配"
desc="JSON Body 中字段值未写 Mock 规则时,按字段名自动匹配下方规则生成"
>
<Toggle checked={mock.smart} onChange={(v) => setMockOption("smart", v)} />
</SettingRow>
</SettingGroup>
<SettingGroup title="智能匹配规则">
<div className="ops-hint">
<Icon name="info" size={14} />
<span>
按从上到下的顺序匹配,先命中先用;<b>* 通配任意字符</b>,类型需与 JSON 字段一致。
<a
href="/docs/mock"
target="_blank"
rel="noopener noreferrer"
className="ops-link ml-1"
>
语法速查 →
</a>
</span>
</div>
<div className="mb-2 flex items-center gap-2">
<input
className="ops-input ops-input-sm w-48"
placeholder="搜索规则 / 表达式…"
value={ruleSearch}
onChange={(e) => setRuleSearch(e.target.value)}
/>
<span className="ops-faint text-[11px]">{mock.rules.length} 条</span>
<div className="ml-auto flex gap-1.5">
<button className="ops-btn ops-btn-secondary" onClick={() => addMockRule()}>
<Icon name="plus" size={13} /> 新增规则
</button>
<button
className="ops-btn ops-btn-secondary"
title="恢复内置默认规则(会覆盖自定义规则)"
onClick={() => resetMockRules()}
>
<Icon name="eraser" size={13} /> 恢复默认
</button>
</div>
</div>
<div className="mock-rules">
<div className="mock-rule-head">
<span>数据类型</span>
<span>匹配方式</span>
<span>匹配规则</span>
<span>Mock 表达式</span>
<span>备注</span>
<span>启用</span>
<span />
</div>
{filteredRules.map((r) => (
<div key={r.id} className="mock-rule-row">
<select
className="ops-input ops-input-sm"
value={r.type}
onChange={(e) => updateMockRule(r.id, { type: e.target.value as never })}
>
<option value="string">string</option>
<option value="number">number</option>
<option value="integer">integer</option>
<option value="boolean">boolean</option>
</select>
<select
className="ops-input ops-input-sm"
value={r.match}
onChange={(e) => updateMockRule(r.id, { match: e.target.value as never })}
>
<option value="wildcard">通配符</option>
<option value="exact">完全匹配</option>
<option value="regex">正则</option>
</select>
<input
className="ops-input ops-input-sm mock-rule-pattern"
value={r.pattern}
placeholder="*name*"
onChange={(e) => updateMockRule(r.id, { pattern: e.target.value })}
/>
<input
className="ops-input ops-input-sm mock-rule-expr"
value={r.rule}
placeholder="@cname"
onChange={(e) => updateMockRule(r.id, { rule: e.target.value })}
/>
<input
className="ops-input ops-input-sm mock-rule-note"
value={r.note ?? ""}
placeholder="备注"
onChange={(e) => updateMockRule(r.id, { note: e.target.value })}
/>
<Toggle
checked={r.enabled}
onChange={(v) => updateMockRule(r.id, { enabled: v })}
/>
<button
className="ops-btn ops-btn-ghost ops-danger px-1.5"
title="删除规则"
onClick={() => removeMockRule(r.id)}
>
<Icon name="trash" size={13} />
</button>
</div>
))}
{filteredRules.length === 0 && (
<div className="ops-faint py-6 text-center text-xs">没有匹配的规则</div>
)}
</div>
</SettingGroup>
<SettingGroup title="表达式预览">
<div className="flex items-center gap-2">
<input
className="ops-input ops-input-sm w-56"
placeholder="输入表达式预览,如 @email"
value={previewRule}
onChange={(e) => setPreviewRule(e.target.value)}
/>
{previewRule.trim().startsWith("@") && (
<span className="mock-preview-value">{generateMock(previewRule.trim())}</span>
)}
</div>
<div className="mock-cheatsheet">
{MOCK_CHEATSHEET.map((c) => (
<button
key={c.expr}
className="mock-cheat-item"
title="点击试一试"
onClick={() => setPreviewRule(c.expr)}
>
<code>{c.expr}</code>
<span className="ops-faint">{c.desc}</span>
<span className="mock-cheat-gen">{generateMock(c.expr)}</span>
</button>
))}
</div>
</SettingGroup>
</>
)}
{active === "data" && (
<>
<SettingGroup title="工作区数据">
<SettingRow
label="本地存储"
desc="集合、环境与设置保存在本地 localStorage,不经过任何服务器"
>
<span className="ops-faint text-[11px]">浏览器本地</span>
</SettingRow>
<SettingRow label="数据概览">
<span className="ops-faint text-[11px]">
{collections.length} 个集合 · {totalRequests} 个接口 · {environments.length} 个环境
</span>
</SettingRow>
</SettingGroup>
<SettingGroup title="备份与恢复">
<SettingRow
label="导出全量备份"
desc="包含全部集合、环境与设置,可在其他设备导入恢复"
>
<button
className="ops-btn ops-btn-secondary"
onClick={() => setDialog({ type: "export", collectionId: collections[0]?.id ?? "" })}
>
<Icon name="download" size={13} /> 打开导出
</button>
</SettingRow>
<SettingRow
label="重置工作区"
desc="恢复到初始示例数据,当前数据不可恢复,建议先导出备份"
>
<button
className="ops-btn ops-btn-danger"
onClick={() => {
if (confirm("确定重置工作区?将恢复到初始示例数据(建议先导出备份)")) {
resetWorkspace();
setDialog(null);
}
}}
>
<Icon name="trash" size={13} /> 重置
</button>
</SettingRow>
</SettingGroup>
</>
)}
{active === "about" && (
<>
<SettingGroup>
<div className="flex items-center gap-4 py-1">
<Logo size={52} />
<div>
<div className="text-[15px] font-semibold">OpsxAPI</div>
<div className="ops-faint mt-0.5 text-[12px]">专业接口调试工作台</div>
<div className="ops-faint mt-1 text-[11px]">
HTTP · GraphQL · WebSocket · SSE · Socket.IO · MQTT
</div>
</div>
</div>
</SettingGroup>
<SettingGroup title="资源链接">
<SettingRow label="用户文档" desc="功能说明、协议调试与最佳实践">
<a className="ops-btn ops-btn-secondary" href="/docs" target="_blank" rel="noopener noreferrer">
<Icon name="book-open" size={13} /> 打开
</a>
</SettingRow>
<SettingRow label="官网与价格" desc="产品介绍、版本对比与购买方案">
<a className="ops-btn ops-btn-secondary" href="/home" target="_blank" rel="noopener noreferrer">
<Icon name="external-link" size={13} /> 打开
</a>
</SettingRow>
<SettingRow label="Mock 语法速查" desc="内置 Mock.js 风格表达式与智能匹配说明">
<a className="ops-btn ops-btn-secondary" href="/docs/mock" target="_blank" rel="noopener noreferrer">
<Icon name="flask-conical" size={13} /> 查看
</a>
</SettingRow>
</SettingGroup>
<SettingGroup title="许可">
<SettingRow label="图标" desc="界面图标源自 lucide(ISC License)">
<a className="ops-link text-[11px]" href="https://lucide.dev" target="_blank" rel="noopener noreferrer">
lucide.dev
</a>
</SettingRow>
</SettingGroup>
</>
)}
</div>
<button
className="ops-btn ops-btn-danger"
onClick={() => {
if (confirm("确定重置工作区?将恢复到初始示例数据(当前数据不可恢复,建议先导出备份)")) {
resetWorkspace();
setDialog(null);
}
}}
>
<Icon name="trash" size={13} /> 重置工作区
</button>
</div>
</div>
</Modal>
@@ -3,6 +3,8 @@
*/
import type { HttpApiNode, HttpRequestData, KVRow } from "@opsxapi/core";
import {
applyMockToJson,
applyMockToValue,
BODY_MODE_LABELS,
RAW_BODY_MODES,
REQUEST_METHODS,
@@ -10,7 +12,7 @@ import {
generateCode,
type CodeTarget,
} from "@opsxapi/core";
import { replaceVariables } from "@opsxapi/core";
import { replaceVariables, smartFillJson } from "@opsxapi/core";
import { useState } from "react";
import { sendHttpRequest } from "../../send";
import { activeEnvMap, useWorkspace } from "../../workspaceStore";
@@ -252,6 +254,29 @@ function BodyEditor({
patch: (p: Partial<HttpRequestData>) => void;
}) {
const mode = data.bodyMode as BodyTab;
const mock = useWorkspace((s) => s.settings.mock);
const [mockPreview, setMockPreview] = useState<string | null>(null);
const smartFill = () => {
const { text, filled } = smartFillJson(data.bodyRaw, mock);
if (filled === 0) {
alert("未命中任何智能规则:请检查 JSON 字段名,或到「设置 → Mock 数据」调整规则");
return;
}
patch({ bodyRaw: text });
};
const previewMock = () => {
const raw = RAW_BODY_MODES.includes(mode) ? data.bodyRaw : "";
if (mode === "json") setMockPreview(applyMockToJson(raw, mock));
else if (mode === "urlencoded" || mode === "form-data") {
const lines = data.bodyForm
.filter((r) => r.enabled && r.key.trim())
.map((r) => `${r.key}: ${applyMockToValue(r.value, mock, r.key)}`);
setMockPreview(lines.join("\n") || "(空)");
} else setMockPreview(applyMockToValue(raw, mock) || "(空)");
};
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-1.5">
@@ -278,24 +303,47 @@ function BodyEditor({
格式化
</button>
)}
{mode !== "none" && (
<div className="ml-auto flex items-center gap-1.5">
<button
className="ops-btn ops-btn-ghost"
title="按智能规则把字段值替换为 @mock 表达式(可再编辑)"
onClick={smartFill}
disabled={!mock.enabled && !mock.smart}
>
<Icon name="wand-sparkles" size={13} /> 智能填充
</button>
<button className="ops-btn ops-btn-ghost" title="预览发送时实际生成的 Mock 数据" onClick={previewMock}>
<Icon name="flask-conical" size={13} /> Mock 预览
</button>
</div>
)}
</div>
{mode === "none" && <div className="ops-faint py-6 text-center text-xs">该请求不携带请求体</div>}
{RAW_BODY_MODES.includes(mode) && (
<textarea
className="ops-code min-h-48 w-full resize-y font-mono"
spellCheck={false}
placeholder={
mode === "json"
? '{\n "key": "value"\n}'
: mode === "xml"
? "<root>\n <item>value</item>\n</root>"
: "原始文本内容"
}
value={data.bodyRaw}
onChange={(e) => patch({ bodyRaw: e.target.value })}
/>
<>
<textarea
className="ops-code min-h-48 w-full resize-y font-mono"
spellCheck={false}
placeholder={
mode === "json"
? '{\n "username": "@cname",\n "email": "@email",\n "age": "@integer(18, 60)"\n}'
: mode === "xml"
? "<root>\n <item>value</item>\n</root>"
: "原始文本内容(可用 @cname 等 Mock 表达式)"
}
value={data.bodyRaw}
onChange={(e) => patch({ bodyRaw: e.target.value })}
/>
{mock.enabled && (
<div className="ops-faint text-[11px]">
值以 <code className="ops-accent">@</code> 开头即按 Mock 表达式生成
{mock.smart && ";开启智能匹配后字段名命中规则也会自动生成"},发送前可用「Mock 预览」查看。
</div>
)}
</>
)}
{(mode === "urlencoded" || mode === "form-data") && (
@@ -303,13 +351,17 @@ function BodyEditor({
rows={data.bodyForm}
onChange={(rows) => patch({ bodyForm: rows })}
keyPlaceholder="字段名"
valuePlaceholder="值"
valuePlaceholder="值(可填 @cname 等 Mock 表达式)"
/>
)}
{mode === "form-data" && (
<div className="ops-faint text-[11px]">当前支持文本字段;文件上传请用 cURL / 代码生成。</div>
)}
{mockPreview !== null && (
<CodeViewModal title="Mock 数据预览" code={mockPreview} onClose={() => setMockPreview(null)} />
)}
</div>
);
}
+16
View File
@@ -5,6 +5,14 @@
import type { CSSProperties } from "react";
export type IconName =
| "palette"
| "network"
| "flask-conical"
| "wand-sparkles"
| "database"
| "monitor"
| "external-link"
| "sparkles"
| "plus"
| "download"
| "upload"
@@ -44,6 +52,14 @@ export type IconName =
| "info";
const ICONS: Record<IconName, string> = {
"palette": "<path d=\"M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z\" /> <circle cx=\"13.5\" cy=\"6.5\" r=\".5\" fill=\"currentColor\" /> <circle cx=\"17.5\" cy=\"10.5\" r=\".5\" fill=\"currentColor\" /> <circle cx=\"6.5\" cy=\"12.5\" r=\".5\" fill=\"currentColor\" /> <circle cx=\"8.5\" cy=\"7.5\" r=\".5\" fill=\"currentColor\" />",
"network": "<rect x=\"16\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\" /> <rect x=\"2\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\" /> <rect x=\"9\" y=\"2\" width=\"6\" height=\"6\" rx=\"1\" /> <path d=\"M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3\" /> <path d=\"M12 12V8\" />",
"flask-conical": "<path d=\"M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2\" /> <path d=\"M6.453 15h11.094\" /> <path d=\"M8.5 2h7\" />",
"wand-sparkles": "<path d=\"m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72\" /> <path d=\"m14 7 3 3\" /> <path d=\"M5 6v4\" /> <path d=\"M19 14v4\" /> <path d=\"M10 2v2\" /> <path d=\"M7 8H3\" /> <path d=\"M21 16h-4\" /> <path d=\"M11 3H9\" />",
"database": "<ellipse cx=\"12\" cy=\"5\" rx=\"9\" ry=\"3\" /> <path d=\"M3 5V19A9 3 0 0 0 21 19V5\" /> <path d=\"M3 12A9 3 0 0 0 21 12\" />",
"monitor": "<rect width=\"20\" height=\"14\" x=\"2\" y=\"3\" rx=\"2\" /> <line x1=\"8\" x2=\"16\" y1=\"21\" y2=\"21\" /> <line x1=\"12\" x2=\"12\" y1=\"17\" y2=\"21\" />",
"external-link": "<path d=\"M15 3h6v6\" /> <path d=\"M10 14 21 3\" /> <path d=\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\" />",
"sparkles": "<path d=\"M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z\" /> <path d=\"M20 2v4\" /> <path d=\"M22 4h-4\" /> <circle cx=\"4\" cy=\"20\" r=\"2\" />",
"plus": "<path d=\"M5 12h14\" /> <path d=\"M12 5v14\" />",
"download": "<path d=\"M12 15V3\" /> <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\" /> <path d=\"m7 10 5 5 5-5\" />",
"upload": "<path d=\"M12 3v12\" /> <path d=\"m17 8-5-5-5 5\" /> <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\" />",