api-proxy-mock/admin.html

754 lines
28 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>API Proxy Mock 配置管理</title>
<link
rel="stylesheet"
href="https://unpkg.com/element-ui/lib/theme-chalk/index.css"
/>
<style>
body {
margin: 0;
background: #f5f7fa;
}
.container {
max-width: 1320px;
margin: 20px auto;
padding: 0 16px 24px;
}
.section-card {
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 {
display: flex;
gap: 8px;
}
@media (max-width: 1200px) {
.actions {
flex-wrap: wrap;
}
}
.small-text {
color: #909399;
font-size: 12px;
}
.table-pagination {
margin-top: 12px;
text-align: right;
}
.route-table .el-table__cell .cell {
white-space: nowrap;
}
</style>
</head>
<body>
<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 class="route-table" style="width: 100%;">
<el-table-column label="接口名称" min-width="180" show-overflow-tooltip>
<template slot-scope="scope">
<span>{{ scope.row.apiName || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="请求路径" min-width="220" show-overflow-tooltip>
<template slot-scope="scope">
<span>{{ scope.row.route }}</span>
</template>
</el-table-column>
<el-table-column label="Mock 文件路径" min-width="240" show-overflow-tooltip>
<template slot-scope="scope">
<span>{{ scope.row.filePath }}</span>
</template>
</el-table-column>
<el-table-column label="返回状态码" width="120" align="center">
<template slot-scope="scope">
<span>{{ scope.row.statusCode || 200 }}</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="160">
<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="别名" min-width="180">
<template slot-scope="scope">
<span>{{ scope.row.alias || "-" }}</span>
</template>
</el-table-column>
<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-form :model="form.config" label-width="180px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="Mock 开关">
<el-switch v-model="form.config.mockEnabled"></el-switch>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="自动重载配置文件">
<el-switch v-model="form.config.reloadOnChange"></el-switch>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="默认响应 Content-Type">
<el-input v-model="form.config.defaultContentType"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="本地代理端口">
<el-input-number
v-model="form.config.proxyPort"
:min="1"
:max="65535"
></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标主机">
<el-input v-model="form.config.targetHost"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标端口">
<el-input-number
v-model="form.config.targetPort"
:min="1"
:max="65535"
></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标 HTTPS">
<el-switch v-model="form.config.targetHttps"></el-switch>
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-card>
</el-tab-pane>
</el-tabs>
<el-dialog
:title="routeDialog.mode === 'edit' ? '修改路由' : '新增路由'"
:visible.sync="routeDialog.visible"
width="760px"
>
<el-form :model="routeDialog.form" label-width="180px">
<el-form-item label="预置接口">
<el-select
v-model="routeDialog.form.selectedApiRoute"
filterable
clearable
placeholder="可选:从 mock/api-list.json 选择"
@change="onSelectApiRoute"
style="width: 100%;"
>
<el-option
v-for="item in apiList"
:key="item.route"
:label="item.name + ' (' + item.route + ')'"
:value="item.route"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="接口名称">
<el-input
v-model="routeDialog.form.apiName"
placeholder="例如:获取用户信息"
:disabled="isApiListLocked()"
></el-input>
</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.alias ? item.alias + ' (' + item.filePath + ')' : item.filePath"
:value="item.filePath"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="返回状态码">
<el-select
v-model="routeDialog.form.statusCode"
filterable
allow-create
default-first-option
clearable
placeholder="先选常用状态码,也可直接输入"
style="width: 100%;"
>
<el-option
v-for="item in commonStatusCodes"
:key="item.value"
:label="item.label"
:value="item.value"
></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="别名">
<el-input
v-model="mockFileDialog.form.alias"
placeholder="可选:用于展示,支持任意文本"
></el-input>
</el-form-item>
<el-form-item label="Mock 文件路径">
<el-input
v-model="mockFileDialog.form.filePath"
placeholder="mock/test123.json路径仅英文和数字"
:disabled="mockFileDialog.mode === 'edit'"
></el-input>
</el-form-item>
<el-form-item label="文件内容">
<el-input
type="textarea"
:rows="12"
v-model="mockFileDialog.form.content"
placeholder='{"code":0,"message":"ok"}'
></el-input>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="mockFileDialog.visible = false">取消</el-button>
<el-button type="primary" @click="submitMockFileDialog">保存 Mock 文件</el-button>
</span>
</el-dialog>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<script>
new Vue({
el: "#app",
data: function () {
return {
activeMainTab: "routes",
apiList: [],
mockFiles: [],
commonStatusCodes: [
{ value: 200, label: "200 OK" },
{ value: 201, label: "201 Created" },
{ value: 204, label: "204 No Content" },
{ value: 400, label: "400 Bad Request" },
{ value: 401, label: "401 Unauthorized" },
{ value: 403, label: "403 Forbidden" },
{ value: 404, label: "404 Not Found" },
{ value: 409, label: "409 Conflict" },
{ value: 422, label: "422 Unprocessable Entity" },
{ value: 429, label: "429 Too Many Requests" },
{ value: 500, label: "500 Internal Server Error" },
{ value: 501, label: "501 Not Implemented" },
{ value: 502, label: "502 Bad Gateway" },
{ value: 503, label: "503 Service Unavailable" },
],
routePagination: {
currentPage: 1,
pageSize: 10,
},
mockFilePagination: {
currentPage: 1,
pageSize: 10,
},
form: {
config: {
mockEnabled: true,
cacheConfig: true,
reloadOnChange: true,
defaultContentType: "application/json",
proxyPort: 8877,
targetHost: "",
targetPort: 443,
targetHttps: true,
},
routes: [],
},
routeDialog: {
visible: false,
mode: "create",
editingIndex: -1,
form: {
apiName: "",
selectedApiRoute: "",
originalRoute: "",
originalRawRoute: "",
route: "",
filePath: "",
statusCode: 200,
enabled: true,
},
},
mockFileDialog: {
visible: false,
mode: "create",
form: {
filePath: "mock/",
alias: "",
content: "",
},
},
};
},
created: async function () {
await this.loadApiList();
await this.loadMockFiles();
await this.loadConfig();
},
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);
},
normalizeStatusCode: function (value) {
var numeric = Number(value);
if (Number.isInteger(numeric) && numeric >= 100 && numeric <= 599) {
return numeric;
}
return 200;
},
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, routeStatusesObj) {
var self = this;
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],
statusCode: self.normalizeStatusCode(
routeStatusesObj && routeStatusesObj[rawRoute],
),
apiName: matched ? matched.name : "",
selectedApiRoute: matched ? matched.route : "",
enabled: enabled,
};
});
},
toRouteObject: function (routeArray) {
var obj = {};
(routeArray || []).forEach(function (item) {
var route = (item.route || "").trim();
var filePath = (item.filePath || "").trim();
if (route && filePath) {
var routeKey = item.enabled === false ? "#" + route : route;
obj[routeKey] = filePath;
}
});
return obj;
},
toRouteStatusObject: function (routeArray) {
var self = this;
var obj = {};
(routeArray || []).forEach(function (item) {
var route = (item.route || "").trim();
if (!route) return;
var routeKey = item.enabled === false ? "#" + route : route;
var statusCode = self.normalizeStatusCode(item.statusCode);
obj[routeKey] = statusCode;
});
return obj;
},
removeRoute: function (index) {
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: "",
statusCode: 200,
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/",
statusCode: this.normalizeStatusCode(item.statusCode),
enabled: item.enabled !== false,
};
this.routeDialog.visible = true;
},
getDefaultMockFileForm: function () {
return {
filePath: "mock/",
alias: "",
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/",
alias: String(item.alias || ""),
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,
alias: this.mockFileDialog.form.alias,
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 () {
try {
var resp = await fetch("/__config");
var data = await resp.json();
this.form.config = Object.assign({}, this.form.config, data.config || {});
this.form.routes = this.toRouteArray(
data.routes || {},
data.routeStatuses || {},
);
this.routePagination.currentPage = 1;
} catch (err) {
this.$message.error("加载配置失败: " + err.message);
}
},
saveConfig: async function () {
var payload = {
config: this.form.config,
routes: this.toRouteObject(this.form.routes),
routeStatuses: this.toRouteStatusObject(this.form.routes),
};
try {
var resp = await fetch("/__config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "保存失败");
}
this.$message.success("配置已保存");
} catch (err) {
this.$message.error("保存失败: " + err.message);
}
},
reloadConfig: async function () {
try {
var resp = await fetch("/__reload-config", { method: "POST" });
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "重载失败");
}
this.$message.success("配置已重载");
this.loadConfig();
} catch (err) {
this.$message.error("重载失败: " + err.message);
}
},
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,
statusCode: this.normalizeStatusCode(this.routeDialog.form.statusCode),
});
try {
var resp = await fetch("/__routes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "创建失败");
}
this.$message.success(
this.routeDialog.mode === "edit"
? "路由修改成功"
: "接口配置文件创建成功",
);
this.routeDialog.visible = false;
await this.loadMockFiles();
await this.loadConfig();
} catch (err) {
this.$message.error("创建失败: " + err.message);
}
},
},
computed: {
pagedRoutes: function () {
return this.getPagedData(this.form.routes, this.routePagination);
},
pagedMockFiles: function () {
return this.getPagedData(this.mockFiles, this.mockFilePagination);
},
},
});
</script>
</body>
</html>