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

+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 = {