设置中心与 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

@@ -0,0 +1,72 @@
# Mock 数据
联调时后端接口没就绪?OpsxAPI 内置 **Mock.js 风格的数据生成器**,两种用法覆盖绝大多数场景:
1. **显式表达式** —— 在参数值、Body 中直接写 `@cname`、`@integer(1,100)`;
2. **智能匹配** —— 开启后按**字段名**自动套用规则表(`username` → 姓名、`email` → 邮箱…),一行不用写。
## 开启与配置
顶栏 **⋯ → Mock 数据**(或「设置 → Mock 数据」):
- **启用 Mock 数据**:总开关,关闭后 `@` 表达式按普通文本原样发送;
- **智能 Mock 匹配**:JSON Body 字段值未写表达式时,按字段名匹配规则自动生成;
- **智能匹配规则表**:数据类型(string / number / integer / boolean)、匹配方式(通配符 / 完全匹配 / 正则)、匹配规则(如 `*name*`)、Mock 表达式、备注与启用开关,支持搜索、新增、删除与恢复默认。
规则**从上到下**匹配,先命中先用;类型需与 JSON 字段实际类型一致(KV 参数一律按 string 匹配)。
## 表达式速查
| 表达式 | 说明 | 示例输出 |
| --- | --- | --- |
| `@cname` | 中文姓名 | `王国强` |
| `@email` | 邮箱 | `kx8f2@qq.com` |
| `@phone` | 手机号 | `138****2733` 形式 11 位 |
| `@id` | 身份证号 | 18 位 |
| `@guid` | UUID / GUID | `3f2b…-…` |
| `@integer(1, 100)` | 整数 | `42` |
| `@float(1, 9999, 2)` | 小数(保留 2 位) | `128.50` |
| `@boolean` | 布尔 | `true` |
| `@string(8, 16)` | 随机字符串 | `aZ3k…` |
| `@datetime(YYYY-MM-DD HH:mm:ss)` | 日期时间 | `2026-09-17 14:30:00` |
| `@date(YYYY-MM-DD)` / `@time(HH:mm:ss)` | 日期 / 时间 | — |
| `@timestamp` | 毫秒时间戳 | `1760000000000` |
| `@url` / `@domain` / `@ip` | URL / 域名 / IPv4 | — |
| `@province` / `@city(true)` / `@county(true)` | 省市区 | `广东省深圳市南山区` |
| `@ctitle(4, 12)` / `@csentence(10, 24)` | 中文标题 / 句子 | — |
| `@image(200x200)` | 图片地址 | picsum 随机图 |
| `@pick("A", "B", "C")` | 从列表中选择 | `B` |
未列出的写法会**原样发送**,可放心与真实值混用。
## 在请求中使用
**Params / x-www-form-urlencoded / form-data**:值一栏直接填表达式。
```text
username = @cname
page = @integer(1, 10)
```
**JSON Body**:叶子值写表达式,或交给智能匹配:
```json
{
"username": "@cname",
"email": "@email",
"age": "@integer(18, 60)",
"vip": "@boolean"
}
```
Body 工具栏提供两个辅助按钮:
- **智能填充**:按规则把 JSON 各字段值批量替换为对应 `@` 表达式,生成可编辑的 Mock 模板;
- **Mock 预览**:即时查看发送时实际生成的数据,不必真的发请求。
## 生效范围
- 生效:Query 参数、JSON / XML / Text Body、x-www-form-urlencoded、form-data、GraphQL Variables;
- 不生效:Headers、Cookies、鉴权字段(避免意外改坏签名与令牌)。
> 提示:Mock 在**发送瞬间**求值,同一请求每次发送都会得到新数据;预览结果仅供核对格式。
+102 -16
View File
@@ -4,37 +4,123 @@
{
"name": "开始使用",
"pages": [
{ "file": "01-intro.md", "slug": "intro", "title": "产品介绍", "updated": "2026-09-17" },
{ "file": "02-quickstart.md", "slug": "quickstart", "title": "快速开始", "updated": "2026-09-17" },
{ "file": "03-interface.md", "slug": "interface", "title": "界面导览", "updated": "2026-09-17" }
{
"file": "01-intro.md",
"slug": "intro",
"title": "产品介绍",
"updated": "2026-09-17"
},
{
"file": "02-quickstart.md",
"slug": "quickstart",
"title": "快速开始",
"updated": "2026-09-17"
},
{
"file": "03-interface.md",
"slug": "interface",
"title": "界面导览",
"updated": "2026-09-17"
}
]
},
{
"name": "请求调试",
"pages": [
{ "file": "04-http.md", "slug": "http", "title": "发送 HTTP 请求", "updated": "2026-09-17" },
{ "file": "05-auth.md", "slug": "auth", "title": "接口鉴权", "updated": "2026-09-17" },
{ "file": "06-env.md", "slug": "env", "title": "环境与变量", "updated": "2026-09-17" },
{ "file": "07-graphql.md", "slug": "graphql", "title": "GraphQL", "updated": "2026-09-17" },
{ "file": "08-websocket.md", "slug": "websocket", "title": "WebSocket", "updated": "2026-09-17" },
{ "file": "09-sse.md", "slug": "sse", "title": "SSE 事件流", "updated": "2026-09-17" },
{ "file": "10-socketio.md", "slug": "socketio", "title": "Socket.IO", "updated": "2026-09-17" },
{ "file": "11-mqtt.md", "slug": "mqtt", "title": "MQTT", "updated": "2026-09-17" }
{
"file": "04-http.md",
"slug": "http",
"title": "发送 HTTP 请求",
"updated": "2026-09-17"
},
{
"file": "05-auth.md",
"slug": "auth",
"title": "接口鉴权",
"updated": "2026-09-17"
},
{
"file": "06-env.md",
"slug": "env",
"title": "环境与变量",
"updated": "2026-09-17"
},
{
"file": "07-graphql.md",
"slug": "graphql",
"title": "GraphQL",
"updated": "2026-09-17"
},
{
"file": "08-websocket.md",
"slug": "websocket",
"title": "WebSocket",
"updated": "2026-09-17"
},
{
"file": "09-sse.md",
"slug": "sse",
"title": "SSE 事件流",
"updated": "2026-09-17"
},
{
"file": "10-socketio.md",
"slug": "socketio",
"title": "Socket.IO",
"updated": "2026-09-17"
},
{
"file": "11-mqtt.md",
"slug": "mqtt",
"title": "MQTT",
"updated": "2026-09-17"
}
]
},
{
"name": "数据管理",
"pages": [
{ "file": "12-import.md", "slug": "import", "title": "数据导入", "updated": "2026-09-17" },
{ "file": "13-export.md", "slug": "export", "title": "导出与代码生成", "updated": "2026-09-17" },
{ "file": "14-manage.md", "slug": "manage", "title": "集合与历史", "updated": "2026-09-17" }
{
"file": "12-import.md",
"slug": "import",
"title": "数据导入",
"updated": "2026-09-17"
},
{
"file": "13-export.md",
"slug": "export",
"title": "导出与代码生成",
"updated": "2026-09-17"
},
{
"file": "14-mock.md",
"slug": "mock",
"title": "Mock 数据",
"updated": "2026-09-17"
},
{
"file": "15-manage.md",
"slug": "manage",
"title": "集合与历史",
"updated": "2026-09-17"
}
]
},
{
"name": "参考",
"pages": [
{ "file": "15-server-api.md", "slug": "server-api", "title": "服务端 API 参考", "updated": "2026-09-17" },
{ "file": "16-faq.md", "slug": "faq", "title": "常见问题", "updated": "2026-09-17" }
{
"file": "16-server-api.md",
"slug": "server-api",
"title": "服务端 API 参考",
"updated": "2026-09-17"
},
{
"file": "17-faq.md",
"slug": "faq",
"title": "常见问题",
"updated": "2026-09-17"
}
]
}
]
+105
View File
@@ -369,6 +369,111 @@
.ops-hint svg { color: var(--accent-strong); margin-top: 2px; }
.ops-hint b { color: var(--accent-strong); font-family: inherit; }
/* ---------- 设置弹窗(左侧导航 + 分区) ---------- */
.set-dialog { display: grid; grid-template-columns: 188px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); gap: 0 16px; height: min(66vh, 620px); min-height: 420px; margin: 0 -1.25rem -0.25rem; }
.set-side {
border-right: 1px solid var(--border);
padding-right: 12px;
display: flex; flex-direction: column; gap: 2px;
}
.set-side-head {
@apply mb-2 px-2 text-[11px] font-semibold uppercase tracking-[0.08em];
color: var(--text-faint);
}
.set-side-item {
@apply flex items-center gap-2.5 rounded-lg px-2 py-2 transition;
color: var(--text-dim);
}
.set-side-item:hover { background: var(--hover); color: var(--text); }
.set-side-item.active { background: var(--active); color: var(--accent-strong); }
.set-side-icon {
@apply grid h-7 w-7 shrink-0 place-items-center rounded-lg;
background: var(--panel-2);
color: var(--text-dim);
}
.set-side-icon.active { color: var(--accent-strong); }
.set-main { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.set-main-head { padding-bottom: 10px; flex-shrink: 0; }
.set-main-body { flex: 1; min-height: 0; padding-top: 14px; overflow-y: auto; display: flex; flex-direction: column; gap: 14px; padding-right: 4px; }
.set-main-body > .set-group { flex-shrink: 0; }
.set-group {
@apply overflow-hidden rounded-xl;
border: 1px solid var(--border);
background: var(--panel-2);
}
.set-group-title {
@apply px-4 pt-3 pb-1 text-[11px] font-semibold uppercase tracking-[0.08em];
color: var(--text-faint);
}
.set-row {
@apply flex items-center gap-4 px-4 py-3;
}
.set-row + .set-row { border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent); }
.set-row-control { flex-shrink: 0; display: flex; align-items: center; gap: 6px; }
/* 开关 */
.set-toggle {
@apply relative h-[22px] w-[38px] rounded-full transition;
background: var(--border);
}
.set-toggle:hover { opacity: 0.85; }
.set-toggle.on { background: var(--accent); }
.set-toggle-dot {
@apply absolute top-[3px] left-[3px] h-4 w-4 rounded-full bg-white transition;
}
.set-toggle.on .set-toggle-dot { transform: translateX(16px); }
/* Mock 规则表(随右侧内容区整体滚动,表头吸附) */
.set-group .ops-hint { margin: 10px 12px 0; }
.mock-rules { @apply mt-2; }
.mock-rule-head, .mock-rule-row {
display: grid;
grid-template-columns: 88px 84px minmax(110px, 1fr) minmax(130px, 1.2fr) minmax(96px, 0.9fr) 44px 30px;
gap: 6px;
align-items: center;
padding: 4px 12px;
}
.mock-rule-head {
@apply sticky top-0 z-[1] text-[11px] font-semibold;
color: var(--text-faint);
background: var(--panel-2);
border-bottom: 1px solid var(--border);
padding-top: 8px;
padding-bottom: 8px;
}
.mock-rule-row { padding-top: 5px; padding-bottom: 5px; }
.mock-rule-row + .mock-rule-row { border-top: 1px solid color-mix(in srgb, var(--border) 45%, transparent); }
.mock-rule-pattern { font-family: ui-monospace, monospace; }
.mock-rule-expr { font-family: ui-monospace, monospace; color: var(--accent-strong); }
/* Mock 速查表 */
.mock-cheatsheet { @apply mt-2 grid grid-cols-2 gap-1.5; }
.mock-cheat-item {
@apply flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[11px] transition;
background: var(--panel);
border: 1px solid var(--border);
color: var(--text-dim);
}
.mock-cheat-item:hover { border-color: var(--accent); color: var(--text); }
.mock-cheat-item code {
font-family: ui-monospace, monospace;
color: var(--accent-strong);
font-size: 11px;
flex-shrink: 0;
}
.mock-cheat-item .ops-faint { flex: 1; min-width: 0; @apply truncate; }
.mock-cheat-gen {
max-width: 46%;
@apply truncate font-mono text-[10px];
color: var(--text-faint);
}
.mock-preview-value {
@apply rounded-md px-2 py-1 font-mono text-[12px];
background: var(--panel-2);
border: 1px solid var(--border);
color: var(--accent-strong);
}
/* ---------- {{变量}} 补全浮层 ---------- */
.var-hint-wrap { position: relative; min-width: 0; flex: 1; display: inline-flex; }
.var-hint-panel {
+2 -1
View File
@@ -1,6 +1,7 @@
/**
* 节点 / 集合 / 环境的默认工厂。
*/
import { normalizeMockSettings } from "./mock";
import type {
AuthConfig,
Collection,
@@ -262,6 +263,6 @@ export function createSeedWorkspace(): WorkspaceData {
environments: envs,
activeEnvironmentId: envs[0]?.id ?? null,
history: [],
settings: { theme: "dark", timeoutMs: 30000 },
settings: { theme: "dark", timeoutMs: 30000, mock: normalizeMockSettings(null) },
};
}
+18
View File
@@ -42,6 +42,10 @@ export type {
WorkspaceSettings,
WorkspaceData,
ImportResult,
MockRule,
MockSettings,
MockFieldType,
MockMatchMode,
} from "./model";
export {
REQUEST_METHODS,
@@ -85,6 +89,20 @@ export {
// 变量替换
export { replaceVariables, replaceVariablesInSpec } from "./replaceVariables";
// Mock 数据
export {
generateMock,
generateMockTyped,
applyMockToValue,
applyMockToJson,
smartFillJson,
matchMockRule,
globToRegExp,
normalizeMockSettings,
DEFAULT_MOCK_RULES,
MOCK_CHEATSHEET,
} from "./mock";
// 导入
export {
detectImportFormat,
+152
View File
@@ -0,0 +1,152 @@
import { describe, expect, it } from "vitest";
import {
applyMockToJson,
applyMockToValue,
DEFAULT_MOCK_RULES,
generateMock,
generateMockTyped,
globToRegExp,
matchMockRule,
normalizeMockSettings,
smartFillJson,
} from "./mock";
const mockOn = normalizeMockSettings({ enabled: true, smart: true });
const mockOff = normalizeMockSettings({ enabled: false });
describe("generateMock 表达式", () => {
it("整数保持在区间内", () => {
for (let i = 0; i < 50; i++) {
const n = Number(generateMock("@integer(3, 7)"));
expect(n).toBeGreaterThanOrEqual(3);
expect(n).toBeLessThanOrEqual(7);
expect(Number.isInteger(n)).toBe(true);
}
});
it("浮点保留指定位数", () => {
const s = generateMock("@float(1, 10, 2)");
expect(s).toMatch(/^\d+\.\d{2}$/);
expect(Number(s)).toBeGreaterThanOrEqual(1);
expect(Number(s)).toBeLessThanOrEqual(10);
});
it("邮箱 / 手机号 / UUID / 身份证格式", () => {
expect(generateMock("@email")).toMatch(/^[\w.]+@[\w.]+\.\w+$/);
expect(generateMock("@phone")).toMatch(/^1[3-9]\d{9}$/);
expect(generateMock("@guid")).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
expect(generateMock("@id")).toMatch(/^\d{17}[\dX]$/);
});
it("日期格式化", () => {
expect(generateMock("@date(YYYY-MM-DD)")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(generateMock("@datetime(YYYY/MM/DD HH:mm:ss)")).toMatch(/^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}$/);
expect(generateMock("@timestamp")).toMatch(/^\d{12,}$/);
});
it("pick 支持引号参数", () => {
const out = generateMock('@pick("男", "女")');
expect(["男", "女"]).toContain(out);
});
it("未知表达式原样返回", () => {
expect(generateMock("@notExist(1)")).toBe("@notExist(1)");
expect(generateMock("普通文本")).toBe("普通文本");
});
it("typed 生成保持类型", () => {
expect(typeof generateMockTyped("@integer(1, 9)", "number")).toBe("number");
expect(typeof generateMockTyped("@boolean", "boolean")).toBe("boolean");
expect(typeof generateMockTyped("@cname", "string")).toBe("string");
});
});
describe("通配符与智能匹配", () => {
it("glob 转换", () => {
expect(globToRegExp("*name*").test("username")).toBe(true);
expect(globToRegExp("*name*").test("nickname")).toBe(true);
expect(globToRegExp("*name*").test("age")).toBe(false);
expect(globToRegExp("*_at").test("created_at")).toBe(true);
expect(globToRegExp("is_*").test("is_vip")).toBe(true);
});
it("默认规则命中常见字段", () => {
const rules = normalizeMockSettings(null).rules;
expect(matchMockRule(rules, "username", "string")?.rule).toBe("@cname");
expect(matchMockRule(rules, "email", "string")?.rule).toBe("@email");
expect(matchMockRule(rules, "age", "integer")).not.toBeNull();
expect(matchMockRule(rules, "age", "string")).toBeNull(); // 类型不匹配
expect(matchMockRule(rules, "is_deleted", "boolean")).not.toBeNull();
expect(matchMockRule(rules, "price", "number")).not.toBeNull();
});
it("大小写不敏感(默认)与禁用规则", () => {
const rules = [
{ ...DEFAULT_MOCK_RULES[0], pattern: "*Name*", caseSensitive: false },
{ ...DEFAULT_MOCK_RULES[0], id: "x", enabled: false },
];
expect(matchMockRule(rules.slice(0, 1), "username", "string")).not.toBeNull();
expect(matchMockRule(rules.slice(1), "username", "string")).toBeNull();
});
});
describe("值与 JSON 应用", () => {
it("KV 值:表达式生成 / 智能兜底 / 关闭时原样", () => {
expect(applyMockToValue("@integer(5, 5)", mockOn)).toBe("5");
expect(applyMockToValue("hello@world", mockOn)).toBe("hello@world"); // 非 @ 开头不动
expect(applyMockToValue("@cname", mockOff)).toBe("@cname");
// 空值 + 字段名命中 → 智能填充;非空值不覆盖
expect(applyMockToValue("", mockOn, "email")).toMatch(/@/);
expect(applyMockToValue("真实值", mockOn, "email")).toBe("真实值");
});
it("JSON:叶子表达式 + 智能匹配(含类型保持)", () => {
const input = JSON.stringify({
username: "张三占位",
age: 20,
vip: false,
nested: { email: "x@y.z", list: [{ order_id: 1 }] },
});
const out = JSON.parse(applyMockToJson(input, mockOn));
expect(typeof out.username).toBe("string");
expect(out.username).not.toBe("张三占位");
expect(Number.isInteger(out.age)).toBe(true);
expect(typeof out.vip).toBe("boolean");
expect(out.nested.email).toMatch(/@/);
expect(Number.isInteger(out.nested.list[0].order_id)).toBe(true);
});
it("JSON:关闭时不改写 / 非 JSON 原样", () => {
const input = '{"a":"@cname"}';
expect(applyMockToJson(input, mockOff)).toBe(input);
expect(applyMockToJson("not-json", mockOn)).toBe("not-json");
expect(applyMockToJson("@cname", mockOn)).not.toBe("@cname"); // 顶层表达式也解析
});
it("smartFillJson 生成可编辑模板", () => {
const { text, filled } = smartFillJson(
JSON.stringify({ username: "占位", age: 18, keep: "自定义值字段名不命中" }),
mockOn,
);
const parsed = JSON.parse(text);
expect(parsed.username).toBe("@cname");
expect(parsed.age).toBe("@integer(18, 65)");
expect(parsed.keep).toBe("自定义值字段名不命中");
expect(filled).toBe(2);
});
});
describe("设置归一化", () => {
it("缺省补默认,坏字段修复", () => {
const s = normalizeMockSettings({ enabled: true, rules: [{ id: "a", pattern: "*x*" }] });
expect(s.smart).toBe(true);
expect(s.rules[0].type).toBe("string");
expect(s.rules[0].match).toBe("wildcard");
expect(s.rules[0].rule).toBe("@string(5,10)");
});
it("null / 乱数据返回默认规则", () => {
expect(normalizeMockSettings(null).rules.length).toBe(DEFAULT_MOCK_RULES.length);
expect(normalizeMockSettings("junk").rules.length).toBe(DEFAULT_MOCK_RULES.length);
});
});
+513
View File
@@ -0,0 +1,513 @@
/**
* Mock 数据引擎(Mock.js 常用语法子集 + 智能字段匹配)。
*
* 两种用法:
* 1. 显式表达式:在参数值 / Body 中直接写 @cname、@integer(1,100)、@datetime(...) 等;
* 2. 智能匹配:开启后按字段名(如 username / email / age)自动套用规则表生成。
*/
import type { MockFieldType, MockRule, MockSettings } from "./model";
/* ---------------- 基础数据 ---------------- */
const SURNAME = "赵钱孙李周吴郑王冯陈蒋沈韩杨朱秦许何吕张孔曹严华金魏陶姜谢邹柏水窦章云苏潘葛范彭鲁马苗凤花方任袁柳史唐薛雷贺倪汤";
const GIVEN = "伟芳娜秀英敏静丽强磊军洋勇艳杰娟涛明超秀兰霞平刚华民玉萍红娥玲芬芳燕春菊学成嘉昊泽宇欣怡思远志坚建国子涵梓萱浩然雨桐";
const PROVINCES = [
"北京市", "上海市", "天津市", "重庆市", "河北省", "山西省", "辽宁省", "吉林省", "黑龙江省",
"江苏省", "浙江省", "安徽省", "福建省", "江西省", "山东省", "河南省", "湖北省", "湖南省",
"广东省", "海南省", "四川省", "贵州省", "云南省", "陕西省", "甘肃省", "青海省", "台湾省",
"内蒙古自治区", "广西壮族自治区", "西藏自治区", "宁夏回族自治区", "新疆维吾尔自治区", "香港特别行政区", "澳门特别行政区",
];
const CITIES = [
"广州", "深圳", "杭州", "成都", "武汉", "西安", "南京", "苏州", "长沙", "郑州", "青岛", "大连",
"厦门", "宁波", "无锡", "佛山", "东莞", "沈阳", "哈尔滨", "石家庄", "太原", "合肥", "福州", "南昌",
"济南", "昆明", "贵阳", "兰州", "西宁", "南宁", "呼和浩特", "乌鲁木齐",
];
const COUNTIES = [
"朝阳区", "海淀区", "浦东新区", "南山区", "福田区", "天河区", "西湖区", "锦江区", "雁塔区", "玄武区",
"工业园区", "香洲区", "东湖区", "岳麓区", "金水区", "市南区", "和平区", "河东区",
];
const LETTERS = "abcdefghijklmnopqrstuvwxyz";
const UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const DIGITS = "0123456789";
const LOREM = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore";
const randInt = (min: number, max: number) => min + Math.floor(Math.random() * (max - min + 1));
const pickFrom = <T,>(arr: T[]): T => arr[Math.floor(Math.random() * arr.length)];
function randString(pool: string, len: number): string {
let s = "";
for (let i = 0; i < len; i++) s += pool[Math.floor(Math.random() * pool.length)];
return s;
}
function pad(n: number, w = 2): string {
return String(n).padStart(w, "0");
}
function formatDate(fmt: string, d = new Date()): string {
return fmt
.replace(/YYYY/g, String(d.getFullYear()))
.replace(/YY/g, String(d.getFullYear()).slice(2))
.replace(/MM/g, pad(d.getMonth() + 1))
.replace(/DD/g, pad(d.getDate()))
.replace(/HH/g, pad(d.getHours()))
.replace(/mm/g, pad(d.getMinutes()))
.replace(/ss/g, pad(d.getSeconds()));
}
/* ---------------- 表达式求值 ---------------- */
/** 拆分实参:兼容引号字符串与裸数字 */
function parseArgs(raw: string): (string | number)[] {
const out: (string | number)[] = [];
let cur = "";
let quote: string | null = null;
let depth = 0;
for (const ch of raw) {
if (quote) {
if (ch === quote) quote = null;
else cur += ch;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (ch === "(") depth++;
if (ch === ")") depth--;
if (ch === "," && depth === 0) {
out.push(cur.trim());
cur = "";
continue;
}
cur += ch;
}
if (cur.trim()) out.push(cur.trim());
return out.map((a) => {
const n = Number(a);
return a !== "" && !Number.isNaN(n) ? n : a;
});
}
const asNum = (v: string | number | undefined, fallback: number): number =>
typeof v === "number" && Number.isFinite(v) ? v : fallback;
const PHONE_HEADS = ["130", "131", "132", "133", "135", "136", "137", "138", "139", "150", "151", "152", "155", "156", "157", "158", "159", "170", "176", "177", "178", "180", "181", "182", "183", "185", "186", "187", "188", "189", "199"];
function idCard(): string {
const area = randInt(110000, 659000);
const y = randInt(1960, 2005);
const m = randInt(1, 12);
const d = randInt(1, 28);
const seq = randInt(100, 999);
return `${area}${y}${pad(m)}${pad(d)}${seq}${randInt(0, 9)}`;
}
/** 求 Mock 表达式的值(字符串形式);非 @ 开头或未知语法时原样返回 */
export function generateMock(expr: string): string {
const m = /^@([a-zA-Z][\w-]*)(?:\((.*)\))?$/.exec(expr.trim());
if (!m) return expr;
const name = m[1].toLowerCase();
const args = parseArgs(m[2] ?? "");
switch (name) {
/* 数字 */
case "integer":
return String(randInt(Math.trunc(asNum(args[0], 0)), Math.trunc(asNum(args[1], 100))));
case "natural":
return String(randInt(Math.trunc(asNum(args[0], 1)), Math.trunc(asNum(args[1], 1000))));
case "increment":
return String(randInt(1, 999));
case "float": {
const min = asNum(args[0], 0);
const max = asNum(args[1], 100);
const d = Math.trunc(asNum(args[2], 2));
return (min + Math.random() * (max - min)).toFixed(d);
}
case "timestamp":
return String(Date.now());
/* 布尔 / 选择 */
case "boolean":
return String(Math.random() < 0.5);
case "bool":
return String(Math.random() < 0.5);
case "pick":
return String(pickFrom(args.length ? args : ["A", "B", "C"]));
/* 字符串 */
case "string": {
const a = args[0];
let pool = LETTERS + UPPER + DIGITS;
let min = 5;
let max = 10;
if (typeof a === "string") {
if (a === "lower") pool = LETTERS;
else if (a === "upper") pool = UPPER;
else if (a === "number") pool = DIGITS;
else if (a === "symbol") pool = "!@#$%^&*";
else { min = asNum(a, 5); max = asNum(args[1], min); }
} else if (typeof a === "number") {
min = a;
max = asNum(args[1], min);
}
return randString(pool, randInt(min, max));
}
case "character":
return randString(LETTERS + UPPER + DIGITS, 1);
case "word":
case "cword": {
const min = Math.trunc(asNum(args[0], 3));
const max = Math.trunc(asNum(args[1], min));
return randString(GIVEN, randInt(min, max));
}
/* 姓名 */
case "cname":
return pickFrom([...SURNAME]) + randString(GIVEN, randInt(1, 2));
case "name":
return `${pickFrom(["James", "John", "Mary", "Alice", "Bob", "Emma", "Li", "Wang"])} ${pickFrom(["Smith", "Brown", "Chen", "Zhang", "Lee", "Wilson"])}`;
case "cfirst":
return pickFrom([...SURNAME]);
case "clast":
return randString(GIVEN, randInt(1, 2));
/* 网络 */
case "email":
return `${randString(LETTERS, randInt(4, 9))}${randInt(1, 99)}@${pickFrom(["qq.com", "163.com", "gmail.com", "outlook.com", "example.com"])}`;
case "url":
return `${pickFrom(["https", "http"])}://${pickFrom(["www.example.com", "api.example.com", "cdn.example.com", "example.org"])}/${randString(LETTERS, randInt(3, 8))}`;
case "domain":
return `${randString(LETTERS, randInt(4, 10))}.${pickFrom(["com", "cn", "net", "org", "io"])}`;
case "ip":
return `${randInt(1, 254)}.${randInt(0, 255)}.${randInt(0, 255)}.${randInt(1, 254)}`;
case "tld":
return pickFrom(["com", "cn", "net", "org", "io", "dev"]);
/* 标识 */
case "guid":
case "uuid": {
const h = "0123456789abcdef";
const seg = (n: number) => randString(h, n);
return `${seg(8)}-${seg(4)}-${seg(4)}-${seg(4)}-${seg(12)}`;
}
case "id":
return idCard();
/* 手机号 */
case "phone":
case "mobile":
case "tel":
return pickFrom(PHONE_HEADS) + randString(DIGITS, 8);
/* 日期时间 */
case "date":
return formatDate(typeof args[0] === "string" ? args[0] : "YYYY-MM-DD");
case "time":
return formatDate(typeof args[0] === "string" ? args[0] : "HH:mm:ss");
case "datetime":
return formatDate(typeof args[0] === "string" ? args[0] : "YYYY-MM-DD HH:mm:ss");
case "now":
return formatDate(typeof args[0] === "string" ? args[0] : "YYYY-MM-DD HH:mm:ss");
/* 地区 */
case "province":
return pickFrom(PROVINCES);
case "city":
return (String(args[0]) === "true" ? pickFrom(PROVINCES) : "") + pickFrom(CITIES) + "市";
case "county":
return (String(args[0]) === "true" ? `${pickFrom(PROVINCES)}${pickFrom(CITIES)}市` : "") + pickFrom(COUNTIES);
case "zip":
case "zipcode":
return randString(DIGITS, 6);
/* 中文文本 */
case "ctitle":
case "ctitlex":
return randString(GIVEN, randInt(Math.trunc(asNum(args[0], 4)), Math.trunc(asNum(args[1], 8))));
case "csentence":
return randString(GIVEN + GIVEN, randInt(Math.trunc(asNum(args[0], 8)), Math.trunc(asNum(args[1], 16)))) + "。";
case "cparagraph":
return Array.from({ length: Math.trunc(asNum(args[0], 2)) }, () => randString(GIVEN, randInt(10, 20)) + "。").join("");
case "sentence":
return `${pickFrom(LOREM.split(" "))} ${pickFrom(LOREM.split(" "))} ${pickFrom(LOREM.split(" "))}.`.replace(/^./, (c) => c.toUpperCase());
case "paragraph":
return Array.from({ length: 3 }, () => pickFrom(LOREM.split(" "))).join(" ");
case "title": {
const w = pickFrom(LOREM.split(" "));
return w.charAt(0).toUpperCase() + w.slice(1);
}
/* 图片 */
case "image": {
const size = typeof args[0] === "string" && /^\d+x\d+$/.test(args[0]) ? args[0] : `${randInt(2, 8) * 100}x${randInt(2, 8) * 100}`;
const [w, h] = size.split("x");
return `https://picsum.photos/seed/${randString(LETTERS + DIGITS, 8)}/${w}/${h}`;
}
default:
return expr;
}
}
/** 按目标类型生成(JSON 数字/布尔叶子保持类型) */
export function generateMockTyped(expr: string, want: "string" | "number" | "boolean"): string | number | boolean {
const raw = generateMock(expr);
if (want === "boolean") return raw === "true";
if (want === "number") {
const n = Number(raw);
return Number.isNaN(n) ? raw : n;
}
return raw;
}
/* ---------------- 智能匹配 ---------------- */
/** 通配符 → RegExp(* 任意串 / ? 单字符) */
export function globToRegExp(pattern: string): RegExp {
let re = "";
for (const ch of pattern) {
if (ch === "*") re += ".*";
else if (ch === "?") re += ".";
else re += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
}
return new RegExp(`^${re}$`);
}
export function normalizeMockSettings(raw: unknown): MockSettings {
const source = raw && typeof raw === "object" ? (raw as Partial<MockSettings>) : {};
const rules = Array.isArray(source.rules)
? source.rules
.filter((r): r is MockRule => !!r && typeof r === "object")
.map((r, i) => ({
id: typeof r.id === "string" && r.id ? r.id : `mr-${Date.now()}-${i}`,
enabled: r.enabled !== false,
type: (["string", "number", "integer", "boolean"] as const).includes(r.type as never) ? r.type : "string",
match: (["wildcard", "exact", "regex"] as const).includes(r.match as never) ? r.match : "wildcard",
pattern: typeof r.pattern === "string" ? r.pattern : "",
caseSensitive: r.caseSensitive === true,
rule: typeof r.rule === "string" ? r.rule : "@string(5,10)",
note: typeof r.note === "string" ? r.note : undefined,
}))
: DEFAULT_MOCK_RULES.map((r) => ({ ...r }));
return {
enabled: source.enabled !== false,
smart: source.smart !== false,
rules,
};
}
function leafKindOf(value: unknown): MockFieldType | null {
if (typeof value === "string") return "string";
if (typeof value === "boolean") return "boolean";
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
return null;
}
/** 字段名是否命中规则(类型需匹配叶子类型;KV 值按 string 匹配) */
export function matchMockRule(
rules: MockRule[],
fieldName: string,
valueKind: "string" | "number" | "integer" | "boolean",
): MockRule | null {
for (const r of rules) {
if (!r.enabled || !r.pattern || !r.rule) continue;
if (r.type !== valueKind && !(r.type === "number" && valueKind === "integer")) continue;
const name = r.caseSensitive ? fieldName : fieldName.toLowerCase();
const pat = r.caseSensitive ? r.pattern : r.pattern.toLowerCase();
let hit = false;
try {
if (r.match === "exact") hit = name === pat;
else if (r.match === "regex") hit = new RegExp(pat).test(name);
else hit = globToRegExp(pat).test(name);
} catch {
hit = false;
}
if (hit) return r;
}
return null;
}
/** 单值解析:@表达式 → 生成值;空值 + 字段名命中时智能填充(非空值不覆盖) */
export function applyMockToValue(
value: string,
mock: MockSettings | undefined,
fieldHint?: string,
): string {
if (!mock?.enabled) return value;
const trimmed = value.trim();
if (trimmed.startsWith("@")) return generateMock(trimmed);
if (mock.smart && value === "" && fieldHint) {
const rule = matchMockRule(mock.rules, fieldHint, "string");
if (rule) return String(generateMockTyped(rule.rule, "string"));
}
return value;
}
/** JSON 字符串 → 解析后逐叶子生成;解析失败时退回整体表达式替换 */
export function applyMockToJson(raw: string, mock: MockSettings | undefined): string {
if (!mock?.enabled) return raw;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return raw.trim().startsWith("@") ? generateMock(raw.trim()) : raw;
}
const visit = (node: unknown, key: string | null): unknown => {
if (Array.isArray(node)) return node.map((item) => visit(item, key));
if (node && typeof node === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
out[k] = visit(v, k);
}
return out;
}
// 叶子
if (typeof node === "string") {
if (node.trim().startsWith("@")) return generateMock(node.trim());
const rule = mock.smart && key ? matchMockRule(mock.rules, key, "string") : null;
if (rule) return String(generateMockTyped(rule.rule, "string"));
return node;
}
if (typeof node === "number" || typeof node === "boolean") {
const kind = leafKindOf(node);
const rule = mock.smart && key && kind ? matchMockRule(mock.rules, key, kind) : null;
if (rule) return generateMockTyped(rule.rule, kind === "boolean" ? "boolean" : "number");
return node;
}
return node;
};
return JSON.stringify(visit(parsed, null), null, 2);
}
/** 编辑态:把 JSON 各叶子值替换为命中的 @表达式(生成可编辑的 Mock 模板) */
export function smartFillJson(raw: string, mock: MockSettings): { text: string; filled: number } {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { text: raw, filled: 0 };
}
let filled = 0;
const visit = (node: unknown, key: string | null): unknown => {
if (Array.isArray(node)) return node.map((item) => visit(item, key));
if (node && typeof node === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
out[k] = visit(v, k);
}
return out;
}
const kind = leafKindOf(node);
const rule = key && kind ? matchMockRule(mock.rules, key, kind) : null;
if (rule) {
filled++;
return rule.rule;
}
return node;
};
return { text: JSON.stringify(visit(parsed, null), null, 2), filled };
}
/* ---------------- 默认规则(按特异性排序,先命中先用) ---------------- */
function rule(
id: string,
pattern: string,
type: MockFieldType,
expr: string,
note: string,
match: "wildcard" | "exact" | "regex" = "wildcard",
): MockRule {
return { id, enabled: true, type, match, pattern, caseSensitive: false, rule: expr, note };
}
export const DEFAULT_MOCK_RULES: MockRule[] = [
rule("mr-nickname", "*nickname*", "string", "@cname", "昵称"),
rule("mr-username", "*username*", "string", "@cname", "用户名/账号"),
rule("mr-name", "*name*", "string", "@cname", "姓名(通配命中 realName/fullName 等)"),
rule("mr-email", "*email*", "string", "@email", "邮箱"),
rule("mr-mail", "*mail*", "string", "@email", "邮箱"),
rule("mr-phone", "*phone*", "string", "@phone", "手机号"),
rule("mr-mobile", "*mobile*", "string", "@phone", "手机号"),
rule("mr-tel", "*tel*", "string", "@phone", "电话"),
rule("mr-avatar", "*avatar*", "string", "@image(120x120)", "头像"),
rule("mr-image", "*image*", "string", "@image", "图片"),
rule("mr-img", "*img*", "string", "@image", "图片/缩略图"),
rule("mr-icon", "*icon*", "string", "@image(64x64)", "图标"),
rule("mr-cover", "*cover*", "string", "@image(640x320)", "封面图"),
rule("mr-url", "*url*", "string", "@url", "链接"),
rule("mr-link", "*link*", "string", "@url", "链接"),
rule("mr-website", "*website*", "string", "@url", "网站"),
rule("mr-id", "*id", "integer", "@integer(1, 999999)", "业务 ID(userId/orderId…)"),
rule("mr-uuid", "*uuid*", "string", "@guid", "UUID"),
rule("mr-guid", "*guid*", "string", "@guid", "GUID"),
rule("mr-token", "*token*", "string", "@string(32)", "令牌"),
rule("mr-password", "*password*", "string", "@string(10,16)", "密码"),
rule("mr-pwd", "*pwd*", "string", "@string(10,16)", "密码"),
rule("mr-secret", "*secret*", "string", "@string(24)", "密钥"),
rule("mr-age", "*age*", "integer", "@integer(18, 65)", "年龄"),
rule("mr-sex", "*sex*", "integer", "@integer(0, 1)", "性别码"),
rule("mr-gender", "*gender*", "integer", "@integer(0, 1)", "性别码"),
rule("mr-price", "*price*", "number", "@float(1, 9999, 2)", "价格"),
rule("mr-amount", "*amount*", "number", "@float(1, 99999, 2)", "金额"),
rule("mr-money", "*money*", "number", "@float(0, 99999, 2)", "金额"),
rule("mr-fee", "*fee*", "number", "@float(0, 999, 2)", "费用"),
rule("mr-count", "*count*", "integer", "@integer(0, 9999)", "数量"),
rule("mr-num", "*num*", "integer", "@integer(0, 999)", "数量"),
rule("mr-qty", "*qty*", "integer", "@integer(1, 999)", "数量"),
rule("mr-stock", "*stock*", "integer", "@integer(0, 9999)", "库存"),
rule("mr-page", "*page*", "integer", "@integer(1, 100)", "页码"),
rule("mr-size", "*size*", "integer", "@integer(10, 50)", "分页大小"),
rule("mr-limit", "*limit*", "integer", "@integer(10, 100)", "分页条数"),
rule("mr-status", "*status*", "integer", "@integer(0, 1)", "状态码"),
rule("mr-state", "*state*", "integer", "@integer(0, 1)", "状态"),
rule("mr-deleted", "*deleted*", "boolean", "@boolean", "逻辑删除标记"),
rule("mr-enabled", "*enabled*", "boolean", "@boolean", "启用标记"),
rule("mr-is", "is_*", "boolean", "@boolean", "布尔标记", "wildcard"),
rule("mr-time", "*time*", "string", "@datetime(YYYY-MM-DD HH:mm:ss)", "时间"),
rule("mr-date", "*date*", "string", "@date(YYYY-MM-DD)", "日期"),
rule("mr-at", "*_at", "string", "@datetime(YYYY-MM-DD HH:mm:ss)", "创建/更新时间"),
rule("mr-timestamp", "*timestamp*", "integer", "@timestamp", "时间戳"),
rule("mr-address", "*address*", "string", "@county(true)", "完整地址"),
rule("mr-addr", "*addr*", "string", "@county(true)", "地址"),
rule("mr-province", "*province*", "string", "@province", "省份"),
rule("mr-city", "*city*", "string", "@city(true)", "城市"),
rule("mr-district", "*district*", "string", "@county", "区县"),
rule("mr-area", "*area*", "string", "@county", "地区"),
rule("mr-title", "*title*", "string", "@ctitle(4, 12)", "标题"),
rule("mr-content", "*content*", "string", "@csentence(10, 24)", "正文内容"),
rule("mr-desc", "*desc*", "string", "@csentence(8, 16)", "描述"),
rule("mr-remark", "*remark*", "string", "@csentence(8, 16)", "备注"),
rule("mr-comment", "*comment*", "string", "@csentence(8, 16)", "评论"),
rule("mr-intro", "*intro*", "string", "@csentence(8, 16)", "简介"),
rule("mr-ip", "*ip*", "string", "@ip", "IP 地址"),
rule("mr-version", "*version*", "string", "@pick(\"1.0.0\", \"2.3.1\", \"3.1.4\")", "版本号"),
rule("mr-keyword", "*keyword*", "string", "@word(3, 8)", "搜索关键词"),
];
/** 设置页 / 文档展示的速查表 */
export const MOCK_CHEATSHEET: { expr: string; desc: string }[] = [
{ expr: "@cname", desc: "中文姓名" },
{ expr: "@email", desc: "邮箱" },
{ expr: "@phone", desc: "手机号" },
{ expr: "@id", desc: "身份证号" },
{ expr: "@guid", desc: "UUID / GUID" },
{ expr: "@integer(1, 100)", desc: "整数" },
{ expr: "@float(1, 9999, 2)", desc: "小数(2 位)" },
{ expr: "@boolean", desc: "true / false" },
{ expr: "@string(8, 16)", desc: "随机字符串" },
{ expr: "@datetime(YYYY-MM-DD HH:mm:ss)", desc: "日期时间" },
{ expr: "@timestamp", desc: "毫秒时间戳" },
{ expr: "@url", desc: "随机 URL" },
{ expr: "@ip", desc: "IPv4 地址" },
{ expr: "@county(true)", desc: "省市区完整地址" },
{ expr: "@ctitle(4, 12)", desc: "中文标题" },
{ expr: "@csentence(10, 24)", desc: "中文句子" },
{ expr: "@image(200x200)", desc: "图片地址" },
{ expr: '@pick("A", "B", "C")', desc: "从列表选择" },
];
+30
View File
@@ -227,10 +227,40 @@ export type HistoryEntry = {
export type ThemeMode = "dark" | "light";
/* ---------------- Mock 数据 ---------------- */
/** 规则作用于哪类字段值(JSON 叶子类型;KV 值视为 string) */
export type MockFieldType = "string" | "number" | "integer" | "boolean";
export type MockMatchMode = "wildcard" | "exact" | "regex";
/** 智能 Mock 匹配规则:字段名命中即按 rule 表达式生成数据 */
export type MockRule = {
id: string;
enabled: boolean;
type: MockFieldType;
match: MockMatchMode;
/** 匹配模式:通配符如 *name* / 精确 / 正则 */
pattern: string;
caseSensitive: boolean;
/** Mock 规则表达式,如 @cname / @integer(1,100) */
rule: string;
note?: string;
};
export type MockSettings = {
/** 全局开关:发送请求时解析 @mock 表达式 */
enabled: boolean;
/** 智能 Mock:字段名命中规则时自动生成(无需写 @表达式) */
smart: boolean;
rules: MockRule[];
};
export type WorkspaceSettings = {
theme: ThemeMode;
/** 请求超时(毫秒) */
timeoutMs: number;
mock: MockSettings;
};
export type WorkspaceData = {
+1 -1
View File
@@ -78,7 +78,7 @@ export function OpsxWorkspace() {
{dialog?.type === "import" && <ImportDialog />}
{dialog?.type === "export" && <ExportDialog collectionId={dialog.collectionId} />}
{dialog?.type === "environments" && <EnvironmentsDialog />}
{dialog?.type === "settings" && <SettingsDialog />}
{dialog?.type === "settings" && <SettingsDialog tab={dialog.tab} />}
{dialog?.type === "rename" && <RenameDialog id={dialog.id} />}
</div>
);
+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\" />",
+21 -13
View File
@@ -6,9 +6,10 @@ import type {
GraphqlRequestData,
HttpRequestData,
HttpSpec,
MockSettings,
RequestNode,
} from "@opsxapi/core";
import { replaceVariables } from "@opsxapi/core";
import { applyMockToJson, applyMockToValue, replaceVariables } from "@opsxapi/core";
import {
connectMqtt,
connectSocketIo,
@@ -34,15 +35,17 @@ export function hasSession(id: string): boolean {
return sessions.has(id);
}
/** KV 行 → 对象(含变量替换) */
/** KV 行 → 对象(含变量替换;传 mock 时对值做 @表达式/智能匹配生成) */
function kvMap(
rows: { key: string; value: string; enabled: boolean }[],
env: Record<string, string>,
mock?: MockSettings,
): Record<string, string> {
const out: Record<string, string> = {};
for (const r of rows) {
if (!r.enabled || !r.key.trim()) continue;
out[replaceVariables(r.key.trim(), env)] = replaceVariables(r.value, env);
const key = replaceVariables(r.key.trim(), env);
out[key] = applyMockToValue(replaceVariables(r.value, env), mock, key);
}
return out;
}
@@ -80,13 +83,13 @@ function authHeaders(
}
/** 手工构建 multipart/form-data 原始体 */
function buildMultipart(rows: HttpRequestData["bodyForm"], env: Record<string, string>): string {
function buildMultipart(rows: HttpRequestData["bodyForm"], env: Record<string, string>, mock?: MockSettings): string {
const boundary = `----OpsxAPI${Math.random().toString(36).slice(2)}`;
const parts: string[] = [];
for (const row of rows) {
if (!row.enabled || !row.key.trim()) continue;
parts.push(
`--${boundary}\r\nContent-Disposition: form-data; name="${row.key}"\r\n\r\n${replaceVariables(row.value, env)}\r\n`,
`--${boundary}\r\nContent-Disposition: form-data; name="${row.key}"\r\n\r\n${applyMockToValue(replaceVariables(row.value, env), mock, row.key)}\r\n`,
);
}
parts.push(`--${boundary}--\r\n`);
@@ -97,6 +100,7 @@ export function buildHttpSpec(
data: HttpRequestData,
env: Record<string, string>,
timeoutMs: number,
mock?: MockSettings,
): HttpSpec {
const { headers: authH, query: authQ } = authHeaders(data.auth, env);
const headers = { ...kvMap(data.headers, env), ...authH };
@@ -107,18 +111,22 @@ export function buildHttpSpec(
let body: NonNullable<HttpSpec["body"]> = { mode: "none" };
if (data.bodyMode === "urlencoded") {
const encoded = Object.entries(kvMap(data.bodyForm, env))
const encoded = Object.entries(kvMap(data.bodyForm, env, mock))
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
body = { mode: "urlencoded", raw: encoded };
if (!("content-type" in headers)) headers["Content-Type"] = "application/x-www-form-urlencoded";
} else if (data.bodyMode === "form-data") {
const raw = buildMultipart(data.bodyForm, env);
const raw = buildMultipart(data.bodyForm, env, mock);
body = { mode: "form-data", raw };
const m = raw.match(/^----\S+/);
headers["Content-Type"] = `multipart/form-data${m ? `; boundary=${m[0]}` : ""}`;
} else if (data.bodyMode !== "none") {
body = { mode: data.bodyMode, raw: replaceVariables(data.bodyRaw, env) };
const resolved = replaceVariables(data.bodyRaw, env);
body = {
mode: data.bodyMode,
raw: data.bodyMode === "json" ? applyMockToJson(resolved, mock) : applyMockToValue(resolved, mock),
};
if (data.bodyMode === "json" && !("content-type" in headers)) {
headers["Content-Type"] = "application/json";
}
@@ -131,7 +139,7 @@ export function buildHttpSpec(
method: data.method,
url: replaceVariables(data.url, env),
headers,
query: { ...kvMap(data.query, env), ...authQ },
query: { ...kvMap(data.query, env, mock), ...authQ },
body,
timeoutMs,
};
@@ -187,18 +195,18 @@ async function sendHttpLike(
export function sendHttpRequest(node: RequestNode & { kind: "http" }): void {
const env = activeEnvMap();
const { timeoutMs } = useWorkspace.getState().settings;
const spec = buildHttpSpec(node.data, env, timeoutMs);
const { timeoutMs, mock } = useWorkspace.getState().settings;
const spec = buildHttpSpec(node.data, env, timeoutMs, mock);
void sendHttpLike(node.id, node.name, spec, node.data.url);
}
export function sendGraphqlRequest(node: RequestNode & { kind: "graphql" }): void {
const env = activeEnvMap();
const { timeoutMs } = useWorkspace.getState().settings;
const { timeoutMs, mock } = useWorkspace.getState().settings;
const data = node.data as GraphqlRequestData;
let variablesRaw: string = "{}";
try {
variablesRaw = JSON.stringify(JSON.parse(replaceVariables(data.variables || "{}", env)));
variablesRaw = applyMockToJson(replaceVariables(data.variables || "{}", env), mock);
} catch {
variablesRaw = replaceVariables(data.variables || "{}", env);
}
+76 -2
View File
@@ -9,6 +9,7 @@ import type {
FolderNode,
HistoryEntry,
HttpRequestData,
MockRule,
RequestKind,
RequestNode,
ThemeMode,
@@ -18,6 +19,7 @@ import type {
import {
createRequest,
createSeedWorkspace,
DEFAULT_MOCK_RULES,
defaultRequestData,
deepCopyNode,
findNode,
@@ -25,6 +27,7 @@ import {
kv,
moveNode,
newId,
normalizeMockSettings,
uniqueName,
} from "@opsxapi/core";
import { create } from "zustand";
@@ -65,7 +68,7 @@ export type Dialog =
| { type: "import" }
| { type: "export"; collectionId: string }
| { type: "environments" }
| { type: "settings" }
| { type: "settings"; tab?: "appearance" | "network" | "mock" | "data" | "about" }
| { type: "rename"; id: string }
| { type: "confirm"; title: string; message: string; action: () => void };
@@ -130,6 +133,11 @@ type WorkspaceStore = PersistedShape & {
setTheme(theme: ThemeMode): void;
setTimeoutMs(ms: number): void;
setMockOption(key: "enabled" | "smart", value: boolean): void;
updateMockRule(ruleId: string, patch: Partial<MockRule>): void;
addMockRule(): string;
removeMockRule(ruleId: string): void;
resetMockRules(): void;
setRuntime(id: string, patch: Partial<RequestRuntime>): void;
pushLive(id: string, dir: LiveMessage["dir"], text: string, topic?: string): void;
@@ -185,6 +193,7 @@ function loadPersisted(): WorkspaceData | null {
settings: {
theme: data.settings?.theme === "light" ? "light" : "dark",
timeoutMs: data.settings?.timeoutMs ?? 30000,
mock: normalizeMockSettings(data.settings?.mock),
},
};
} catch {
@@ -226,7 +235,7 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
environments: [],
activeEnvironmentId: null,
history: [],
settings: { theme: "dark", timeoutMs: 30000 },
settings: { theme: "dark", timeoutMs: 30000, mock: normalizeMockSettings(null) },
openTabIds: [],
activeTabId: null,
@@ -534,6 +543,71 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
persist();
},
setMockOption(key, value) {
set({ settings: { ...get().settings, mock: { ...get().settings.mock, [key]: value } } });
persist();
},
updateMockRule(ruleId, patch) {
set({
settings: {
...get().settings,
mock: {
...get().settings.mock,
rules: get().settings.mock.rules.map((r) => (r.id === ruleId ? { ...r, ...patch } : r)),
},
},
});
persist();
},
addMockRule() {
const id = newId();
set({
settings: {
...get().settings,
mock: {
...get().settings.mock,
rules: [
...get().settings.mock.rules,
{
id,
enabled: true,
type: "string",
match: "wildcard",
pattern: "*field*",
caseSensitive: false,
rule: "@string(6, 12)",
note: "",
},
],
},
},
});
persist();
return id;
},
removeMockRule(ruleId) {
set({
settings: {
...get().settings,
mock: { ...get().settings.mock, rules: get().settings.mock.rules.filter((r) => r.id !== ruleId) },
},
});
persist();
},
resetMockRules() {
set({
settings: {
...get().settings,
mock: { ...get().settings.mock, rules: DEFAULT_MOCK_RULES.map((r) => ({ ...r })) },
},
});
persist();
},
setRuntime(id, patch) {
const state = get();
set({