设置中心与 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:
1 parent
6307a017a3
commit
9a019408ab
18 files changed
+1684
-104
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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\" />",
|
||||
|
||||
Reference in new issue
Block a user