Files
tms-erp-web/src/views/authority/ratelimit.vue
T
gxwebsoft 3bb0fddcca style(transportCapacity): 调整到期筛选标签位置和样式,统一标签展示逻辑
- transportCapacity/driver、vehicle、ship 页的「全部/30天内到期/已到期」筛选标签改为靠右对齐
- 统一使用 #search-menu 插槽替代 #expireStatus-search,标签移至搜索按钮前
- 标签容器删除宽度限制,增加 order:-1 和 margin-right:12px,保证标签和按钮为一体
- transportCapacity/vehicle 和 ship 的 expireStatus 列 search 改为 false,避免多余输入框出现
- transportCapacity/vehicle、ship 的 searchIndex 提升至6,避免搜索条件折叠,标签默认展开
- 暂用样式更改保证标签与搜索按钮间距和垂直对齐
- temporary-credit-limit 弹窗新增 dialogCustomClass,实现弹窗label固定六字宽度并自动换行
- element-ui.scss 新增临时额度弹窗 label 样式,修改label宽度、换行、对齐,提升可读性
- 调整多处搜索表单内 label 宽度由160px变为88px,优化表单紧凑度
- 多处文本域的最小行数 minRows 统一由较高数值改为2,改善表单布局和输入体验
- 更新 vite.config.mjs 开发服务器端口及代理配置,切换至本地测试环境
- waybill-import-dialog.vue 中修正文本域 autosize 最小行数为2,更符合表单设计规范
- 修改业务页 contract-manage.vue 菜单宽度由245px调整至252px,优化界面布局
2026-08-14 00:33:32 +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: 88,
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: 2,
value: 100,
hide: true,
labelTip: '单个时间窗口内允许通过的最大请求数,超出后触发限流并返回 429',
rules: [{ required: true, message: '请输入请求阈值', trigger: 'blur' }],
},
{
label: '时间窗口',
prop: 'limitPeriod',
type: 'number',
minRows: 2,
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>