feat: 增加mock配置

This commit is contained in:
lenmotion 2026-04-23 18:02:34 +08:00
parent 8d20792fc3
commit 7ae07a6fcb
3 changed files with 804 additions and 134 deletions

View File

@ -21,31 +21,163 @@
.section-card { .section-card {
margin-bottom: 16px; margin-bottom: 16px;
} }
.top-nav {
position: sticky;
top: 0;
z-index: 1100;
background: #fff;
border-bottom: 1px solid #ebeef5;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.top-nav-inner {
max-width: 1200px;
margin: 0 auto;
padding: 10px 16px;
}
.actions { .actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
}
@media (max-width: 1200px) {
.actions {
flex-wrap: wrap; flex-wrap: wrap;
} }
}
.small-text { .small-text {
color: #909399; color: #909399;
font-size: 12px; font-size: 12px;
} }
.table-pagination {
margin-top: 12px;
text-align: right;
}
</style> </style>
</head> </head>
<body> <body>
<div id="app" class="container"> <div id="app">
<div class="top-nav">
<div class="top-nav-inner">
<div class="actions">
<el-button type="primary" @click="saveConfig">保存配置到 config.json</el-button>
<el-button @click="reloadConfig">重载服务内存配置</el-button>
<el-button @click="loadConfig">刷新页面数据</el-button>
</div>
</div>
</div>
<div class="container">
<el-tabs v-model="activeMainTab" class="section-card">
<el-tab-pane label="路由配置" name="routes">
<el-card class="section-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<strong>路由配置routes</strong>
<el-button size="mini" type="primary" @click="openRouteDialogForCreate">新增路由</el-button>
</div>
<el-table :data="pagedRoutes" border>
<el-table-column label="接口名称" min-width="220">
<template slot-scope="scope">
<span>{{ scope.row.apiName || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="请求路径" min-width="260">
<template slot-scope="scope">
<span>{{ scope.row.route }}</span>
</template>
</el-table-column>
<el-table-column label="Mock 文件路径" min-width="300">
<template slot-scope="scope">
<span>{{ scope.row.filePath }}</span>
</template>
</el-table-column>
<el-table-column label="启用" width="90" align="center">
<template slot-scope="scope">
<el-switch v-model="scope.row.enabled"></el-switch>
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template slot-scope="scope">
<el-button
size="mini"
type="primary"
plain
@click="openRouteDialogForEdit(scope.$index)"
>
修改
</el-button>
<el-button size="mini" type="danger" @click="removeRoute(scope.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="table-pagination"
background
layout="total, prev, pager, next"
:current-page="routePagination.currentPage"
:page-size="routePagination.pageSize"
:total="form.routes.length"
@current-change="handleRoutePageChange"
></el-pagination>
<div class="small-text" style="margin-top:8px;">
说明:关闭“启用”后会以 # 注释路由,不参与 mock 命中;切换后请点击“保存配置到 config.json”生效到文件。
</div>
</el-card>
</el-tab-pane>
<el-tab-pane label="Mock 数据配置" name="mocks">
<el-card class="section-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<strong>Mock 数据配置</strong>
<el-button size="mini" type="primary" @click="openMockFileDialogForCreate">新增 Mock 文件</el-button>
</div>
<el-table :data="pagedMockFiles" border>
<el-table-column label="Mock 文件路径" min-width="320">
<template slot-scope="scope">
<span>{{ scope.row.filePath }}</span>
</template>
</el-table-column>
<el-table-column label="内容预览" min-width="420">
<template slot-scope="scope">
<span>{{ getPreviewText(scope.row.content) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template slot-scope="scope">
<el-button size="mini" type="primary" plain @click="openMockFileDialogForEdit(scope.row)">修改</el-button>
<el-button size="mini" type="danger" @click="removeMockFile(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="table-pagination"
background
layout="total, prev, pager, next"
:current-page="mockFilePagination.currentPage"
:page-size="mockFilePagination.pageSize"
:total="mockFiles.length"
@current-change="handleMockFilePageChange"
></el-pagination>
</el-card>
</el-tab-pane>
<el-tab-pane label="基础配置" name="basic">
<el-card class="section-card"> <el-card class="section-card">
<div slot="header"><strong>基础配置</strong></div>
<el-form :model="form.config" label-width="180px"> <el-form :model="form.config" label-width="180px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="Mock 开关"> <el-form-item label="Mock 开关">
<el-switch v-model="form.config.mockEnabled"></el-switch> <el-switch v-model="form.config.mockEnabled"></el-switch>
</el-form-item> </el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="自动重载配置文件"> <el-form-item label="自动重载配置文件">
<el-switch v-model="form.config.reloadOnChange"></el-switch> <el-switch v-model="form.config.reloadOnChange"></el-switch>
</el-form-item> </el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="默认响应 Content-Type"> <el-form-item label="默认响应 Content-Type">
<el-input v-model="form.config.defaultContentType"></el-input> <el-input v-model="form.config.defaultContentType"></el-input>
</el-form-item> </el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="本地代理端口"> <el-form-item label="本地代理端口">
<el-input-number <el-input-number
v-model="form.config.proxyPort" v-model="form.config.proxyPort"
@ -53,9 +185,13 @@
:max="65535" :max="65535"
></el-input-number> ></el-input-number>
</el-form-item> </el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标主机"> <el-form-item label="目标主机">
<el-input v-model="form.config.targetHost"></el-input> <el-input v-model="form.config.targetHost"></el-input>
</el-form-item> </el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标端口"> <el-form-item label="目标端口">
<el-input-number <el-input-number
v-model="form.config.targetPort" v-model="form.config.targetPort"
@ -63,92 +199,110 @@
:max="65535" :max="65535"
></el-input-number> ></el-input-number>
</el-form-item> </el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标 HTTPS"> <el-form-item label="目标 HTTPS">
<el-switch v-model="form.config.targetHttps"></el-switch> <el-switch v-model="form.config.targetHttps"></el-switch>
</el-form-item> </el-form-item>
</el-col>
</el-row>
</el-form> </el-form>
</el-card> </el-card>
</el-tab-pane>
</el-tabs>
<el-card class="section-card"> <el-dialog
<div slot="header" style="display:flex;justify-content:space-between;align-items:center;"> :title="routeDialog.mode === 'edit' ? '修改路由' : '新增路由'"
<strong>路由配置routes</strong> :visible.sync="routeDialog.visible"
<el-button size="mini" type="primary" @click="addRouteRow">新增一行</el-button> width="760px"
</div> >
<el-table :data="form.routes" border> <el-form :model="routeDialog.form" label-width="180px">
<el-table-column label="请求路径" min-width="260"> <el-form-item label="预置接口">
<template slot-scope="scope">
<el-input v-model="scope.row.route" placeholder="/api/example"></el-input>
</template>
</el-table-column>
<el-table-column label="Mock 文件路径" min-width="300">
<template slot-scope="scope">
<el-input
v-model="scope.row.filePath"
placeholder="mock/example.json"
></el-input>
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template slot-scope="scope">
<el-button size="mini" type="danger" @click="removeRoute(scope.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="small-text" style="margin-top:8px;">
说明:如果路由以 # 开头,会作为注释保留,不参与 mock 命中。
</div>
</el-card>
<el-card class="section-card">
<div slot="header"><strong>动态新增接口配置文件</strong></div>
<el-form :model="newRoute" label-width="180px">
<el-form-item label="内容模板">
<el-select <el-select
v-model="newRoute.template" v-model="routeDialog.form.selectedApiRoute"
placeholder="请选择模板" filterable
@change="applyTemplate" clearable
placeholder="可选:从 mock/api-list.json 选择"
@change="onSelectApiRoute"
style="width: 100%;" style="width: 100%;"
> >
<el-option label="自定义内容" value="custom"></el-option> <el-option
<el-option label="基础失败模板" value="basicError"></el-option> v-for="item in apiList"
:key="item.route"
:label="item.name + ' (' + item.route + ')'"
:value="item.route"
></el-option>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="请求路径"> <el-form-item label="接口名称">
<el-input v-model="newRoute.route" placeholder="/api/new/mock"></el-input> <el-input
v-model="routeDialog.form.apiName"
placeholder="例如:获取用户信息"
:disabled="isApiListLocked()"
></el-input>
</el-form-item> </el-form-item>
<el-form-item label="请求路径">
<el-input
v-model="routeDialog.form.route"
placeholder="/api/new/mock"
:disabled="isApiListLocked()"
></el-input>
</el-form-item>
<el-form-item label="Mock 文件">
<el-select
v-model="routeDialog.form.filePath"
filterable
placeholder="请选择 mock 文件"
style="width: 100%;"
>
<el-option
v-for="item in mockFiles"
:key="item.filePath"
:label="item.filePath"
:value="item.filePath"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="是否启用 Mock">
<el-switch v-model="routeDialog.form.enabled"></el-switch>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="routeDialog.visible = false">取消</el-button>
<el-button type="primary" @click="submitRouteDialog">
{{ routeDialog.mode === "edit" ? "保存修改" : "创建路由+文件" }}
</el-button>
</span>
</el-dialog>
<el-dialog
:title="mockFileDialog.mode === 'edit' ? '修改 Mock 文件' : '新增 Mock 文件'"
:visible.sync="mockFileDialog.visible"
width="760px"
>
<el-form :model="mockFileDialog.form" label-width="180px">
<el-form-item label="Mock 文件路径"> <el-form-item label="Mock 文件路径">
<el-input <el-input
v-model="newRoute.filePath" v-model="mockFileDialog.form.filePath"
placeholder="mock/new-api.json" placeholder="mock/new-api.json"
:disabled="newRoute.template === 'basicError'" :disabled="mockFileDialog.mode === 'edit'"
></el-input> ></el-input>
</el-form-item> </el-form-item>
<el-form-item label="文件内容"> <el-form-item label="文件内容">
<el-input <el-input
type="textarea" type="textarea"
:rows="8" :rows="12"
v-model="newRoute.fileContent" v-model="mockFileDialog.form.content"
placeholder='{"code":0,"message":"ok"}' placeholder='{"code":0,"message":"ok"}'
:disabled="newRoute.template === 'basicError'"
></el-input> ></el-input>
</el-form-item> </el-form-item>
<el-form-item label="覆盖已存在文件">
<el-switch
v-model="newRoute.overwrite"
:disabled="newRoute.template === 'basicError'"
></el-switch>
</el-form-item>
</el-form> </el-form>
<el-button type="success" @click="createRouteAndFile">创建路由+文件</el-button> <span slot="footer" class="dialog-footer">
</el-card> <el-button @click="mockFileDialog.visible = false">取消</el-button>
<el-button type="primary" @click="submitMockFileDialog">保存 Mock 文件</el-button>
<el-card> </span>
<div class="actions"> </el-dialog>
<el-button type="primary" @click="saveConfig">保存配置到 config.json</el-button>
<el-button @click="reloadConfig">重载服务内存配置</el-button>
<el-button @click="loadConfig">刷新页面数据</el-button>
</div> </div>
</el-card>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script> <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
@ -158,6 +312,17 @@
el: "#app", el: "#app",
data: function () { data: function () {
return { return {
activeMainTab: "routes",
apiList: [],
mockFiles: [],
routePagination: {
currentPage: 1,
pageSize: 10,
},
mockFilePagination: {
currentPage: 1,
pageSize: 10,
},
form: { form: {
config: { config: {
mockEnabled: true, mockEnabled: true,
@ -171,22 +336,78 @@
}, },
routes: [], routes: [],
}, },
newRoute: { routeDialog: {
template: "custom", visible: false,
mode: "create",
editingIndex: -1,
form: {
apiName: "",
selectedApiRoute: "",
originalRoute: "",
originalRawRoute: "",
route: "", route: "",
filePath: "",
enabled: true,
},
},
mockFileDialog: {
visible: false,
mode: "create",
form: {
filePath: "mock/", filePath: "mock/",
fileContent: "", content: "",
overwrite: false, },
}, },
}; };
}, },
created: function () { created: async function () {
this.loadConfig(); await this.loadApiList();
await this.loadMockFiles();
await this.loadConfig();
}, },
methods: { methods: {
getPreviewText: function (content) {
var text = String(content || "").replace(/\s+/g, " ").trim();
if (!text) return "(空文件)";
return text.length > 120 ? text.slice(0, 120) + "..." : text;
},
getPagedData: function (list, pagination) {
var source = Array.isArray(list) ? list : [];
var pageSize = pagination.pageSize || 10;
var currentPage = pagination.currentPage || 1;
var maxPage = Math.max(1, Math.ceil(source.length / pageSize));
if (currentPage > maxPage) {
currentPage = maxPage;
pagination.currentPage = currentPage;
}
var start = (currentPage - 1) * pageSize;
return source.slice(start, start + pageSize);
},
handleRoutePageChange: function (page) {
this.routePagination.currentPage = page;
},
handleMockFilePageChange: function (page) {
this.mockFilePagination.currentPage = page;
},
findApiByRoute: function (route) {
return (this.apiList || []).find(function (item) {
return item.route === route;
});
},
toRouteArray: function (routesObj) { toRouteArray: function (routesObj) {
return Object.keys(routesObj || {}).map(function (route) { var self = this;
return { route: route, filePath: routesObj[route] }; return Object.keys(routesObj || {}).map(function (rawRoute) {
var enabled = !rawRoute.startsWith("#");
var route = enabled ? rawRoute : rawRoute.replace(/^#+/, "");
var matched = self.findApiByRoute(route);
return {
rawRoute: rawRoute,
route: route,
filePath: routesObj[rawRoute],
apiName: matched ? matched.name : "",
selectedApiRoute: matched ? matched.route : "",
enabled: enabled,
};
}); });
}, },
toRouteObject: function (routeArray) { toRouteObject: function (routeArray) {
@ -195,16 +416,162 @@
var route = (item.route || "").trim(); var route = (item.route || "").trim();
var filePath = (item.filePath || "").trim(); var filePath = (item.filePath || "").trim();
if (route && filePath) { if (route && filePath) {
obj[route] = filePath; var routeKey = item.enabled === false ? "#" + route : route;
obj[routeKey] = filePath;
} }
}); });
return obj; return obj;
}, },
addRouteRow: function () {
this.form.routes.push({ route: "", filePath: "" });
},
removeRoute: function (index) { removeRoute: function (index) {
this.form.routes.splice(index, 1); var actualIndex =
(this.routePagination.currentPage - 1) * this.routePagination.pageSize +
index;
this.form.routes.splice(actualIndex, 1);
},
getDefaultRouteForm: function () {
return {
apiName: "",
selectedApiRoute: "",
originalRoute: "",
originalRawRoute: "",
route: "",
filePath: "",
enabled: true,
};
},
isApiListLocked: function () {
return !!this.routeDialog.form.selectedApiRoute;
},
onSelectApiRoute: function (route) {
var selected = route ? this.findApiByRoute(route) : null;
if (!selected) {
this.routeDialog.form.selectedApiRoute = "";
return;
}
this.routeDialog.form.selectedApiRoute = selected.route;
this.routeDialog.form.route = selected.route;
this.routeDialog.form.apiName = selected.name;
},
openRouteDialogForCreate: function () {
this.routeDialog.mode = "create";
this.routeDialog.editingIndex = -1;
this.routeDialog.form = this.getDefaultRouteForm();
this.routeDialog.visible = true;
},
openRouteDialogForEdit: function (index) {
var actualIndex =
(this.routePagination.currentPage - 1) * this.routePagination.pageSize +
index;
var item =
this.form.routes[actualIndex] || { route: "", filePath: "mock/" };
this.routeDialog.mode = "edit";
this.routeDialog.editingIndex = actualIndex;
var matched = this.findApiByRoute(item.route || "");
this.routeDialog.form = {
apiName: matched ? matched.name : item.apiName || "",
selectedApiRoute: matched ? matched.route : "",
originalRoute: item.route || "",
originalRawRoute: item.rawRoute || item.route || "",
route: item.route || "",
filePath: item.filePath || "mock/",
enabled: item.enabled !== false,
};
this.routeDialog.visible = true;
},
getDefaultMockFileForm: function () {
return {
filePath: "mock/",
content: "",
};
},
openMockFileDialogForCreate: function () {
this.mockFileDialog.mode = "create";
this.mockFileDialog.form = this.getDefaultMockFileForm();
this.mockFileDialog.visible = true;
},
openMockFileDialogForEdit: function (item) {
this.mockFileDialog.mode = "edit";
this.mockFileDialog.form = {
filePath: item.filePath || "mock/",
content: String(item.content || ""),
};
this.mockFileDialog.visible = true;
},
loadMockFiles: async function () {
try {
var resp = await fetch("/__mock-files");
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "加载 mock 文件失败");
}
this.mockFiles = Array.isArray(data.list) ? data.list : [];
this.mockFilePagination.currentPage = 1;
} catch (err) {
this.mockFiles = [];
this.$message.error("加载 mock 文件失败: " + err.message);
}
},
submitMockFileDialog: async function () {
if (!this.mockFileDialog.form.filePath) {
this.$message.error("Mock 文件路径不能为空");
return;
}
try {
var resp = await fetch("/__mock-files", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filePath: this.mockFileDialog.form.filePath,
content: this.mockFileDialog.form.content,
}),
});
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "保存 mock 文件失败");
}
this.$message.success("Mock 文件已保存");
this.mockFileDialog.visible = false;
await this.loadMockFiles();
} catch (err) {
this.$message.error("保存 mock 文件失败: " + err.message);
}
},
removeMockFile: async function (item) {
try {
await this.$confirm(
"确认删除 " + item.filePath + " 吗?删除后引用该文件的路由需要重新选择。",
"提示",
{ type: "warning" },
);
var resp = await fetch("/__mock-files", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filePath: item.filePath }),
});
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "删除 mock 文件失败");
}
this.$message.success("Mock 文件已删除");
await this.loadMockFiles();
} catch (err) {
if (err !== "cancel") {
this.$message.error("删除 mock 文件失败: " + err.message);
}
}
},
loadApiList: async function () {
try {
var resp = await fetch("/__api-list");
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "加载接口列表失败");
}
this.apiList = Array.isArray(data.list) ? data.list : [];
} catch (err) {
this.apiList = [];
this.$message.error("加载接口列表失败: " + err.message);
}
}, },
loadConfig: async function () { loadConfig: async function () {
try { try {
@ -212,6 +579,7 @@
var data = await resp.json(); var data = await resp.json();
this.form.config = Object.assign({}, this.form.config, data.config || {}); this.form.config = Object.assign({}, this.form.config, data.config || {});
this.form.routes = this.toRouteArray(data.routes || {}); this.form.routes = this.toRouteArray(data.routes || {});
this.routePagination.currentPage = 1;
} catch (err) { } catch (err) {
this.$message.error("加载配置失败: " + err.message); this.$message.error("加载配置失败: " + err.message);
} }
@ -249,42 +617,47 @@
this.$message.error("重载失败: " + err.message); this.$message.error("重载失败: " + err.message);
} }
}, },
createRouteAndFile: async function () { submitRouteDialog: async function () {
if (!this.routeDialog.form.route) {
this.$message.error("请求路径不能为空");
return;
}
if (!this.routeDialog.form.filePath) {
this.$message.error("请选择 Mock 文件");
return;
}
var payload = Object.assign({}, this.routeDialog.form, {
useExistingFile: true,
});
try { try {
var resp = await fetch("/__routes", { var resp = await fetch("/__routes", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify( body: JSON.stringify(payload),
Object.assign({}, this.newRoute, {
useExistingFile: this.newRoute.template === "basicError",
}),
),
}); });
var data = await resp.json(); var data = await resp.json();
if (!resp.ok || data.success === false) { if (!resp.ok || data.success === false) {
throw new Error(data.error || "创建失败"); throw new Error(data.error || "创建失败");
} }
this.$message.success("接口配置文件创建成功"); this.$message.success(
this.newRoute.template = "custom"; this.routeDialog.mode === "edit"
this.newRoute.route = ""; ? "路由修改成功"
this.newRoute.filePath = "mock/"; : "接口配置文件创建成功",
this.newRoute.fileContent = ""; );
this.newRoute.overwrite = false; this.routeDialog.visible = false;
this.loadConfig(); await this.loadMockFiles();
await this.loadConfig();
} catch (err) { } catch (err) {
this.$message.error("创建失败: " + err.message); this.$message.error("创建失败: " + err.message);
} }
}, },
applyTemplate: function (template) { },
if (template === "basicError") { computed: {
this.newRoute.filePath = "mock/basic-error.json"; pagedRoutes: function () {
this.newRoute.fileContent = return this.getPagedData(this.form.routes, this.routePagination);
'{"code": -1, "success": false, "msg":"失败"}'; },
this.newRoute.overwrite = false; pagedMockFiles: function () {
return; return this.getPagedData(this.mockFiles, this.mockFilePagination);
}
this.newRoute.filePath = "mock/";
this.newRoute.fileContent = "";
}, },
}, },
}); });

View File

@ -6,6 +6,8 @@ import * as zlib from "zlib";
// 配置文件路径 // 配置文件路径
const CONFIG_FILE = path.join(__dirname, "config.json"); const CONFIG_FILE = path.join(__dirname, "config.json");
const API_LIST_FILE = path.join(__dirname, "mock", "api-list.json");
const MOCK_DIR = path.join(__dirname, "mock");
// 定义类型 // 定义类型
interface RouteConfig { interface RouteConfig {
@ -31,6 +33,16 @@ interface ConfigFile {
config: AppConfig; config: AppConfig;
} }
interface ApiListItem {
name: string;
route: string;
}
interface MockFileItem {
filePath: string;
content: string;
}
// 存储当前的路由配置 // 存储当前的路由配置
let MOCK_ROUTES: RouteConfig = {}; let MOCK_ROUTES: RouteConfig = {};
let RAW_ROUTES: RouteConfig = {}; let RAW_ROUTES: RouteConfig = {};
@ -160,6 +172,93 @@ function saveConfigFile(nextConfig: ConfigFile): void {
CONFIG = nextConfig.config || CONFIG; CONFIG = nextConfig.config || CONFIG;
} }
function loadApiList(): ApiListItem[] {
if (!fs.existsSync(API_LIST_FILE)) {
return [];
}
try {
const text = fs.readFileSync(API_LIST_FILE, "utf-8").trim();
if (!text) {
return [];
}
const parsed = JSON.parse(text);
if (!Array.isArray(parsed)) {
return [];
}
const seenRoutes = new Set<string>();
const list: ApiListItem[] = [];
for (const item of parsed) {
const route = String(item?.route || "").trim();
const name = String(item?.name || "").trim();
if (!route || !route.startsWith("/") || !name || seenRoutes.has(route)) {
continue;
}
seenRoutes.add(route);
list.push({ name, route });
}
return list;
} catch (error) {
console.warn("[ADMIN] 读取 api-list.json 失败:", error);
return [];
}
}
function normalizeMockFilePath(filePath: string): string {
const trimmed = filePath.trim().replace(/\\/g, "/");
if (!trimmed) {
throw new Error("filePath is required");
}
const relative = trimmed.startsWith("mock/") ? trimmed : `mock/${trimmed}`;
if (path.isAbsolute(relative)) {
throw new Error("filePath must be a relative path");
}
return relative;
}
function resolveMockFullPath(relativePath: string): string {
const normalized = normalizeMockFilePath(relativePath);
const fullPath = path.resolve(__dirname, normalized);
const mockRoot = path.resolve(MOCK_DIR);
if (!fullPath.startsWith(mockRoot)) {
throw new Error("filePath is invalid");
}
if (path.basename(fullPath) === "api-list.json") {
throw new Error("api-list.json is read-only in this panel");
}
return fullPath;
}
function walkMockFiles(dir: string, baseDir: string, result: string[]): void {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkMockFiles(fullPath, baseDir, result);
continue;
}
if (!entry.isFile()) continue;
if (entry.name === "api-list.json") continue;
const relative = path.relative(baseDir, fullPath).replace(/\\/g, "/");
result.push(`mock/${relative}`);
}
}
function loadMockFiles(): MockFileItem[] {
if (!fs.existsSync(MOCK_DIR)) {
return [];
}
const files: string[] = [];
walkMockFiles(MOCK_DIR, MOCK_DIR, files);
files.sort((a, b) => a.localeCompare(b));
return files.map((filePath) => {
const fullPath = path.join(__dirname, filePath);
return {
filePath,
content: fs.readFileSync(fullPath, "utf-8"),
};
});
}
function readBody(req: http.IncomingMessage): Promise<string> { function readBody(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
@ -375,28 +474,48 @@ const proxyServer = http.createServer((clientReq, clientRes) => {
readBody(clientReq) readBody(clientReq)
.then((bodyText) => { .then((bodyText) => {
const body = bodyText ? JSON.parse(bodyText) : {}; const body = bodyText ? JSON.parse(bodyText) : {};
const route = String(body.route || "").trim(); let route = String(body.route || "").trim();
const filePath = String(body.filePath || "").trim(); const filePath = String(body.filePath || "").trim();
const fileContent = String(body.fileContent || ""); const fileContent = String(body.fileContent || "");
const overwrite = body.overwrite === true; const overwrite = body.overwrite === true;
const template = String(body.template || "").trim(); const template = String(body.template || "").trim();
const apiName = String(body.apiName || "").trim();
const selectedApiRoute = String(body.selectedApiRoute || "").trim();
const originalRoute = String(body.originalRoute || "").trim();
const originalRawRoute = String(body.originalRawRoute || "").trim();
const enabled = body.enabled !== false;
const useExistingFile = const useExistingFile =
body.useExistingFile === true || template === "basicError"; body.useExistingFile === true || template === "basicError";
const apiList = loadApiList();
const selectedApi = selectedApiRoute
? apiList.find((item) => item.route === selectedApiRoute)
: undefined;
const originalApi = originalRoute
? apiList.find((item) => item.route === originalRoute)
: undefined;
// 来自 api-list 的接口路由不可在新增/修改时变更
if (selectedApi) {
if (route && route !== selectedApi.route) {
throw new Error("api-list route cannot be changed");
}
if (apiName && apiName !== selectedApi.name) {
throw new Error("api-list name cannot be changed");
}
route = selectedApi.route;
}
if (originalApi && route !== originalApi.route) {
throw new Error("api-list route cannot be changed");
}
if (originalApi && apiName && apiName !== originalApi.name) {
throw new Error("api-list name cannot be changed");
}
if (!route.startsWith("/")) { if (!route.startsWith("/")) {
throw new Error("route must start with '/'"); throw new Error("route must start with '/'");
} }
if (!filePath) { const normalizedFilePath = normalizeMockFilePath(filePath);
throw new Error("filePath is required"); const fullPath = resolveMockFullPath(normalizedFilePath);
}
if (path.isAbsolute(filePath)) {
throw new Error("filePath must be a relative path");
}
const fullPath = path.join(__dirname, filePath);
if (!fullPath.startsWith(__dirname)) {
throw new Error("filePath is invalid");
}
if (useExistingFile) { if (useExistingFile) {
if (!fs.existsSync(fullPath)) { if (!fs.existsSync(fullPath)) {
throw new Error("mock file does not exist"); throw new Error("mock file does not exist");
@ -412,7 +531,21 @@ const proxyServer = http.createServer((clientReq, clientRes) => {
} }
const nextConfig = buildCurrentConfigFile(); const nextConfig = buildCurrentConfigFile();
nextConfig.routes[route] = filePath; const nextRouteKey = enabled ? route : `#${route}`;
const oldRouteCandidates = new Set<string>();
if (originalRawRoute) {
oldRouteCandidates.add(originalRawRoute);
}
if (originalRoute) {
oldRouteCandidates.add(originalRoute);
oldRouteCandidates.add(`#${originalRoute}`);
}
oldRouteCandidates.forEach((key) => {
if (key && key !== nextRouteKey) {
delete nextConfig.routes[key];
}
});
nextConfig.routes[nextRouteKey] = normalizedFilePath;
saveConfigFile(nextConfig); saveConfigFile(nextConfig);
clientRes.writeHead(200, { "Content-Type": "application/json" }); clientRes.writeHead(200, { "Content-Type": "application/json" });
@ -421,7 +554,111 @@ const proxyServer = http.createServer((clientReq, clientRes) => {
success: true, success: true,
message: "Route and mock file created successfully", message: "Route and mock file created successfully",
route, route,
filePath, routeKey: nextRouteKey,
filePath: normalizedFilePath,
enabled,
}),
);
})
.catch((error) => {
clientRes.writeHead(400, { "Content-Type": "application/json" });
clientRes.end(
JSON.stringify({
success: false,
error: (error as Error).message,
}),
);
});
return;
}
if (requestPath === "/__api-list") {
if (clientReq.method !== "GET") {
clientRes.writeHead(405, {
"Content-Type": "application/json",
Allow: "GET",
});
clientRes.end(
JSON.stringify({
success: false,
error: "Method Not Allowed",
allow: ["GET"],
}),
);
return;
}
clientRes.writeHead(200, { "Content-Type": "application/json" });
clientRes.end(
JSON.stringify({
success: true,
list: loadApiList(),
}),
);
return;
}
if (requestPath === "/__mock-files") {
if (
clientReq.method !== "GET" &&
clientReq.method !== "POST" &&
clientReq.method !== "DELETE"
) {
clientRes.writeHead(405, {
"Content-Type": "application/json",
Allow: "GET, POST, DELETE",
});
clientRes.end(
JSON.stringify({
success: false,
error: "Method Not Allowed",
allow: ["GET", "POST", "DELETE"],
}),
);
return;
}
if (clientReq.method === "GET") {
clientRes.writeHead(200, { "Content-Type": "application/json" });
clientRes.end(
JSON.stringify({
success: true,
list: loadMockFiles(),
}),
);
return;
}
readBody(clientReq)
.then((bodyText) => {
const body = bodyText ? JSON.parse(bodyText) : {};
const filePath = String(body.filePath || "").trim();
const fullPath = resolveMockFullPath(filePath);
const normalizedPath = normalizeMockFilePath(filePath);
if (clientReq.method === "POST") {
const content = String(body.content || "");
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, "utf-8");
clientRes.writeHead(200, { "Content-Type": "application/json" });
clientRes.end(
JSON.stringify({
success: true,
filePath: normalizedPath,
}),
);
return;
}
// DELETE
if (!fs.existsSync(fullPath)) {
throw new Error("mock file does not exist");
}
fs.unlinkSync(fullPath);
clientRes.writeHead(200, { "Content-Type": "application/json" });
clientRes.end(
JSON.stringify({
success: true,
filePath: normalizedPath,
}), }),
); );
}) })

60
mock/api-list.json Normal file
View File

@ -0,0 +1,60 @@
[
{ "name": "登录/注册发送验证码", "route": "/api1/authentication/sms/send" },
{ "name": "绑定手机号发送验证码", "route": "/api1/authentication/bind/send" },
{ "name": "账号注销", "route": "/api1/account/revoke" },
{ "name": "绑定手机号", "route": "/api1/account/bind" },
{ "name": "认证授权刷新接口", "route": "/api1/oauth2/token" },
{ "name": "一键登录阿里云授权码", "route": "/api1/authentication/common" },
{ "name": "更新用户信息", "route": "/api2/user/update" },
{ "name": "获取用户信息", "route": "/api2/user" },
{ "name": "我的链接", "route": "/api2/link" },
{ "name": "获取字典类型", "route": "/api2/dict/types" },
{ "name": "获取字典项", "route": "/api2/dict/" },
{ "name": "获取设备列表", "route": "/api2/device/list" },
{ "name": "根据类型获取设备列表", "route": "/api2/device/type/list" },
{ "name": "查询历史设备列表", "route": "/api2/device/history/list" },
{ "name": "绑定设备", "route": "/api2/device/save" },
{ "name": "切换设备", "route": "/api2/device/switch" },
{ "name": "用机人主设备", "route": "/api2/device/master" },
{ "name": "解绑设备", "route": "/api2/device/unbind" },
{ "name": "获取设备信息", "route": "/api2/device/one" },
{ "name": "获取设备店铺", "route": "/api2/device/store" },
{ "name": "获取设备图片", "route": "/api2/device/bind/img" },
{ "name": "查询设备报告日期", "route": "/api2/device/report/date" },
{ "name": "查询是否有报告页", "route": "/api2/device/reports" },
{ "name": "查询使用教程列表", "route": "/api2/course/list" },
{ "name": "查询白脸教程列表", "route": "/api2/course/white/face/list" },
{ "name": "获取省份城市列表", "route": "/api2/sys/provinces" },
{ "name": "制氧机报告单天", "route": "/api2/oxygenerator/report/day" },
{ "name": "制氧机报告多天", "route": "/api2/oxygenerator/report/multi/day" },
{ "name": "血氧仪报告单天", "route": "/api2/oximeter/report/day" },
{ "name": "血氧仪报告多天", "route": "/api2/oximeter/report/multi/day" },
{ "name": "呼吸机报告单天", "route": "/api2/ventilator/report/day" },
{ "name": "呼吸机报告多天", "route": "/api2/ventilator/report/multi/day" },
{ "name": "血氧仪健康报告单天", "route": "/api2/oximeter/health/report/day" },
{ "name": "血氧仪健康报告多天", "route": "/api2/oximeter/health/report/multi/day" },
{ "name": "血氧仪报告详情", "route": "/api2/oximeter/report/detail" },
{ "name": "首页判断", "route": "/api2/home/flag" },
{ "name": "首页Banner", "route": "/api2/home/ad/list" },
{ "name": "首页卡片信息", "route": "/api2/home/card/info" },
{ "name": "修改首页卡片", "route": "/api2/home/card/update" },
{ "name": "协议列表", "route": "/api2/protocol/list" },
{ "name": "同意协议", "route": "/api2/protocol/agree" },
{ "name": "协议是否更新", "route": "/api2/protocol/has/update" },
{ "name": "扫码获取报告", "route": "/api2/qr/scan" },
{ "name": "查询QR报告详情", "route": "/api2/qr/one" },
{ "name": "查询QR报告列表", "route": "/api2/qr/page" },
{ "name": "获取报告数量", "route": "/api2/qr/count" },
{ "name": "检查健康自测人数", "route": "/api2/self/check/head/count" },
{ "name": "健康自测结果", "route": "/api2/self/check/save" },
{ "name": "科普分类列表", "route": "/api2/health/category/list" },
{ "name": "科普列表", "route": "/api2/health/service/list" },
{ "name": "科普详情", "route": "/api2/health/service/getById" },
{ "name": "deepSeek流式接口", "route": "/api2/deepSeek/stream" }
]