api-proxy-mock/admin.html
dongzp 9bec3208f8 feat: 管理员白名单与个性化配置权限控制
系统配置仅允许 admin_list.txt 白名单 IP 编辑;localhost 访问时解析为 LAN IP;个性化配置支持按 IP 权限隔离,新增时自动填充当前 IP。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 11:02:16 +08:00

1870 lines
77 KiB
HTML
Raw 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>企业级数据Mock管理系统 - by tony</title>
<link rel="stylesheet" href="/assets/element-ui/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: 1320px;
margin: 0 auto;
padding: 14px 16px;
}
.top-nav-title {
font-size: 18px;
font-weight: 600;
color: #303133;
letter-spacing: 1px;
}
.small-text {
color: #909399;
font-size: 12px;
}
.table-pagination {
margin-top: 12px;
text-align: right;
}
.route-table .el-table__cell .cell {
white-space: nowrap;
}
.mock-content-textarea textarea {
font-family: Consolas, Monaco, "Courier New", monospace;
line-height: 1.5;
}
.el-dialog.is-fullscreen .el-dialog__body {
overflow-y: auto;
max-height: calc(100vh - 120px);
}
.basic-config-readonly {
pointer-events: none;
opacity: 0.65;
}
</style>
</head>
<body>
<div id="app">
<div class="top-nav">
<div class="top-nav-inner">
<span class="top-nav-title">企业级数据Mock管理系统</span>
</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>
<div style="display:flex;gap:8px;align-items:center;">
<el-input size="small" v-model="routeSearch" placeholder="搜索接口名称或路径" clearable style="width:220px;" prefix-icon="el-icon-search"></el-input>
<el-button size="mini" icon="el-icon-refresh" :loading="routesRefreshing" @click="refreshRoutes">刷新</el-button>
<el-button size="mini" type="primary" @click="openRouteDialogForCreate">新增路由</el-button>
</div>
</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">
<a href="javascript:void(0)" style="color:#409EFF;cursor:pointer;" @click="openMockFileFromRoute(scope.row)">{{ scope.row.filePath }}</a>
</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" @change="saveRoutes"></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="filteredRoutes.length"
@current-change="handleRoutePageChange"
></el-pagination>
</el-card>
</el-tab-pane>
<el-tab-pane label="数据配置" name="mocks">
<el-card class="section-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<strong>数据配置(mock)</strong>
<div style="display:flex;gap:8px;align-items:center;">
<el-select size="small" v-model="mockGroupFilter" clearable placeholder="按分组筛选" style="width:160px;">
<el-option label="全部" :value="null"></el-option>
<el-option label="未分组" :value="-1"></el-option>
<el-option v-for="g in mockGroups" :key="g.id" :label="g.name" :value="g.id"></el-option>
</el-select>
<el-input size="small" v-model="mockFileSearch" placeholder="搜索别名或路径" clearable style="width:220px;" prefix-icon="el-icon-search"></el-input>
<el-button size="mini" icon="el-icon-refresh" :loading="mocksRefreshing" @click="refreshMocks">刷新</el-button>
<el-button size="mini" type="primary" @click="openMockFileDialogForCreate">新增 Mock 文件</el-button>
<el-button size="mini" @click="openMockGroupDialog">分组管理</el-button>
</div>
</div>
<el-table :data="pagedMockFiles" border>
<el-table-column label="分组" width="140">
<template slot-scope="scope">
<span>{{ getGroupName(scope.row.groupId) }}</span>
</template>
</el-table-column>
<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="240">
<template slot-scope="scope">
<el-button size="mini" type="primary" plain @click="openMockFileDialogForEdit(scope.row)">修改</el-button>
<el-button size="mini" type="success" plain @click="openMockFileDialogForCopy(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="filteredMockFiles.length"
@current-change="handleMockFilePageChange"
></el-pagination>
</el-card>
</el-tab-pane>
<el-tab-pane label="接口管理" name="api">
<el-card class="section-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<strong>接口列表</strong>
<div style="display:flex;gap:8px;align-items:center;">
<el-input size="small" v-model="apiSearch" placeholder="搜索接口名称或路径" clearable style="width:220px;" prefix-icon="el-icon-search"></el-input>
<el-button size="mini" icon="el-icon-refresh" :loading="apiRefreshing" @click="refreshApiList">刷新</el-button>
<el-button size="mini" type="primary" @click="openApiDialogForCreate">新增接口</el-button>
<el-button size="mini" type="success" @click="openBatchImportDialog">批量导入</el-button>
</div>
</div>
<el-table :data="pagedApiList" border style="width: 100%;">
<el-table-column label="接口名称" min-width="200">
<template slot-scope="scope">
<span>{{ scope.row.name }}</span>
</template>
</el-table-column>
<el-table-column label="请求路径" min-width="300">
<template slot-scope="scope">
<span>{{ scope.row.route }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="160">
<template slot-scope="scope">
<el-button size="mini" type="primary" plain @click="openApiDialogForEdit(scope.$index)">修改</el-button>
<el-button size="mini" type="danger" @click="removeApiItem(scope.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="table-pagination"
background
layout="total, prev, pager, next"
:current-page="apiPagination.currentPage"
:page-size="apiPagination.pageSize"
:total="filteredApiList.length"
@current-change="handleApiPageChange"
></el-pagination>
</el-card>
</el-tab-pane>
<el-tab-pane label="系统配置" name="basic">
<el-card class="section-card">
<div style="display:flex;justify-content:flex-end;align-items:center;margin-bottom:12px;">
<el-button size="mini" icon="el-icon-refresh" :loading="configRefreshing" @click="refreshConfig">刷新</el-button>
</div>
<el-alert
v-if="!canEditBasicConfig"
type="warning"
:closable="false"
show-icon
style="margin-bottom:16px;"
:title="'当前 IP(' + (clientIp || '未知') + ')不在管理员白名单内,无法修改系统配置'"
description="系统配置仅允许 admin_list.txt 中的 IP 访问与编辑。"
></el-alert>
<div :class="{ 'basic-config-readonly': !canEditBasicConfig }">
<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" @change="saveServerConfig"></el-switch>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="默认响应 Content-Type">
<el-select
v-model="form.config.defaultContentType"
filterable
allow-create
default-first-option
placeholder="请选择"
@change="saveServerConfig"
style="width: 100%;"
>
<el-option
v-for="item in contentTypeOptions"
:key="item"
:label="item"
:value="item"
></el-option>
</el-select>
</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"
@change="saveServerConfig"
></el-input-number>
<div style="color:#E6A23C;font-size:12px;margin-top:4px;">
<i class="el-icon-warning"></i> 修改端口后需重启应用才能生效
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标主机">
<el-input v-model="form.config.targetHost" @blur="saveServerConfig"></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"
@change="saveServerConfig"
></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标 HTTPS">
<el-switch v-model="form.config.targetHttps" @change="saveServerConfig"></el-switch>
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
</el-card>
</el-tab-pane>
<el-tab-pane label="个性化配置" name="ip">
<el-card class="section-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<div>
<strong>个性化配置</strong>
<div class="small-text" style="margin-top:4px;">
为当前 IP 配置独立目标环境(不含本地代理端口),覆盖全局系统配置。
普通用户仅可管理 IP 为 <strong>{{ clientIp || "未知" }}</strong> 的配置;管理员可管理全部。
<a href="/__client-ip" target="_blank" style="color:#409EFF;">查看当前识别 IP</a>
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;">
<el-input size="small" v-model="ipRuleSearch" placeholder="搜索 IP 或备注" clearable style="width:220px;" prefix-icon="el-icon-search"></el-input>
<el-button size="mini" icon="el-icon-refresh" :loading="ipRulesRefreshing" @click="refreshIpRules">刷新</el-button>
<el-button
v-if="canCreateIpRule"
size="mini"
type="primary"
@click="openIpRuleDialogForCreate"
>{{ isAdmin ? "新增配置" : "配置我的环境" }}</el-button>
</div>
</div>
<el-table :data="pagedIpRules" border style="width: 100%;">
<el-table-column label="IP 地址" min-width="160">
<template slot-scope="scope">
<span>{{ scope.row.ip }}</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="140" show-overflow-tooltip>
<template slot-scope="scope">
<span>{{ scope.row.remark || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="策略" width="120" align="center">
<template slot-scope="scope">
<el-tag v-if="scope.row.blocked" type="danger" size="mini">拉黑</el-tag>
<el-tag v-else-if="scope.row.useCustomConfig" type="warning" size="mini">个性化</el-tag>
<el-tag v-else type="info" size="mini">默认</el-tag>
</template>
</el-table-column>
<el-table-column label="目标环境" min-width="260" show-overflow-tooltip>
<template slot-scope="scope">
<span>{{ getIpRuleTargetSummary(scope.row) }}</span>
</template>
</el-table-column>
<el-table-column label="Mock" width="90" align="center">
<template slot-scope="scope">
<span v-if="scope.row.blocked">-</span>
<span v-else-if="scope.row.useCustomConfig">{{ scope.row.config.mockEnabled === false ? "关闭" : "开启" }}</span>
<span v-else>全局</span>
</template>
</el-table-column>
<el-table-column label="操作" width="160">
<template slot-scope="scope">
<template v-if="canManageIpRule(scope.row)">
<el-button size="mini" type="primary" plain @click="openIpRuleDialogForEdit(scope.row)">修改</el-button>
<el-button size="mini" type="danger" @click="removeIpRule(scope.row)">删除</el-button>
</template>
<span v-else class="small-text">无权限</span>
</template>
</el-table-column>
</el-table>
<el-pagination
class="table-pagination"
background
layout="total, prev, pager, next"
:current-page="ipRulePagination.currentPage"
:page-size="ipRulePagination.pageSize"
:total="filteredIpRules.length"
@current-change="handleIpRulePageChange"
></el-pagination>
</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 文件">
<div style="display:flex;gap:8px;">
<el-select
v-model="routeDialog.form.groupFilter"
clearable
placeholder="选择分组"
style="width: 180px;"
@change="onRouteDialogGroupChange"
>
<el-option label="全部" :value="null"></el-option>
<el-option label="无分组" :value="-1"></el-option>
<el-option
v-for="g in mockGroups"
:key="g.id"
:label="g.name"
:value="g.id"
></el-option>
</el-select>
<el-select
v-model="routeDialog.form.filePath"
filterable
placeholder="请选择 mock 文件"
style="flex: 1;"
>
<el-option
v-for="item in routeDialogMockFiles"
:key="item.filePath"
:label="item.alias ? item.alias + ' (' + item.filePath + ')' : item.filePath"
:value="item.filePath"
></el-option>
</el-select>
</div>
</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 文件' : mockFileDialog.mode === 'copy' ? '复制 Mock 文件' : '新增 Mock 文件'"
:visible.sync="mockFileDialog.visible"
:width="mockFileDialog.fullscreen ? '100%' : '760px'"
:fullscreen="mockFileDialog.fullscreen"
:top="mockFileDialog.fullscreen ? '0' : '15vh'"
>
<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="分组">
<el-select v-model="mockFileDialog.form.groupId" clearable placeholder="选择分组" style="width: 100%;">
<el-option label="无分组" :value="null"></el-option>
<el-option v-for="g in mockGroups" :key="g.id" :label="g.name" :value="g.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="Mock 文件路径">
<el-input
v-model="mockFileDialog.form.fileName"
placeholder="请输入文件名,如 test123.json"
:disabled="mockFileDialog.mode === 'edit'"
>
<template slot="prepend">mock/</template>
</el-input>
</el-form-item>
<el-form-item label="文件内容">
<div style="display:flex;justify-content:flex-end;margin-bottom:6px;">
<el-button size="mini" type="text" @click="mockFileDialog.fullscreen = !mockFileDialog.fullscreen">
<i :class="mockFileDialog.fullscreen ? 'el-icon-copy-document' : 'el-icon-full-screen'"></i>
{{ mockFileDialog.fullscreen ? '退出全屏' : '全屏编辑' }}
</el-button>
</div>
<el-input
type="textarea"
:autosize="mockFileDialog.fullscreen ? { minRows: 28, maxRows: 28 } : { minRows: 12, maxRows: 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>
<el-dialog
:title="apiDialog.mode === 'edit' ? '修改接口' : '新增接口'"
:visible.sync="apiDialog.visible"
width="500px"
>
<el-form :model="apiDialog.form" label-width="80px">
<el-form-item label="接口名称">
<el-input v-model="apiDialog.form.name" placeholder="如:获取用户信息"></el-input>
</el-form-item>
<el-form-item label="请求路径">
<el-input v-model="apiDialog.form.route" placeholder="如:/api2/user/info"></el-input>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="apiDialog.visible = false">取消</el-button>
<el-button type="primary" @click="submitApiDialog">确定</el-button>
</span>
</el-dialog>
<el-dialog
title="批量导入接口"
:visible.sync="batchImportDialog.visible"
width="600px"
>
<el-form label-width="80px">
<el-form-item label="JSON 数据">
<el-input
type="textarea"
:rows="10"
v-model="batchImportDialog.input"
placeholder='[{"route":"/api/xxxx","name":"登录接口"}]'
></el-input>
</el-form-item>
</el-form>
<div style="color:#909399;font-size:12px;margin-top:-8px;padding-left:80px;">
格式:JSON 数组,每项包含 route(请求路径)和 name(接口名称)
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="batchImportDialog.visible = false">取消</el-button>
<el-button type="primary" @click="submitBatchImport">导入</el-button>
</span>
</el-dialog>
<el-dialog
title="分组管理"
:visible.sync="mockGroupDialog.visible"
width="600px"
>
<div style="margin-bottom:12px;">
<el-button size="mini" type="primary" @click="addMockGroup">新增分组</el-button>
</div>
<el-table :data="mockGroups" border size="small" style="width: 100%;">
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column label="分组名称" min-width="200">
<template slot-scope="scope">
<el-input
v-if="mockGroupDialog.editingId === scope.row.id"
v-model="mockGroupDialog.form.name"
size="small"
@blur="saveMockGroupEdit(scope.row)"
@keyup.enter.native="saveMockGroupEdit(scope.row)"
></el-input>
<span v-else>{{ scope.row.name }}</span>
</template>
</el-table-column>
<el-table-column label="文件数" width="80" align="center">
<template slot-scope="scope">
<span>{{ getGroupFileCount(scope.row.id) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="160">
<template slot-scope="scope">
<el-button size="mini" type="primary" plain @click="startEditMockGroup(scope.row)">修改</el-button>
<el-button size="mini" type="danger" @click="confirmDeleteGroup(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
<el-dialog
title="删除分组确认"
:visible.sync="deleteGroupDialog.visible"
width="500px"
>
<div style="margin-bottom:16px;">
<p>分组「{{ deleteGroupDialog.groupName }}」下有 <strong>{{ deleteGroupDialog.fileCount }}</strong> 个 mock 文件。</p>
<p>请选择对这些文件的处理方式:</p>
</div>
<el-radio-group v-model="deleteGroupDialog.action" style="display:flex;flex-direction:column;gap:12px;">
<el-radio label="reassign">转移到其他分组</el-radio>
<el-radio label="unassign">取消分组(设为未分组)</el-radio>
<el-radio label="delete">删除全部文件</el-radio>
</el-radio-group>
<div v-if="deleteGroupDialog.action === 'reassign'" style="margin-top:12px;">
<el-select v-model="deleteGroupDialog.targetGroupId" placeholder="选择目标分组" style="width:100%;">
<el-option v-for="g in mockGroups.filter(function(g){return g.id !== deleteGroupDialog.groupId})" :key="g.id" :label="g.name" :value="g.id"></el-option>
</el-select>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="deleteGroupDialog.visible = false">取消</el-button>
<el-button type="danger" @click="submitDeleteGroup">确认删除</el-button>
</span>
</el-dialog>
<el-dialog
:title="ipRuleDialog.mode === 'edit' ? '修改个性化配置' : (isAdmin ? '新增个性化配置' : '配置我的环境')"
:visible.sync="ipRuleDialog.visible"
width="760px"
>
<el-form :model="ipRuleDialog.form" label-width="180px">
<el-form-item label="IP 地址">
<el-input
v-model="ipRuleDialog.form.ip"
placeholder="自动识别当前 IP"
disabled
></el-input>
<div class="small-text" style="margin-top:4px;">
{{ ipRuleDialog.mode === 'create' ? '自动填充当前 IP,不可修改' : 'IP 地址不可修改' }}
</div>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="ipRuleDialog.form.remark" placeholder="如:测试组、开发组"></el-input>
</el-form-item>
<el-form-item v-if="isAdmin" label="拉黑访问">
<el-switch v-model="ipRuleDialog.form.blocked" @change="onIpRuleBlockedChange"></el-switch>
<div class="small-text" style="margin-top:4px;">开启后该 IP 的所有请求将被拒绝(403)</div>
</el-form-item>
<template v-if="!ipRuleDialog.form.blocked">
<el-form-item label="个性化配置">
<el-switch v-model="ipRuleDialog.form.useCustomConfig"></el-switch>
<div class="small-text" style="margin-top:4px;">开启后可为此 IP 单独配置目标环境,覆盖全局系统配置</div>
</el-form-item>
<template v-if="ipRuleDialog.form.useCustomConfig">
<el-divider content-position="left">独立环境配置</el-divider>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="Mock 开关">
<el-switch v-model="ipRuleDialog.form.config.mockEnabled"></el-switch>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="默认 Content-Type">
<el-select
v-model="ipRuleDialog.form.config.defaultContentType"
filterable
allow-create
default-first-option
placeholder="请选择"
style="width: 100%;"
>
<el-option
v-for="item in contentTypeOptions"
:key="item"
:label="item"
:value="item"
></el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标主机">
<el-input v-model="ipRuleDialog.form.config.targetHost" placeholder="如 dev.example.com"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标端口">
<el-input-number
v-model="ipRuleDialog.form.config.targetPort"
:min="1"
:max="65535"
style="width: 100%;"
></el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标 HTTPS">
<el-switch v-model="ipRuleDialog.form.config.targetHttps"></el-switch>
</el-form-item>
</el-col>
</el-row>
</template>
</template>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="ipRuleDialog.visible = false">取消</el-button>
<el-button type="primary" @click="submitIpRuleDialog">保存</el-button>
</span>
</el-dialog>
</div>
</div>
<script src="/assets/vue.js"></script>
<script src="/assets/element-ui/index.js"></script>
<script>
new Vue({
el: "#app",
data: function () {
return {
activeMainTab: "routes",
routesRefreshing: false,
mocksRefreshing: false,
apiRefreshing: false,
configRefreshing: false,
canEditBasicConfig: false,
clientIp: "",
isAdmin: false,
ipRulesRefreshing: false,
ipRules: [],
ipRuleSearch: "",
ipRulePagination: { currentPage: 1, pageSize: 10 },
ipRuleDialog: {
visible: false,
mode: "create",
form: {
ip: "",
remark: "",
blocked: false,
useCustomConfig: true,
config: {
mockEnabled: true,
defaultContentType: "application/json",
targetHost: "",
targetPort: 443,
targetHttps: true,
},
},
},
apiList: [],
apiPagination: { currentPage: 1, pageSize: 10 },
apiDialog: {
visible: false,
mode: "create",
editingIndex: -1,
form: { name: "", route: "", originalRoute: "" },
},
batchImportDialog: {
visible: false,
input: "",
},
mockFiles: [],
mockGroups: [],
mockGroupFilter: null,
mockGroupDialog: {
visible: false,
mode: "create",
editingId: null,
form: { name: "" },
},
deleteGroupDialog: {
visible: false,
groupId: null,
groupName: "",
fileCount: 0,
action: "",
targetGroupId: null,
},
contentTypeOptions: [
"application/json",
"application/x-www-form-urlencoded",
],
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" },
],
routeSearch: "",
mockFileSearch: "",
apiSearch: "",
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: "",
route: "",
filePath: "",
statusCode: 200,
enabled: true,
groupFilter: null,
},
},
mockFileDialog: {
visible: false,
fullscreen: false,
mode: "create",
form: {
fileName: "",
alias: "",
content: "",
},
},
};
},
watch: {
activeMainTab: function (tab) {
if (tab === "routes") { this.loadRoutes(); }
else if (tab === "mocks") { this.loadMockFiles(); this.loadMockGroups(); }
else if (tab === "api") { this.loadApiList(); }
else if (tab === "basic") { this.loadConfig(); }
else if (tab === "ip") { this.loadIpRules(); }
},
routeSearch: function () { this.routePagination.currentPage = 1; },
mockFileSearch: function () { this.mockFilePagination.currentPage = 1; },
mockGroupFilter: function () { this.mockFilePagination.currentPage = 1; },
apiSearch: function () { this.apiPagination.currentPage = 1; },
ipRuleSearch: function () { this.ipRulePagination.currentPage = 1; },
},
created: async function () {
await this.loadApiList();
await this.loadMockFiles();
await this.loadMockGroups();
await this.loadConfig();
await this.loadRoutes();
},
methods: {
loadRoutes: async function () {
try {
var resp = await fetch("/__routes");
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "加载路由失败");
}
this.form.routes = this.toRouteArray(
data.routes || {},
data.routeStatuses || {},
data.routeEnabledMap || {},
data.routeApiNameMap || {},
);
this.routePagination.currentPage = 1;
} catch (err) {
this.$message.error("加载路由失败: " + err.message);
}
},
refreshRoutes: async function () {
this.routesRefreshing = true;
try {
await this.loadRoutes();
this.$message.success("路由配置已刷新");
} finally {
this.routesRefreshing = false;
}
},
refreshMocks: async function () {
this.mocksRefreshing = true;
try {
await this.loadMockFiles();
await this.loadMockGroups();
this.$message.success("Mock 数据已刷新");
} finally {
this.mocksRefreshing = false;
}
},
refreshApiList: async function () {
this.apiRefreshing = true;
try {
await this.loadApiList();
this.$message.success("接口列表已刷新");
} finally {
this.apiRefreshing = false;
}
},
refreshConfig: async function () {
this.configRefreshing = true;
try {
await this.loadConfig();
this.$message.success("系统配置已刷新");
} finally {
this.configRefreshing = false;
}
},
refreshIpRules: async function () {
this.ipRulesRefreshing = true;
try {
await this.loadIpRules();
this.$message.success("个性化配置已刷新");
} finally {
this.ipRulesRefreshing = false;
}
},
loadIpRules: async function () {
try {
var resp = await fetch("/__ip-rules");
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "加载 IP 规则失败");
}
this.ipRules = Array.isArray(data.list) ? data.list : [];
if (data.clientIp) {
this.clientIp = data.clientIp;
}
if (data.isAdmin === true) {
this.isAdmin = true;
} else if (data.isAdmin === false) {
this.isAdmin = false;
}
this.ipRulePagination.currentPage = 1;
} catch (err) {
this.ipRules = [];
this.$message.error("加载个性化配置失败: " + err.message);
}
},
canManageIpRule: function (rule) {
if (!rule) return false;
if (this.isAdmin) return true;
var ruleIp = (rule.ip || "").trim();
var mine = (this.clientIp || "").trim();
return ruleIp && mine && ruleIp === mine;
},
getDefaultIpRuleForm: function () {
var global = this.form.config || {};
return {
ip: this.clientIp || "",
remark: "",
blocked: false,
useCustomConfig: true,
config: {
mockEnabled: global.mockEnabled !== false,
defaultContentType: global.defaultContentType || "application/json",
targetHost: global.targetHost || "",
targetPort: global.targetPort || 443,
targetHttps: global.targetHttps !== false,
},
};
},
getIpRuleTargetSummary: function (rule) {
if (!rule) return "-";
if (rule.blocked) return "已拉黑,禁止访问";
if (!rule.useCustomConfig) return "使用全局配置";
var cfg = rule.config || {};
var host = cfg.targetHost || this.form.config.targetHost || "-";
var port = cfg.targetPort != null ? cfg.targetPort : this.form.config.targetPort;
var https = cfg.targetHttps !== undefined ? cfg.targetHttps !== false : this.form.config.targetHttps !== false;
var proto = https ? "https" : "http";
return proto + "://" + host + (port ? ":" + port : "");
},
onIpRuleBlockedChange: function (blocked) {
if (blocked) {
this.ipRuleDialog.form.useCustomConfig = false;
}
},
openIpRuleDialogForCreate: async function () {
if (!this.canCreateIpRule) {
this.$message.warning("您已有个性化配置,请直接修改");
return;
}
if (!this.clientIp) {
await this.loadIpRules();
}
this.ipRuleDialog.mode = "create";
this.ipRuleDialog.form = this.getDefaultIpRuleForm();
this.ipRuleDialog.visible = true;
},
openIpRuleDialogForEdit: function (rule) {
if (!this.canManageIpRule(rule)) {
this.$message.error("只能修改与当前 IP 匹配的个性化配置");
return;
}
var cfg = rule.config || {};
this.ipRuleDialog.mode = "edit";
this.ipRuleDialog.form = {
ip: rule.ip || "",
remark: rule.remark || "",
blocked: !!rule.blocked,
useCustomConfig: !!rule.useCustomConfig,
config: {
mockEnabled: cfg.mockEnabled !== false,
defaultContentType: cfg.defaultContentType || this.form.config.defaultContentType || "application/json",
targetHost: cfg.targetHost || "",
targetPort: cfg.targetPort != null ? cfg.targetPort : this.form.config.targetPort,
targetHttps: cfg.targetHttps !== undefined ? cfg.targetHttps !== false : this.form.config.targetHttps !== false,
},
};
this.ipRuleDialog.visible = true;
},
submitIpRuleDialog: async function () {
var form = this.ipRuleDialog.form;
var ip = (form.ip || "").trim();
if (!this.isAdmin || this.ipRuleDialog.mode === "create") {
ip = (this.clientIp || "").trim();
form.blocked = false;
}
if (!ip) {
this.$message.error("IP 地址不能为空");
return;
}
if (this.isAdmin && !form.blocked && !form.useCustomConfig) {
this.$message.error("请至少启用「拉黑访问」或「个性化配置」");
return;
}
if (!this.isAdmin && !form.useCustomConfig) {
this.$message.error("请启用个性化配置");
return;
}
try {
var resp = await fetch("/__ip-rules", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: this.ipRuleDialog.mode,
ip: ip,
remark: form.remark,
blocked: !!form.blocked,
useCustomConfig: !!form.useCustomConfig,
config: form.useCustomConfig ? form.config : {},
}),
});
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "保存 IP 规则失败");
}
this.$message.success("IP 规则已保存");
this.ipRuleDialog.visible = false;
await this.loadIpRules();
} catch (err) {
this.$message.error("保存 IP 规则失败: " + err.message);
}
},
removeIpRule: async function (rule) {
if (!this.canManageIpRule(rule)) {
this.$message.error("只能删除与当前 IP 匹配的个性化配置");
return;
}
try {
await this.$confirm("确定删除 IP「" + rule.ip + "」的规则吗?", "提示", {
type: "warning",
});
} catch (e) {
return;
}
try {
var resp = await fetch("/__ip-rules", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ip: rule.ip }),
});
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "删除 IP 规则失败");
}
this.$message.success("IP 规则已删除");
await this.loadIpRules();
} catch (err) {
this.$message.error("删除 IP 规则失败: " + err.message);
}
},
handleIpRulePageChange: function (page) {
this.ipRulePagination.currentPage = page;
},
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, routeEnabledMap, routeApiNameMap) {
var self = this;
return Object.keys(routesObj || {}).map(function (route) {
var storedName = (routeApiNameMap || {})[route] || "";
var matched = storedName ? null : self.findApiByRoute(route);
return {
route: route,
filePath: routesObj[route],
statusCode: self.normalizeStatusCode(
routeStatusesObj && routeStatusesObj[route],
),
apiName: storedName || (matched ? matched.name : ""),
selectedApiRoute: matched ? matched.route : "",
enabled: (routeEnabledMap || {})[route] !== false,
};
});
},
toRouteObject: function (routeArray) {
var obj = {};
(routeArray || []).forEach(function (item) {
var route = (item.route || "").trim();
var filePath = (item.filePath || "").trim();
if (route && filePath) {
obj[route] = filePath;
}
});
return obj;
},
toRouteStatusObject: function (routeArray) {
var self = this;
var obj = {};
(routeArray || []).forEach(function (item) {
var route = (item.route || "").trim();
if (!route) return;
obj[route] = self.normalizeStatusCode(item.statusCode);
});
return obj;
},
toRouteEnabledMap: function (routeArray) {
var obj = {};
(routeArray || []).forEach(function (item) {
var route = (item.route || "").trim();
if (route) {
obj[route] = item.enabled !== false;
}
});
return obj;
},
toRouteApiNameMap: function (routeArray) {
var obj = {};
(routeArray || []).forEach(function (item) {
var route = (item.route || "").trim();
if (route) {
obj[route] = item.apiName || "";
}
});
return obj;
},
removeRoute: async function (index) {
var pagedIndex =
(this.routePagination.currentPage - 1) * this.routePagination.pageSize +
index;
var target = this.filteredRoutes[pagedIndex];
if (!target) return;
var realIndex = this.form.routes.indexOf(target);
if (realIndex === -1) return;
this.form.routes.splice(realIndex, 1);
await this.saveRoutes();
},
getDefaultRouteForm: function () {
return {
apiName: "",
selectedApiRoute: "",
originalRoute: "",
route: "",
filePath: "",
statusCode: 200,
enabled: true,
groupFilter: null,
};
},
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;
},
onRouteDialogGroupChange: function () {
// 切换分组时清空已选文件
this.routeDialog.form.filePath = "";
},
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.filteredRoutes[actualIndex] || { route: "", filePath: "mock/" };
this.routeDialog.mode = "edit";
this.routeDialog.editingIndex = actualIndex;
var matched = this.findApiByRoute(item.route || "");
var mockFile = this.mockFiles.find(function (f) { return f.filePath === item.filePath; });
var groupFilter = mockFile && mockFile.groupId ? mockFile.groupId : null;
this.routeDialog.form = {
apiName: matched ? matched.name : item.apiName || "",
selectedApiRoute: matched ? matched.route : "",
originalRoute: item.route || "",
route: item.route || "",
filePath: item.filePath || "mock/",
statusCode: this.normalizeStatusCode(item.statusCode),
enabled: item.enabled !== false,
groupFilter: groupFilter,
};
this.routeDialog.visible = true;
},
getDefaultMockFileForm: function () {
return {
fileName: "",
alias: "",
content: "",
groupId: null,
};
},
openMockFileDialogForCreate: function () {
this.mockFileDialog.mode = "create";
this.mockFileDialog.fullscreen = false;
this.mockFileDialog.form = this.getDefaultMockFileForm();
this.mockFileDialog.visible = true;
},
openMockFileDialogForEdit: function (item) {
var full = item.filePath || "";
var name = full.indexOf("mock/") === 0 ? full.slice(5) : full;
this.mockFileDialog.mode = "edit";
this.mockFileDialog.fullscreen = false;
this.mockFileDialog.form = {
fileName: name,
alias: String(item.alias || ""),
content: String(item.content || ""),
groupId: item.groupId || null,
};
this.mockFileDialog.visible = true;
},
openMockFileFromRoute: function (routeRow) {
var filePath = routeRow.filePath || "";
var mockFile = this.mockFiles.find(function (f) { return f.filePath === filePath; });
if (!mockFile) {
this.$message.warning("未找到对应的 Mock 文件:" + filePath);
return;
}
this.openMockFileDialogForEdit(mockFile);
},
openMockFileDialogForCopy: function (item) {
this.mockFileDialog.mode = "copy";
this.mockFileDialog.fullscreen = false;
this.mockFileDialog.form = {
fileName: "",
alias: String(item.alias || "") + "-副本",
content: String(item.content || ""),
groupId: item.groupId || null,
};
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 () {
var fileName = (this.mockFileDialog.form.fileName || "").trim();
if (!fileName) {
this.$message.error("文件名不能为空");
return;
}
if (fileName.indexOf("/") !== -1 || fileName.indexOf("\\") !== -1) {
this.$message.error("文件名不能包含路径分隔符");
return;
}
if (!/\.[^./\\]+$/.test(fileName)) {
this.$message.error("文件名必须包含后缀,如 .json、.txt 等");
return;
}
var filePath = "mock/" + fileName;
try {
var resp = await fetch("/__mock-files", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filePath: filePath,
alias: this.mockFileDialog.form.alias,
content: this.mockFileDialog.form.content,
groupId: this.mockFileDialog.form.groupId,
}),
});
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.canEditBasicConfig = data.canEditBasicConfig === true;
this.clientIp = data.clientIp || "";
if (data.canEditBasicConfig === true) {
this.isAdmin = true;
}
} catch (err) {
this.$message.error("加载配置失败: " + err.message);
}
},
saveRoutes: async function () {
var payload = {
routes: this.toRouteObject(this.form.routes),
routeStatuses: this.toRouteStatusObject(this.form.routes),
routeEnabledMap: this.toRouteEnabledMap(this.form.routes),
routeApiNameMap: this.toRouteApiNameMap(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 || "保存失败");
}
} catch (err) {
this.$message.error("保存路由失败: " + err.message);
}
},
saveServerConfig: async function () {
if (!this.canEditBasicConfig) {
return;
}
try {
var resp = await fetch("/__config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ config: this.form.config }),
});
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "保存失败");
}
} 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.loadRoutes();
} catch (err) {
this.$message.error("创建失败: " + err.message);
}
},
handleApiPageChange: function (page) {
this.apiPagination.currentPage = page;
},
openApiDialogForCreate: function () {
this.apiDialog.mode = "create";
this.apiDialog.editingIndex = -1;
this.apiDialog.form = { name: "", route: "", originalRoute: "" };
this.apiDialog.visible = true;
},
openApiDialogForEdit: function (index) {
var actualIndex =
(this.apiPagination.currentPage - 1) * this.apiPagination.pageSize + index;
var item = this.apiList[actualIndex];
this.apiDialog.mode = "edit";
this.apiDialog.editingIndex = actualIndex;
this.apiDialog.form = {
name: item.name,
route: item.route,
originalRoute: item.route,
};
this.apiDialog.visible = true;
},
submitApiDialog: async function () {
if (!this.apiDialog.form.name) {
this.$message.error("接口名称不能为空");
return;
}
if (!this.apiDialog.form.route || !this.apiDialog.form.route.startsWith("/")) {
this.$message.error("请求路径必须以 / 开头");
return;
}
try {
var resp = await fetch("/__api-list", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.apiDialog.form),
});
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "操作失败");
}
this.$message.success(this.apiDialog.mode === "edit" ? "接口已修改" : "接口已添加");
this.apiDialog.visible = false;
await this.loadApiList();
} catch (err) {
this.$message.error("操作失败: " + err.message);
}
},
removeApiItem: async function (index) {
var actualIndex =
(this.apiPagination.currentPage - 1) * this.apiPagination.pageSize + index;
var item = this.apiList[actualIndex];
try {
await this.$confirm("确定删除接口「" + item.name + "」?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
var resp = await fetch("/__api-list", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ route: item.route }),
});
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "删除失败");
}
this.$message.success("接口已删除");
await this.loadApiList();
} catch (err) {
if (err !== "cancel") {
this.$message.error("删除失败: " + err.message);
}
}
},
openBatchImportDialog: function () {
this.batchImportDialog.input = "";
this.batchImportDialog.visible = true;
},
submitBatchImport: async function () {
var raw = (this.batchImportDialog.input || "").trim();
if (!raw) {
this.$message.error("请输入 JSON 数据");
return;
}
var list;
try {
list = JSON.parse(raw);
} catch (e) {
this.$message.error("JSON 格式不正确:" + e.message);
return;
}
if (!Array.isArray(list) || list.length === 0) {
this.$message.error("请输入非空 JSON 数组");
return;
}
var okCount = 0;
var failCount = 0;
for (var i = 0; i < list.length; i++) {
var item = list[i];
if (!item || !item.route || !item.name) {
failCount++;
continue;
}
try {
var resp = await fetch("/__api-list", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ route: item.route, name: item.name }),
});
var data = await resp.json();
if (resp.ok && data.success !== false) {
okCount++;
} else {
failCount++;
}
} catch (err) {
failCount++;
}
}
if (okCount > 0) {
this.$message.success("成功导入 " + okCount + " 条" + (failCount > 0 ? ",失败 " + failCount + " 条" : ""));
} else {
this.$message.error("导入失败,共 " + failCount + " 条");
}
this.batchImportDialog.visible = false;
await this.loadApiList();
},
getGroupName: function (groupId) {
if (!groupId) return "未分组";
var group = this.mockGroups.find(function (g) { return g.id === groupId; });
return group ? group.name : "未分组";
},
getGroupFileCount: function (groupId) {
return this.mockFiles.filter(function (f) { return f.groupId === groupId; }).length;
},
loadMockGroups: async function () {
try {
var resp = await fetch("/__mock-groups");
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "加载分组失败");
}
this.mockGroups = Array.isArray(data.list) ? data.list : [];
} catch (err) {
this.mockGroups = [];
this.$message.error("加载分组失败: " + err.message);
}
},
openMockGroupDialog: function () {
this.mockGroupDialog.editingId = null;
this.mockGroupDialog.form.name = "";
this.mockGroupDialog.visible = true;
},
addMockGroup: async function () {
var self = this;
this.$prompt("请输入分组名称", "新增分组", {
confirmButtonText: "确定",
cancelButtonText: "取消",
inputPattern: /\S+/,
inputErrorMessage: "分组名称不能为空",
}).then(async function (res) {
var name = (res.value || "").trim();
try {
var resp = await fetch("/__mock-groups", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name }),
});
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "新增分组失败");
}
self.$message.success("分组已创建");
await self.loadMockGroups();
} catch (err) {
self.$message.error("新增分组失败: " + err.message);
}
}).catch(function () {});
},
startEditMockGroup: function (group) {
this.mockGroupDialog.editingId = group.id;
this.mockGroupDialog.form.name = group.name;
},
saveMockGroupEdit: async function (group) {
var newName = (this.mockGroupDialog.form.name || "").trim();
this.mockGroupDialog.editingId = null;
if (!newName || newName === group.name) return;
try {
var resp = await fetch("/__mock-groups", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: group.id, name: newName }),
});
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "修改分组失败");
}
this.$message.success("分组已修改");
await this.loadMockGroups();
} catch (err) {
this.$message.error("修改分组失败: " + err.message);
}
},
confirmDeleteGroup: async function (group) {
var fileCount = this.getGroupFileCount(group.id);
if (fileCount === 0) {
// 无文件,直接删除
try {
var resp = await fetch("/__mock-groups", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: group.id }),
});
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "删除分组失败");
}
this.$message.success("分组已删除");
await this.loadMockGroups();
} catch (err) {
this.$message.error("删除分组失败: " + err.message);
}
return;
}
// 有文件,弹出确认对话框
this.deleteGroupDialog.groupId = group.id;
this.deleteGroupDialog.groupName = group.name;
this.deleteGroupDialog.fileCount = fileCount;
this.deleteGroupDialog.action = "";
this.deleteGroupDialog.targetGroupId = null;
this.deleteGroupDialog.visible = true;
},
submitDeleteGroup: async function () {
var action = this.deleteGroupDialog.action;
if (!action) {
this.$message.error("请选择处理方式");
return;
}
if (action === "reassign" && !this.deleteGroupDialog.targetGroupId) {
this.$message.error("请选择目标分组");
return;
}
var payload = {
id: this.deleteGroupDialog.groupId,
action: action === "unassign" ? "reassign" : action,
};
if (action === "reassign") {
payload.targetGroupId = this.deleteGroupDialog.targetGroupId;
} else if (action === "unassign") {
payload.targetGroupId = null;
}
try {
var resp = await fetch("/__mock-groups", {
method: "DELETE",
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.deleteGroupDialog.visible = false;
await this.loadMockGroups();
await this.loadMockFiles();
} catch (err) {
this.$message.error("删除分组失败: " + err.message);
}
},
},
computed: {
filteredRoutes: function () {
var q = (this.routeSearch || "").toLowerCase().trim();
var list = this.form.routes;
if (q) {
list = list.filter(function (item) {
return (
(item.route || "").toLowerCase().indexOf(q) !== -1 ||
(item.apiName || "").toLowerCase().indexOf(q) !== -1 ||
(item.filePath || "").toLowerCase().indexOf(q) !== -1
);
});
}
return list.slice().sort(function (a, b) {
return (b.enabled !== false ? 1 : 0) - (a.enabled !== false ? 1 : 0);
});
},
filteredMockFiles: function () {
var q = (this.mockFileSearch || "").toLowerCase().trim();
var groupFilter = this.mockGroupFilter;
var list = this.mockFiles;
if (groupFilter !== null && groupFilter !== undefined) {
if (groupFilter === -1) {
list = list.filter(function (item) { return !item.groupId; });
} else {
list = list.filter(function (item) { return item.groupId === groupFilter; });
}
}
if (q) {
list = list.filter(function (item) {
return (
(item.filePath || "").toLowerCase().indexOf(q) !== -1 ||
(item.alias || "").toLowerCase().indexOf(q) !== -1
);
});
}
return list;
},
filteredApiList: function () {
var q = (this.apiSearch || "").toLowerCase().trim();
if (!q) return this.apiList;
return this.apiList.filter(function (item) {
return (
(item.route || "").toLowerCase().indexOf(q) !== -1 ||
(item.name || "").toLowerCase().indexOf(q) !== -1
);
});
},
filteredIpRules: function () {
var q = (this.ipRuleSearch || "").toLowerCase().trim();
var list = this.ipRules || [];
if (!q) return list;
return list.filter(function (item) {
return (
(item.ip || "").toLowerCase().indexOf(q) !== -1 ||
(item.remark || "").toLowerCase().indexOf(q) !== -1
);
});
},
pagedRoutes: function () {
return this.getPagedData(this.filteredRoutes, this.routePagination);
},
pagedMockFiles: function () {
return this.getPagedData(this.filteredMockFiles, this.mockFilePagination);
},
pagedApiList: function () {
return this.getPagedData(this.filteredApiList, this.apiPagination);
},
pagedIpRules: function () {
return this.getPagedData(this.filteredIpRules, this.ipRulePagination);
},
canCreateIpRule: function () {
if (this.isAdmin) return true;
return !(this.ipRules || []).some(function (rule) {
return rule && rule.ip;
});
},
routeDialogMockFiles: function () {
var groupFilter = this.routeDialog.form.groupFilter;
if (groupFilter === null || groupFilter === undefined) {
return this.mockFiles;
}
if (groupFilter === -1) {
return this.mockFiles.filter(function (f) { return !f.groupId; });
}
return this.mockFiles.filter(function (f) { return f.groupId === groupFilter; });
},
},
});
</script>
</body>
</html>