From 9a019408ab4244fe4f9054da6c1cd0c755bf3078 Mon Sep 17 00:00:00 2001 From: logan Date: Fri, 18 Sep 2026 01:35:54 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E4=B8=AD=E5=BF=83=E4=B8=8E?= =?UTF-8?q?=20Mock=20=E6=95=B0=E6=8D=AE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 顶栏:移除 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 前端用例全绿) --- apps/server/internal/docs/content/14-mock.md | 72 +++ .../content/{14-manage.md => 15-manage.md} | 0 .../{15-server-api.md => 16-server-api.md} | 0 .../docs/content/{16-faq.md => 17-faq.md} | 0 .../internal/docs/content/manifest.json | 118 +++- apps/web/src/styles.css | 105 ++++ packages/core/src/factory.ts | 3 +- packages/core/src/index.ts | 18 + packages/core/src/mock.test.ts | 152 ++++++ packages/core/src/mock.ts | 513 ++++++++++++++++++ packages/core/src/model.ts | 30 + packages/ui/src/App.tsx | 2 +- packages/ui/src/components/TopBar.tsx | 89 ++- .../components/dialogs/WorkspaceDialogs.tsx | 476 ++++++++++++++-- .../ui/src/components/editors/HttpEditor.tsx | 82 ++- packages/ui/src/components/icons.tsx | 16 + packages/ui/src/send.ts | 34 +- packages/ui/src/workspaceStore.ts | 78 ++- 18 files changed, 1684 insertions(+), 104 deletions(-) create mode 100644 apps/server/internal/docs/content/14-mock.md rename apps/server/internal/docs/content/{14-manage.md => 15-manage.md} (100%) rename apps/server/internal/docs/content/{15-server-api.md => 16-server-api.md} (100%) rename apps/server/internal/docs/content/{16-faq.md => 17-faq.md} (100%) create mode 100644 packages/core/src/mock.test.ts create mode 100644 packages/core/src/mock.ts diff --git a/apps/server/internal/docs/content/14-mock.md b/apps/server/internal/docs/content/14-mock.md new file mode 100644 index 0000000..ac2d6eb --- /dev/null +++ b/apps/server/internal/docs/content/14-mock.md @@ -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 在**发送瞬间**求值,同一请求每次发送都会得到新数据;预览结果仅供核对格式。 diff --git a/apps/server/internal/docs/content/14-manage.md b/apps/server/internal/docs/content/15-manage.md similarity index 100% rename from apps/server/internal/docs/content/14-manage.md rename to apps/server/internal/docs/content/15-manage.md diff --git a/apps/server/internal/docs/content/15-server-api.md b/apps/server/internal/docs/content/16-server-api.md similarity index 100% rename from apps/server/internal/docs/content/15-server-api.md rename to apps/server/internal/docs/content/16-server-api.md diff --git a/apps/server/internal/docs/content/16-faq.md b/apps/server/internal/docs/content/17-faq.md similarity index 100% rename from apps/server/internal/docs/content/16-faq.md rename to apps/server/internal/docs/content/17-faq.md diff --git a/apps/server/internal/docs/content/manifest.json b/apps/server/internal/docs/content/manifest.json index b04288a..4e05818 100644 --- a/apps/server/internal/docs/content/manifest.json +++ b/apps/server/internal/docs/content/manifest.json @@ -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" + } ] } ] diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index e307f96..da27321 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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 { diff --git a/packages/core/src/factory.ts b/packages/core/src/factory.ts index c473deb..faafd10 100644 --- a/packages/core/src/factory.ts +++ b/packages/core/src/factory.ts @@ -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) }, }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fcb0c02..a07201d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/mock.test.ts b/packages/core/src/mock.test.ts new file mode 100644 index 0000000..d113dbe --- /dev/null +++ b/packages/core/src/mock.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/mock.ts b/packages/core/src/mock.ts new file mode 100644 index 0000000..9212d35 --- /dev/null +++ b/packages/core/src/mock.ts @@ -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 = (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) : {}; + 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 = {}; + for (const [k, v] of Object.entries(node as Record)) { + 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 = {}; + for (const [k, v] of Object.entries(node as Record)) { + 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: "从列表选择" }, +]; diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 9911ca9..aa0ea3c 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -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 = { diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index d15261c..5dc5292 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -78,7 +78,7 @@ export function OpsxWorkspace() { {dialog?.type === "import" && } {dialog?.type === "export" && } {dialog?.type === "environments" && } - {dialog?.type === "settings" && } + {dialog?.type === "settings" && } {dialog?.type === "rename" && } ); diff --git a/packages/ui/src/components/TopBar.tsx b/packages/ui/src/components/TopBar.tsx index a081eb7..e7fd940 100644 --- a/packages/ui/src/components/TopBar.tsx +++ b/packages/ui/src/components/TopBar.tsx @@ -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: , + onClick: () => setDialog({ type: "settings" }), + }, + { + label: "Mock 数据…", + icon: , + onClick: () => setDialog({ type: "settings", tab: "mock" }), + }, + { + label: "环境管理…", + icon: , + onClick: () => setDialog({ type: "environments" }), + }, + { + label: settings.theme === "dark" ? "切换到浅色主题" : "切换到深色主题", + icon: , + onClick: () => setTheme(settings.theme === "dark" ? "light" : "dark"), + }, + { type: "sep" as const }, + { + label: ( + + 用户文档 + + + ), + icon: , + onClick: () => window.open("/docs", "_blank", "noopener,noreferrer"), + }, + { + label: ( + + 官网与价格 + + + ), + icon: , + onClick: () => window.open("/home", "_blank", "noopener,noreferrer"), + }, + { type: "sep" as const }, + { + label: "重置工作区…", + icon: , + danger: true, + onClick: () => + setDialog({ + type: "confirm", + title: "重置工作区", + message: "将恢复到初始示例数据,当前集合 / 环境 / 历史会被覆盖(建议先在「导出」中生成全量备份)。确定继续吗?", + action: () => useWorkspace.getState().resetWorkspace(), + }), + }, + ]; + return (
文档 - + - - {transportKind} - + setMoreMenu(null)} + /> setNewMenu(null)} diff --git a/packages/ui/src/components/dialogs/WorkspaceDialogs.tsx b/packages/ui/src/components/dialogs/WorkspaceDialogs.tsx index aedfb61..441a240 100644 --- a/packages/ui/src/components/dialogs/WorkspaceDialogs.tsx +++ b/packages/ui/src/components/dialogs/WorkspaceDialogs.tsx @@ -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 ( +
+
+
{label}
+ {desc ?
{desc}
: null} +
+
{children}
+
+ ); +} + +function SettingGroup({ title, children }: { title?: string; children: React.ReactNode }) { + return ( +
+ {title ?
{title}
: null} + {children} +
+ ); +} + +function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { + return ( + + ); +} + +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(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 ( setDialog(null)} + width="860px" footer={ - +
+ 修改即时生效并自动保存 + +
} > -
-
- -
- {(["dark", "light"] as const).map((t) => ( - - ))} -
-
+
+ -
- - setTimeoutMs(Number(e.target.value) || 30000)} - /> -
- -
- -
- 所有集合与环境保存在浏览器 localStorage;全量备份请在「导出 → 全量备份」中生成。 +
+
+
{currentMeta.label}
+
{currentMeta.desc}
+
+ +
+ {active === "appearance" && ( + <> + + +
+ {(["dark", "light"] as const).map((t) => ( + + ))} +
+
+
+ + +
+ {environments.slice(0, 6).map((e) => ( + + ))} +
+
+
+ + )} + + {active === "network" && ( + <> + + + setTimeoutMs(Number(e.target.value) || 30000)} + /> + + + 自动 + + + + + 自动管理 + + + + )} + + {active === "mock" && ( + <> + + + setMockOption("enabled", v)} /> + + + setMockOption("smart", v)} /> + + + + +
+ + + 按从上到下的顺序匹配,先命中先用;* 通配任意字符,类型需与 JSON 字段一致。 + + 语法速查 → + + +
+
+ setRuleSearch(e.target.value)} + /> + {mock.rules.length} 条 +
+ + +
+
+ +
+
+ 数据类型 + 匹配方式 + 匹配规则 + Mock 表达式 + 备注 + 启用 + +
+ {filteredRules.map((r) => ( +
+ + + updateMockRule(r.id, { pattern: e.target.value })} + /> + updateMockRule(r.id, { rule: e.target.value })} + /> + updateMockRule(r.id, { note: e.target.value })} + /> + updateMockRule(r.id, { enabled: v })} + /> + +
+ ))} + {filteredRules.length === 0 && ( +
没有匹配的规则
+ )} +
+
+ + +
+ setPreviewRule(e.target.value)} + /> + {previewRule.trim().startsWith("@") && ( + {generateMock(previewRule.trim())} + )} +
+
+ {MOCK_CHEATSHEET.map((c) => ( + + ))} +
+
+ + )} + + {active === "data" && ( + <> + + + 浏览器本地 + + + + {collections.length} 个集合 · {totalRequests} 个接口 · {environments.length} 个环境 + + + + + + + + + + + + + )} + + {active === "about" && ( + <> + +
+ +
+
OpsxAPI
+
专业接口调试工作台
+
+ HTTP · GraphQL · WebSocket · SSE · Socket.IO · MQTT +
+
+
+
+ + + + 打开 + + + + + 打开 + + + + + 查看 + + + + + + + lucide.dev + + + + + )}
-
diff --git a/packages/ui/src/components/editors/HttpEditor.tsx b/packages/ui/src/components/editors/HttpEditor.tsx index 31b232e..744a918 100644 --- a/packages/ui/src/components/editors/HttpEditor.tsx +++ b/packages/ui/src/components/editors/HttpEditor.tsx @@ -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) => void; }) { const mode = data.bodyMode as BodyTab; + const mock = useWorkspace((s) => s.settings.mock); + const [mockPreview, setMockPreview] = useState(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 (
@@ -278,24 +303,47 @@ function BodyEditor({ 格式化 )} + {mode !== "none" && ( +
+ + +
+ )}
{mode === "none" &&
该请求不携带请求体
} {RAW_BODY_MODES.includes(mode) && ( -