Files
tms-erp-web/src/views/authority/apikey.vue
赵忠林 565553b3f7 refactor(table): 统一调整全仓表格操作列宽度及按钮样式
- 将所有操作按钮数量≤3的表格操作列宽度统一调整为180px
- 调整涉及全局默认值、各模块和页面中menuWidth显式设定
- 单独修改/authority/role页面操作列宽度为240px,/system/menu及流程配置对应调为220px
- 通过CSS新规则视觉统一Avue内置操作按钮为el-link链接样式,无功能变更
- 优化通知公告操作列宽度,解决三按钮链接换行问题
- 调整首页Logo图标尺寸及边距以适配设计需求
- 保证所有相关改动经过npm run build验证通过
2026-08-07 12:02:29 +08:00

832 lines
24 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('admin')"
@click="handleDelete"
>删除
</el-button>
</template>
<template #status="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
<template #apiKey="{ row }">
<el-tag>{{ row.apiKey }}</el-tag>
</template>
<template #expireTime="{ row }">
<el-tag>{{ row.expireTimeInfo }}</el-tag>
</template>
<template #menu="{ row }">
<el-link
type="primary"
v-if="row.status === -1 && userInfo.authority.includes('admin')"
@click.stop="handleEnable(row)"
>启用
</el-link>
<el-link
type="primary"
v-if="row.status === 1 && userInfo.authority.includes('admin')"
@click.stop="handleDisable(row)"
>禁用
</el-link>
<el-link
type="primary"
v-if="userInfo.authority.includes('admin')"
@click.stop="handleRowLog(row)"
>日志
</el-link>
</template>
<!-- 访问权限动态表单 -->
<template #apiPath-form="{ type }">
<el-row
v-for="(item, index) in apiPathList"
:key="index"
:gutter="8"
style="margin-bottom: 16px"
>
<el-col :span="22">
<el-input
v-model="apiPathList[index]"
placeholder="请输入URL路径"
:disabled="type === 'view'"
/>
</el-col>
<el-col :span="2" v-if="type !== 'view'">
<el-button type="danger" text icon="el-icon-delete" @click="removeApiPath(index)" />
</el-col>
</el-row>
<el-row v-if="type !== 'view'" :gutter="8">
<el-col :span="8">
<el-link type="primary" @click="addApiPath"
>添加路径</el-link
>
</el-col>
</el-row>
<el-row style="margin-top: 8px">
<el-text type="info" size="small">
<el-icon><WarningFilled /></el-icon>
提示: 支持 Ant 风格路径匹配路径需以服务前缀开头 /** 匹配所有/blade-system/user/*
匹配单层/blade-system/user/** 匹配多层
</el-text>
</el-row>
</template>
<!-- 扩展参数动态表单 -->
<template #extParams-form="{ type }">
<el-row
v-for="(item, index) in extParamsList"
:key="index"
:gutter="8"
style="margin-bottom: 16px"
>
<el-col :span="8">
<el-input v-model="item.key" placeholder="参数名" :disabled="type === 'view'" />
</el-col>
<el-col :span="14">
<el-input v-model="item.value" placeholder="参数值" :disabled="type === 'view'" />
</el-col>
<el-col :span="2" v-if="type !== 'view'">
<el-button type="danger" text icon="el-icon-delete" @click="removeExtParam(index)" />
</el-col>
</el-row>
<el-row v-if="type !== 'view'" :gutter="8">
<el-col :span="8">
<el-link type="primary" @click="addExtParam"
>添加参数</el-link
>
</el-col>
</el-row>
<el-row style="margin-top: 8px">
<el-text type="info" size="small">
<el-icon><WarningFilled /></el-icon>
提示: 可配置 deptIdpostIdroleId 等参数自定义 API Key 属性
</el-text>
</el-row>
</template>
</avue-crud>
<!-- 单个密钥调用日志 Drawer -->
<el-drawer
:title="`[${logKeyName}] 调用日志`"
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>
</avue-crud>
</basic-container>
</el-drawer>
<!-- API Key 创建成功弹框 -->
<el-dialog
v-model="apiKeyDialogVisible"
:show-close="false"
:close-on-click-modal="false"
:close-on-press-escape="false"
width="620px"
align-center
title=""
>
<el-row justify="center">
<el-icon :size="48" color="#10b981"><CircleCheckFilled /></el-icon>
</el-row>
<el-row justify="center" style="margin-top: 16px">
<el-text size="large" tag="b">API Key 创建成功</el-text>
</el-row>
<el-row style="margin-top: 24px">
<el-divider content-position="left">
<el-text type="primary" tag="b">密钥信息</el-text>
</el-divider>
</el-row>
<el-row style="margin-top: 12px">
<el-card
shadow="never"
style="width: 100%; background: #f5f7fa"
:body-style="{ textAlign: 'center', padding: '12px' }"
>
<el-text tag="code" style="word-break: break-all">{{ createdApiKey }}</el-text>
</el-card>
</el-row>
<el-row justify="center" style="margin-top: 12px">
<el-space :size="6">
<el-icon color="#3b82f6" :size="16"><Clock /></el-icon>
<el-text size="small" v-if="createdExpireTime">过期时间{{ createdExpireTime }}</el-text>
<el-text size="small" v-else>过期时间永久有效</el-text>
</el-space>
</el-row>
<el-row justify="center" style="margin-top: 12px">
<el-space :size="6">
<el-icon color="#f59e0b" :size="16"><WarningFilled /></el-icon>
<el-text size="small" type="warning">请尽快复制保存关闭后将无法再次查看</el-text>
</el-space>
</el-row>
<el-row style="margin-top: 24px">
<el-divider content-position="left">
<el-text type="primary" tag="b">使用说明</el-text>
</el-divider>
</el-row>
<el-row style="margin-top: 12px">
<el-text type="info" size="small"> 在调用 API 需要在请求头中携带以下信息 </el-text>
</el-row>
<el-row style="margin-top: 12px">
<el-card
shadow="never"
style="width: 100%; background: #f5f7fa"
:body-style="{ padding: '12px' }"
>
<el-space direction="vertical" :size="8" style="width: 100%; align-items: flex-start">
<el-text size="small" style="word-break: break-all">
<el-text type="primary" tag="b">Blade-Auth:</el-text> {{ createdApiKey }}
</el-text>
<el-text size="small">
<el-text type="primary" tag="b">Blade-Requested-With:</el-text> BladeHttpRequest
</el-text>
</el-space>
</el-card>
</el-row>
<el-row style="margin-top: 16px">
<el-text type="info" size="small">cURL 调用示例</el-text>
</el-row>
<el-row style="margin-top: 8px">
<el-card
shadow="never"
style="width: 100%; background: #1e1e1e"
:body-style="{ padding: '12px' }"
>
<el-text
type="success"
size="small"
tag="code"
style="
color: #a6e3a1;
word-break: break-all;
white-space: pre-wrap;
font-family: 'Courier New', monospace;
line-height: 1.6;
"
>{{ curlCommand }}</el-text
>
</el-card>
</el-row>
<template #footer>
<el-row justify="center" :gutter="12">
<el-col :span="12" style="text-align: right; padding-right: 6px">
<el-button type="primary" size="large" @click="copyAndCloseDialog" style="width: 100%">
<el-space>
<el-icon><DocumentCopy /></el-icon>
<span>复制 API Key</span>
</el-space>
</el-button>
</el-col>
<el-col :span="12" style="text-align: left; padding-left: 6px">
<el-button size="large" @click="copyCurlCommand" style="width: 100%">
<el-space>
<el-icon><DocumentCopy /></el-icon>
<span>复制 cURL 命令</span>
</el-space>
</el-button>
</el-col>
</el-row>
</template>
</el-dialog>
</basic-container>
</template>
<script>
import { getList, getDetail, remove, add, update, enable, disable } from '@/api/system/apikey';
import { getList as getLogList, getDetail as getLogDetail } from '@/api/system/apikeylog';
import { getUserPage, getUser } from '@/api/system/user';
import { mapGetters } from 'vuex';
import {
CircleCheckFilled,
Clock,
WarningFilled,
DocumentCopy,
Delete,
Plus,
Notebook,
} from '@element-plus/icons-vue';
export default {
components: {
CircleCheckFilled,
Clock,
WarningFilled,
DocumentCopy,
Delete,
Plus,
Notebook,
},
data() {
return {
form: {},
query: {},
loading: true,
apiPathList: [],
extParamsList: [],
apiKeyDialogVisible: false,
createdApiKey: '',
createdExpireTime: '',
page: {
pageSize: 10,
currentPage: 1,
total: 0,
},
selectionList: [],
option: {
height: 'auto',
calcHeight: 32,
dialogWidth: 700,
menuWidth: 180,
tip: false,
searchShow: true,
searchMenuSpan: 24,
searchIcon: true,
searchIndex: 8,
searchMenuPosition: 'right',
border: true,
index: true,
viewBtn: true,
selection: true,
dialogClickModal: false,
column: [
{
label: 'API Key',
prop: 'apiKey',
slot: true,
addDisplay: false,
editDisplay: false,
span: 24,
width: 280,
},
{
label: '密钥名称',
prop: 'name',
search: true,
editDisabled: true,
span: 24,
rules: [
{
required: true,
message: '请输入密钥名称',
trigger: 'blur',
},
],
},
{
label: '绑定用户',
prop: 'userId',
type: 'table',
editDisabled: true,
props: {
label: 'name',
value: 'id',
},
children: {
border: true,
column: [
{
label: '账号',
prop: 'account',
search: true,
},
{
label: '姓名',
prop: 'name',
search: true,
},
{
label: '角色',
prop: 'roleName',
},
{
label: '部门',
prop: 'deptName',
},
{
label: '岗位',
prop: 'postName',
},
],
},
formatter(row) {
if (!row.name) return '';
return row.name + '' + row.account + '';
},
onLoad: ({ page, value, data }, callback) => {
if (value) {
getUser(value).then(res => {
callback(res.data.data);
});
return;
}
// 首次打开弹窗时设置默认每页5条
if (page && (!page.pageSizes || !page.pageSizes.includes(5))) {
page.pageSize = 5;
page.pageSizes = [5, 10, 20];
}
let currentPage = page ? page.currentPage : 1;
let pageSize = page ? page.pageSize : 5;
let param = {};
if (data) {
param = data;
}
getUserPage(currentPage, pageSize, param.account, param.name).then(res => {
callback({
total: res.data.data.total,
data: res.data.data.records,
});
});
},
span: 24,
rules: [
{
required: true,
message: '请选择用户',
trigger: 'change',
},
],
},
{
label: '过期时间',
prop: 'expireTime',
type: 'datetime',
format: 'YYYY-MM-DD HH:mm:ss',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
span: 24,
width: 180,
slot: true,
},
{
label: '状态',
prop: 'status',
type: 'select',
slot: true,
search: true,
dicData: [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
],
value: 1,
span: 24,
display: false,
width: 80,
},
{
label: '访问权限',
labelTip: '配置允许访问的 API 路径,留空则表示不限制访问路径',
prop: 'apiPath',
formslot: true,
hide: true,
span: 24,
},
{
label: '扩展参数',
labelTip: '配置扩展参数,可用于自定义 API Key 属性',
prop: 'extParams',
formslot: true,
hide: true,
span: 24,
},
],
},
data: [],
// 单个密钥日志
logDrawerVisible: false,
logKeyName: '',
logApiKeyId: 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: '服务ID', prop: 'serviceId', width: 120 },
{ label: '服务host', prop: 'serverHost', hide: true },
{ label: '服务IP', prop: 'serverIp', hide: true, width: 160 },
{ label: '软件环境', prop: 'env', hide: true, width: 85 },
{ label: '请求接口', prop: 'requestUri', search: true, slot: true },
{ label: '请求方法', prop: 'method', slot: true, width: 85 },
{ label: '调用IP', prop: 'remoteIp', search: true, width: 140 },
{ label: '耗时(ms)', prop: 'time', width: 85 },
{ label: '日志时间', prop: 'createTime', span: 24, width: 180 },
{ label: '用户代理', prop: 'userAgent', span: 24, hide: true },
{ 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(',');
},
curlCommand() {
if (!this.createdApiKey) return '';
return `curl -X GET 'https://api.bladex.cn/blade-system/user/info' \\
-H 'Blade-Auth: ${this.createdApiKey}' \\
-H 'Blade-Requested-With: BladeHttpRequest'`;
},
},
methods: {
copyAndCloseDialog() {
navigator.clipboard
.writeText(this.createdApiKey)
.then(() => {
this.$message({
type: 'success',
message: 'API Key 已复制到剪贴板',
});
this.apiKeyDialogVisible = false;
this.createdApiKey = '';
this.createdExpireTime = '';
})
.catch(() => {
this.$message({
type: 'error',
message: '复制失败,请手动复制',
});
});
},
copyCurlCommand() {
navigator.clipboard
.writeText(this.curlCommand)
.then(() => {
this.$message({
type: 'success',
message: 'cURL 命令已复制到剪贴板',
});
})
.catch(() => {
this.$message({
type: 'error',
message: '复制失败,请手动复制',
});
});
},
showApiKeyDialog(apiKey, expireTime) {
this.createdApiKey = apiKey;
this.createdExpireTime = expireTime || '';
this.apiKeyDialogVisible = true;
},
addApiPath() {
this.apiPathList.push('');
},
removeApiPath(index) {
this.apiPathList.splice(index, 1);
},
parseApiPath(apiPath) {
if (!apiPath) return [];
return apiPath
.split(',')
.map(path => path.trim())
.filter(path => path.length > 0);
},
buildApiPath() {
const paths = this.apiPathList.filter(path => path && path.trim());
return paths.length > 0 ? paths.join(',') : '';
},
addExtParam() {
this.extParamsList.push({ key: '', value: '' });
},
removeExtParam(index) {
this.extParamsList.splice(index, 1);
},
parseExtParams(extParams) {
if (!extParams) return [];
try {
const obj = typeof extParams === 'string' ? JSON.parse(extParams) : extParams;
return Object.entries(obj).map(([key, value]) => ({ key, value: String(value) }));
} catch (e) {
return [];
}
},
buildExtParams() {
const result = {};
this.extParamsList.forEach(item => {
if (item.key && item.key.trim()) {
result[item.key.trim()] = item.value;
}
});
return Object.keys(result).length > 0 ? JSON.stringify(result) : '';
},
rowSave(row, done, loading) {
const data = {
...row,
apiPath: this.buildApiPath(),
extParams: this.buildExtParams(),
};
add(data).then(
res => {
this.onLoad(this.page);
done();
// 显示 API Key 弹框
this.showApiKeyDialog(res.data.data, row.expireTime);
},
error => {
window.console.log(error);
loading();
}
);
},
rowUpdate(row, index, done, loading) {
const data = {
...row,
apiPath: this.buildApiPath(),
extParams: this.buildExtParams(),
};
update(data).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('确定启用该 API Key?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
return enable(row.id);
})
.then(() => {
this.onLoad(this.page);
this.$message({
type: 'success',
message: '启用成功!',
});
});
},
handleDisable(row) {
this.$confirm('确定禁用该 API Key?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
return disable(row.id);
})
.then(() => {
this.onLoad(this.page);
this.$message({
type: 'success',
message: '禁用成功!',
});
});
},
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();
},
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();
});
},
beforeOpen(done, type) {
if (['add'].includes(type)) {
this.apiPathList = [];
this.extParamsList = [];
}
if (['edit', 'view'].includes(type)) {
getDetail(this.form.id).then(res => {
this.form = res.data.data;
this.apiPathList = this.parseApiPath(this.form.apiPath);
this.extParamsList = this.parseExtParams(this.form.extParams);
});
}
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.logKeyName = row.name || 'API Key';
this.logApiKeyId = 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({ apiKeyId: this.logApiKeyId }, 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>