Files
tms-erp-web/src/views/authority/ratelimit.vue
赵忠林 7fb8113c8e feat(core): 统一全站时间列加排序并优化司机管理显示
- 所有含时间的列表列(创建时间、更新时间、起止日期等)均增加排序功能,兼顾Avue option和手写el-table-column
- 司机管理「姓名身份证号」列拆分为独立的「姓名」「身份证号」两列,分别使用插槽渲染
- 修复车辆/船舶/司机管理「所属组织」列前导空格问题,value 使用无缩进原始标签
- 调整司机管理页到期筛选标签位置,移至搜索菜单行并优化样式,删除过渡占位筛选字段
- 增加组织名称列自定义插槽,去除已入库旧数据的前导空白
- 更新相关样式,删除无用样式代码,增加表格列排序样式支持
2026-08-07 15:11:08 +08:00

640 lines
20 KiB
Vue
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.

<template>
<basic-container>
<avue-crud
:option="option"
:table-loading="loading"
:data="data"
v-model:page="page"
ref="crud"
v-model="form"
:before-open="beforeOpen"
@row-del="rowDel"
@row-update="rowUpdate"
@row-save="rowSave"
@search-change="searchChange"
@search-reset="searchReset"
@selection-change="selectionChange"
@current-change="currentChange"
@size-change="sizeChange"
@refresh-change="refreshChange"
@on-load="onLoad"
>
<template #menu-left>
<el-button
type="danger"
icon="el-icon-delete"
plain
v-if="userInfo.authority.includes('administrator')"
@click="handleDelete"
>删除
</el-button>
</template>
<template #limitPattern="{ row }">
<el-tag
v-for="(pattern, index) in parseLimitPattern(row.limitPattern)"
:key="index"
style="margin: 2px 4px 2px 0"
>{{ pattern }}</el-tag
>
</template>
<template #requestMethod="{ row }">
<el-tag>{{ requestMethodLabel(row.requestMethod) }}</el-tag>
</template>
<template #limitDimension="{ row }">
<el-tag>{{ limitDimensionLabel(row.limitDimension) }}</el-tag>
</template>
<template #rate="{ row }">
{{ row.limitCount }} / {{ row.limitPeriod }} {{ timeUnitLabel(row.timeUnit) }}
</template>
<template #status="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
<template #menu="{ row }">
<el-link
type="primary"
v-if="row.status !== 1 && userInfo.authority.includes('administrator')"
@click.stop="handleEnable(row)"
>启用
</el-link>
<el-link
type="primary"
v-if="row.status === 1 && userInfo.authority.includes('administrator')"
@click.stop="handleDisable(row)"
>禁用
</el-link>
<el-link
type="primary"
v-if="userInfo.authority.includes('administrator')"
@click.stop="handleRowLog(row)"
>日志
</el-link>
</template>
<!-- 匹配路径动态表单 -->
<template #limitPattern-form="{ type }">
<el-row
v-for="(item, index) in limitPatternList"
:key="index"
:gutter="8"
style="margin-bottom: 16px"
>
<el-col :span="22">
<el-input
v-model="limitPatternList[index]"
placeholder="请输入URL匹配路径如 /blade-system/user/**"
:disabled="type === 'view'"
/>
</el-col>
<el-col :span="2" v-if="type !== 'view'">
<el-button
type="danger"
text
icon="el-icon-delete"
@click="removeLimitPattern(index)"
/>
</el-col>
</el-row>
<el-row v-if="type !== 'view'" :gutter="8">
<el-col :span="8">
<el-link type="primary" @click="addLimitPattern">添加路径</el-link
>
</el-col>
</el-row>
<el-row style="margin-top: 8px">
<el-text type="info" size="small">
<el-icon><WarningFilled /></el-icon>
提示: 支持 Ant 风格路径匹配多路径命中任一即生效共享同一配额
</el-text>
</el-row>
</template>
</avue-crud>
<!-- 单条规则触发日志 Drawer -->
<el-drawer :title="`[${logLimitName}] 触发日志`" v-model="logDrawerVisible" append-to-body size="1200px">
<basic-container>
<avue-crud
:option="logOption"
:table-loading="logLoading"
:data="logData"
v-model:page="logPage"
ref="logCrud"
v-model="logForm"
:before-open="beforeOpenLog"
@search-change="logSearchChange"
@search-reset="logSearchReset"
@current-change="logCurrentChange"
@size-change="logSizeChange"
@refresh-change="logRefreshChange"
@on-load="onLoadLog"
>
<template #requestUri="{ row }">
<el-tag>{{ row.requestUri }}</el-tag>
</template>
<template #method="{ row }">
<el-tag>{{ row.method }}</el-tag>
</template>
<template #limitDimension="{ row }">
<el-tag>{{ limitDimensionLabel(row.limitDimension) }}</el-tag>
</template>
<template #rate="{ row }">
<span v-if="row.limitCount"
>{{ row.limitCount }} / {{ row.limitPeriod }} {{ timeUnitLabel(row.timeUnit) }}</span
>
<span v-else>-</span>
</template>
<template #rate-form>
<span v-if="logForm.limitCount"
>{{ logForm.limitCount }} / {{ logForm.limitPeriod }} {{ timeUnitLabel(logForm.timeUnit) }}</span
>
<span v-else>-</span>
</template>
</avue-crud>
</basic-container>
</el-drawer>
</basic-container>
</template>
<script>
import { getList, getDetail, remove, add, update, enable, disable } from '@/api/system/ratelimit';
import { getList as getLogList, getDetail as getLogDetail } from '@/api/system/ratelimitlog';
import { mapGetters } from 'vuex';
import { WarningFilled } from '@element-plus/icons-vue';
export default {
components: {
WarningFilled,
},
data() {
return {
form: {},
query: {},
loading: true,
limitPatternList: [],
page: {
pageSize: 10,
currentPage: 1,
total: 0,
},
selectionList: [],
timeUnitDic: [
{ label: '秒', value: 'SECONDS' },
{ label: '分钟', value: 'MINUTES' },
{ label: '小时', value: 'HOURS' },
],
requestMethodDic: [
{ label: '全部', value: 'ALL' },
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
{ label: 'PUT', value: 'PUT' },
{ label: 'DELETE', value: 'DELETE' },
],
limitDimensionDic: [
{ label: '全局', value: 'GLOBAL' },
{ label: '租户', value: 'TENANT' },
{ label: 'IP', value: 'IP' },
{ label: '用户', value: 'USER' },
],
option: {
height: 'auto',
calcHeight: 32,
dialogWidth: 700,
tip: false,
searchShow: true,
searchMenuSpan: 24,
searchIcon: true,
searchIndex: 8,
searchMenuPosition: 'right',
labelWidth: 'auto',
searchLabelWidth: 160,
border: true,
index: true,
viewBtn: true,
selection: true,
menuWidth: 180,
dialogClickModal: false,
column: [
{
label: '规则名称',
prop: 'limitName',
search: true,
span: 24,
rules: [{ required: true, message: '请输入规则名称', trigger: 'blur' }],
},
{
label: '匹配路径',
prop: 'limitPattern',
slot: true,
formslot: true,
span: 24,
labelTip:
'支持 Ant 风格路径匹配,可配置多个,如 /blade-system/user/** 匹配多层、/api/* 匹配单层;多个路径共享该规则的同一份配额',
rules: [{ required: true, message: '请输入匹配路径', trigger: 'change' }],
},
{
label: '请求方法',
prop: 'requestMethod',
type: 'select',
slot: true,
search: true,
value: 'ALL',
width: 100,
labelTip: '指定规则生效的 HTTP 请求方法,选择「全部」时对所有方法生效,否则仅对所选方法限流',
dicData: [
{ label: '全部', value: 'ALL' },
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
{ label: 'PUT', value: 'PUT' },
{ label: 'DELETE', value: 'DELETE' },
],
rules: [{ required: true, message: '请选择请求方法', trigger: 'change' }],
},
{
label: '限流维度',
prop: 'limitDimension',
type: 'select',
slot: true,
search: true,
value: 'IP',
width: 100,
labelTip: '全局所有请求共享配额租户按租户隔离IP按来源IP隔离用户按登录用户隔离',
dicData: [
{ label: '全局', value: 'GLOBAL' },
{ label: '租户', value: 'TENANT' },
{ label: 'IP', value: 'IP' },
{ label: '用户', value: 'USER' },
],
rules: [{ required: true, message: '请选择限流维度', trigger: 'change' }],
},
{
label: '请求阈值',
prop: 'limitCount',
type: 'number',
minRows: 1,
value: 100,
hide: true,
labelTip: '单个时间窗口内允许通过的最大请求数,超出后触发限流并返回 429',
rules: [{ required: true, message: '请输入请求阈值', trigger: 'blur' }],
},
{
label: '时间窗口',
prop: 'limitPeriod',
type: 'number',
minRows: 1,
value: 1,
hide: true,
labelTip: '限流统计周期的数值,与时间单位组合使用,如时间窗口 1 + 时间单位分钟表示每分钟一个周期',
rules: [{ required: true, message: '请输入时间窗口', trigger: 'blur' }],
},
{
label: '时间单位',
prop: 'timeUnit',
type: 'select',
value: 'MINUTES',
hide: true,
labelTip: '限流统计周期的计量单位,与请求阈值、时间窗口共同构成限流速率,如 100 次 / 分钟',
dicData: [
{ label: '秒', value: 'SECONDS' },
{ label: '分钟', value: 'MINUTES' },
{ label: '小时', value: 'HOURS' },
],
rules: [{ required: true, message: '请选择时间单位', trigger: 'change' }],
},
{
label: '限流速率',
prop: 'rate',
slot: true,
addDisplay: false,
editDisplay: false,
viewDisplay: false,
width: 150,
},
{
label: '优先级',
prop: 'priority',
type: 'number',
value: 1,
hide: true,
labelTip: '数值越小越优先匹配,同一路径命中多条规则时取优先级最高的一条',
},
{
label: '状态',
prop: 'status',
type: 'select',
slot: true,
value: 1,
display: false,
width: 90,
dicData: [
{ label: '启用', value: 1 },
{ label: '禁用', value: -1 },
],
},
{
label: '备注',
prop: 'remark',
type: 'textarea',
minRows: 2,
span: 24,
hide: true,
},
],
},
data: [],
// 单条规则触发日志
logDrawerVisible: false,
logLimitName: '',
logLimitId: null,
logForm: {},
logQuery: {},
logLoading: false,
logData: [],
logPage: { pageSize: 10, currentPage: 1, total: 0 },
logOption: {
height: 'auto',
calcHeight: 70,
tip: false,
header: false,
searchShow: true,
searchMenuSpan: 24,
searchIcon: true,
searchIndex: 8,
searchMenuPosition: 'right',
border: true,
index: true,
viewBtn: true,
editBtn: false,
addBtn: false,
delBtn: false,
menuWidth: 180,
dialogType: 'drawer',
dialogWidth: 800,
column: [
{ label: '请求接口', prop: 'requestUri', search: true, slot: true },
{ label: '请求方法', prop: 'method', slot: true, width: 90 },
{
label: '限流维度',
prop: 'limitDimension',
type: 'select',
slot: true,
width: 100,
dicData: [
{ label: '全局', value: 'GLOBAL' },
{ label: '租户', value: 'TENANT' },
{ label: 'IP', value: 'IP' },
{ label: '用户', value: 'USER' },
],
},
{ label: '限流速率', prop: 'rate', slot: true, formslot: true, width: 150 },
{ label: '租户ID', prop: 'tenantId', hide: true },
{ label: '账号', prop: 'createBy', hide: true },
{ label: '触发IP', prop: 'remoteIp', search: true, width: 140 },
{ label: '服务ID', prop: 'serviceId', hide: true },
{ label: '服务IP', prop: 'serverIp', hide: true, width: 160 },
{ label: '环境', prop: 'env', hide: true, width: 85 },
{ label: '用户代理', prop: 'userAgent', span: 24, hide: true },
{ label: '触发时间', prop: 'createTime',
sortable: true, span: 24, width: 170 },
{ label: '请求数据', prop: 'params', type: 'textarea', span: 24, minRows: 2, hide: true },
],
},
};
},
computed: {
...mapGetters(['userInfo']),
ids() {
let ids = [];
this.selectionList.forEach(ele => {
ids.push(ele.id);
});
return ids.join(',');
},
},
watch: {
// 同步动态多行输入到表单字段,使匹配路径的必填校验生效
limitPatternList: {
handler() {
this.form.limitPattern = this.buildLimitPattern();
},
deep: true,
},
},
methods: {
timeUnitLabel(value) {
const item = this.timeUnitDic.find(unit => unit.value === value);
return item ? item.label : value;
},
requestMethodLabel(value) {
const item = this.requestMethodDic.find(method => method.value === value);
return item ? item.label : value;
},
limitDimensionLabel(value) {
const item = this.limitDimensionDic.find(dimension => dimension.value === value);
return item ? item.label : value;
},
addLimitPattern() {
this.limitPatternList.push('');
},
removeLimitPattern(index) {
this.limitPatternList.splice(index, 1);
},
parseLimitPattern(limitPattern) {
if (!limitPattern) return [];
return limitPattern
.split(',')
.map(pattern => pattern.trim())
.filter(pattern => pattern.length > 0);
},
buildLimitPattern() {
const patterns = this.limitPatternList
.map(pattern => pattern && pattern.trim())
.filter(pattern => pattern && pattern.length > 0);
return patterns.length > 0 ? patterns.join(',') : '';
},
rowSave(row, done, loading) {
add(row).then(
() => {
this.onLoad(this.page);
this.$message({ type: 'success', message: '操作成功!' });
done();
},
error => {
window.console.log(error);
loading();
}
);
},
rowUpdate(row, index, done, loading) {
update(row).then(
() => {
this.onLoad(this.page);
this.$message({ type: 'success', message: '操作成功!' });
done();
},
error => {
window.console.log(error);
loading();
}
);
},
rowDel(row) {
this.$confirm('确定将选择数据删除?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
return remove(row.id);
})
.then(() => {
this.onLoad(this.page);
this.$message({ type: 'success', message: '操作成功!' });
});
},
handleEnable(row) {
this.$confirm('确定启用该限流规则?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
return enable(row.id);
})
.then(() => {
this.onLoad(this.page);
this.$message({ type: 'success', message: '启用成功!' });
});
},
handleDisable(row) {
this.$confirm('确定禁用该限流规则?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
return disable(row.id);
})
.then(() => {
this.onLoad(this.page);
this.$message({ type: 'success', message: '禁用成功!' });
});
},
handleDelete() {
if (this.selectionList.length === 0) {
this.$message.warning('请选择至少一条数据');
return;
}
this.$confirm('确定将选择数据删除?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
return remove(this.ids);
})
.then(() => {
this.onLoad(this.page);
this.$message({ type: 'success', message: '操作成功!' });
this.$refs.crud.toggleSelection();
});
},
searchReset() {
this.query = {};
this.onLoad(this.page);
},
searchChange(params, done) {
this.query = params;
this.page.currentPage = 1;
this.onLoad(this.page, params);
done();
},
selectionChange(list) {
this.selectionList = list;
},
selectionClear() {
this.selectionList = [];
this.$refs.crud.toggleSelection();
},
beforeOpen(done, type) {
if (['add'].includes(type)) {
this.limitPatternList = [''];
}
if (['edit', 'view'].includes(type)) {
getDetail(this.form.id).then(res => {
this.form = res.data.data;
const patternList = this.parseLimitPattern(this.form.limitPattern);
this.limitPatternList = patternList.length > 0 ? patternList : [''];
});
}
done();
},
currentChange(currentPage) {
this.page.currentPage = currentPage;
},
sizeChange(pageSize) {
this.page.pageSize = pageSize;
},
refreshChange() {
this.onLoad(this.page, this.query);
},
onLoad(page, params = {}) {
this.loading = true;
getList(page.currentPage, page.pageSize, Object.assign(params, this.query)).then(res => {
const data = res.data.data;
this.page.total = data.total;
this.data = data.records;
this.loading = false;
this.selectionClear();
});
},
// ==================== 单条规则触发日志 ====================
handleRowLog(row) {
this.logLimitName = row.limitName || '限流规则';
this.logLimitId = row.id;
this.logDrawerVisible = true;
this.logPage.currentPage = 1;
this.logQuery = {};
this.onLoadLog(this.logPage);
},
beforeOpenLog(done, type) {
if (['view'].includes(type)) {
getLogDetail(this.logForm.id).then(res => {
this.logForm = res.data.data;
});
}
done();
},
logSearchChange(params, done) {
this.logQuery = params;
this.logPage.currentPage = 1;
this.onLoadLog(this.logPage, params);
done();
},
logSearchReset() {
this.logQuery = {};
this.onLoadLog(this.logPage);
},
logCurrentChange(currentPage) {
this.logPage.currentPage = currentPage;
},
logSizeChange(pageSize) {
this.logPage.pageSize = pageSize;
},
logRefreshChange() {
this.onLoadLog(this.logPage, this.logQuery);
},
onLoadLog(page, params = {}) {
this.logLoading = true;
const query = Object.assign({ limitId: this.logLimitId }, params, this.logQuery);
getLogList(page.currentPage, page.pageSize, query).then(res => {
const data = res.data.data;
this.logPage.total = data.total;
this.logData = data.records;
this.logLoading = false;
});
},
},
};
</script>