feat: 新增LDAP认证

This commit is contained in:
hukdoesn
2025-07-23 13:44:47 +08:00
parent 5c89db4337
commit 82b7614c27
14 changed files with 1495 additions and 192 deletions

View File

@@ -38,6 +38,22 @@
</template>
</a-input-password>
</a-form-item>
<!-- LDAP认证选项 -->
<a-form-item v-if="ldapEnabled">
<div class="auth-type-selection">
<a-switch
v-model:checked="useLDAP"
checked-children="LDAP"
un-checked-children="系统"
@change="handleAuthTypeChange"
/>
<span class="auth-type-label">
{{ useLDAP ? 'LDAP认证' : '系统认证' }}
</span>
</div>
</a-form-item>
<a-form-item>
<a-button
type="primary"
@@ -53,14 +69,14 @@
</a-form-item>
</a-form>
<div class="footer-text">
<p>© 2023 LiteOps 胡图图不涂涂</p>
<p>© 2024 LiteOps 胡图图不涂涂</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue';
import { ref, reactive, onMounted } from 'vue';
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { useRouter } from 'vue-router';
@@ -69,6 +85,8 @@ import { initUserPermissions } from '../../utils/permission';
const router = useRouter();
const loading = ref(false);
const useLDAP = ref(false);
const ldapEnabled = ref(false);
const formState = reactive({
username: '',
@@ -89,12 +107,30 @@ const rules = {
],
};
const handleAuthTypeChange = (checked) => {
console.log('认证类型变更:', checked ? 'LDAP' : '系统');
};
// 检查LDAP是否启用
const checkLdapStatus = async () => {
try {
const response = await axios.get('/api/system/ldap/status/');
if (response.data.code === 200) {
ldapEnabled.value = response.data.data.enabled;
}
} catch (error) {
console.error('检查LDAP状态失败:', error);
ldapEnabled.value = false;
}
};
const handleSubmit = async (values) => {
try {
loading.value = true;
const response = await axios.post('/api/login/', {
username: values.username,
password: values.password,
auth_type: useLDAP.value ? 'ldap' : 'system'
});
if (response.data.code === 200) {
@@ -117,6 +153,11 @@ const handleSubmit = async (values) => {
loading.value = false;
}
};
// 组件挂载时检查LDAP状态
onMounted(() => {
checkLdapStatus();
});
</script>
<style scoped>
@@ -155,6 +196,24 @@ const handleSubmit = async (values) => {
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.15));
}
.auth-type-selection {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 10px;
}
.auth-type-label {
color: rgba(0, 0, 0, 0.65);
font-size: 14px;
font-weight: 500;
}
:deep(.ant-switch-checked) {
background-color: #1890ff;
}
:deep(.ant-input-affix-wrapper) {
height: 55px;
border-radius: 12px;

View File

@@ -94,7 +94,6 @@
</a-form-item>
</a-form>
<!-- 分割线 -->
<a-divider>水印配置</a-divider>
<!-- 水印功能 -->
@@ -159,7 +158,6 @@
</a-row>
</a-form>
<!-- 分割线 -->
<a-divider>日志清理</a-divider>
<!-- 日志清理功能 -->
@@ -294,6 +292,277 @@
</a-table>
</a-card>
</a-tab-pane>
<!-- 认证配置 -->
<a-tab-pane key="auth-config" tab="认证">
<a-card>
<a-form
ref="ldapFormRef"
:model="ldapConfig"
:rules="ldapRules"
layout="vertical"
>
<a-row :gutter="24">
<a-col :span="24">
<a-form-item>
<a-switch
v-model:checked="ldapConfig.enabled"
checked-children="启用"
un-checked-children="禁用"
@change="handleLdapEnabledChange"
/>
<span style="margin-left: 12px; font-weight: 500;">启用LDAP认证</span>
</a-form-item>
</a-col>
</a-row>
<template v-if="ldapConfig.enabled">
<a-divider>服务器配置</a-divider>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="服务器地址" name="server_host">
<a-input
v-model:value="ldapConfig.server_host"
placeholder="例如: ldap.example.com"
/>
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item label="端口" name="server_port">
<a-input-number
v-model:value="ldapConfig.server_port"
:min="1"
:max="65535"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item label="使用SSL">
<a-switch v-model:checked="ldapConfig.use_ssl" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="24">
<a-form-item label="Base DN" name="base_dn">
<a-input
v-model:value="ldapConfig.base_dn"
placeholder="例如: dc=example,dc=com"
/>
<div class="form-item-help">
LDAP搜索的起始点通常是你的域名: dc=company,dc=com
</div>
</a-form-item>
</a-col>
</a-row>
<a-divider>高级配置</a-divider>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="绑定DN" name="bind_dn">
<a-input
v-model:value="ldapConfig.bind_dn"
placeholder="cn=admin,dc=example,dc=com"
/>
<div class="form-item-help">
管理员账户DN用于连接LDAP服务器搜索和认证用户
</div>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="绑定密码" name="bind_password">
<a-input-password
v-model:value="ldapConfig.bind_password"
placeholder="输入新密码或留空保持原密码"
/>
<div class="form-item-help">
管理员账户密码
</div>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="18">
<a-form-item label="用户搜索过滤器">
<a-input
v-model:value="ldapConfig.user_search_filter"
placeholder="例如: (uid={username})"
/>
<div class="form-item-help">
用于搜索用户的LDAP过滤器{username} 将被替换为实际用户名
</div>
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item label="连接超时(秒)">
<a-input-number
v-model:value="ldapConfig.timeout"
:min="1"
:max="60"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="24">
<a-form-item label="属性映射配置" name="user_attr_map">
<a-textarea
v-model:value="userAttrMapJson"
placeholder='示例:
{
"username": "cn",
"name": "uid",
"email": "mail"
}'
:rows="6"
@blur="handleAttrMapChange"
/>
</a-form-item>
</a-col>
</a-row>
<a-divider>用户同步</a-divider>
<a-row :gutter="24">
<a-col :span="16">
<a-form-item label="搜索条件">
<a-input
v-model:value="ldapSyncForm.searchFilter"
placeholder="例如: uid=user* 或 mail=搜索email"
/>
<div class="form-item-help">
可以指定搜索条件来过滤LDAP用户留空则搜索所有用户
</div>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="操作">
<a-button
type="primary"
@click="handleSearchLdapUsers"
:loading="ldapSyncLoading"
style="margin-right: 8px"
>
搜索用户
</a-button>
<a-button
type="default"
@click="handleSyncSelectedUsers"
:loading="ldapSyncLoading"
:disabled="selectedUsers.length === 0"
>
同步选中
</a-button>
</a-form-item>
</a-col>
</a-row>
<!-- 搜索结果 -->
<div v-if="ldapUsers.length > 0">
<h4>搜索结果</h4>
<a-table
:columns="ldapUserColumns"
:data-source="ldapUsers"
:pagination="{ pageSize: 10 }"
:row-selection="{ selectedRowKeys: selectedUsers, onChange: onSelectUsers }"
row-key="username"
size="small"
style="margin-bottom: 16px"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'exists'">
<a-tag :color="record.exists ? 'rgba(56, 158, 13, 0.8)' : 'rgba(22,119,255,0.8)'">
{{ record.exists ? '已存在' : '新用户' }}
</a-tag>
</template>
</template>
</a-table>
</div>
<!-- 同步结果 -->
<div v-if="syncResult">
<a-alert
:message="syncResult.success ? '用户同步成功' : '用户同步失败'"
:description="syncResult.message"
:type="syncResult.success ? 'success' : 'error'"
show-icon
style="margin-bottom: 16px"
/>
<div v-if="syncResult.success && syncResult.synced_users">
<h4>同步详情</h4>
<ul>
<li v-for="user in syncResult.synced_users" :key="user.username">
<strong>{{ user.username }}</strong> - {{ user.action === 'created' ? '新建' : '更新' }}
</li>
</ul>
</div>
</div>
<a-row v-if="ldapTestResult" :gutter="24">
<a-col :span="24">
<a-alert
:message="ldapTestResult.success ? 'LDAP连接测试成功' : 'LDAP连接测试失败'"
:description="ldapTestResult.message"
:type="ldapTestResult.success ? 'success' : 'error'"
show-icon
style="margin-bottom: 16px"
/>
<div v-if="ldapTestResult.success && ldapTestResult.connection_info">
<h4>连接信息</h4>
<a-descriptions size="small" bordered>
<a-descriptions-item label="服务器">
{{ ldapTestResult.connection_info.server }}
</a-descriptions-item>
<a-descriptions-item label="绑定DN">
{{ ldapTestResult.connection_info.bind_dn }}
</a-descriptions-item>
<a-descriptions-item label="Base DN">
{{ ldapTestResult.connection_info.base_dn }}
</a-descriptions-item>
<a-descriptions-item label="连接状态">
{{ ldapTestResult.connection_info.connection_status }}
</a-descriptions-item>
</a-descriptions>
</div>
</a-col>
</a-row>
</template>
<a-form-item>
<a-space>
<a-button
type="primary"
@click="saveLdapConfig"
:loading="ldapConfigLoading"
v-if="hasFunctionPermission('system_basic', 'edit')"
>
保存配置
</a-button>
<a-button
@click="handleLdapTest"
:loading="ldapTestLoading"
:disabled="!canTestConnection"
v-if="hasFunctionPermission('system_basic', 'edit')"
>
测试连接
</a-button>
</a-space>
<div v-if="!canTestConnection" class="form-item-help" style="margin-top: 8px;">
请先启用LDAP认证并完成所有必要配置点击保存配置后再测试连接
</div>
</a-form-item>
</a-form>
</a-card>
</a-tab-pane>
</a-tabs>
<!-- 添加/编辑机器人抽屉 -->
@@ -440,7 +709,7 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { ref, reactive, onMounted, computed } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import axios from 'axios';
@@ -448,6 +717,127 @@ import { hasFunctionPermission, checkPermission } from '../../utils/permission';
const activeTabKey = ref('security-config');
// LDAP配置
const ldapFormRef = ref();
const ldapConfigLoading = ref(false);
const ldapTestLoading = ref(false);
const ldapTestResult = ref(null);
// 是否可以测试连接
const canTestConnection = computed(() => {
// 是否启用且所有必要配置都已填写
const hasPassword = ldapConfig.bind_password &&
ldapConfig.bind_password.trim() &&
ldapConfig.bind_password !== '';
return ldapConfig.enabled &&
hasPassword &&
ldapConfig.server_host &&
ldapConfig.server_host.trim() &&
ldapConfig.base_dn &&
ldapConfig.base_dn.trim() &&
ldapConfig.bind_dn &&
ldapConfig.bind_dn.trim();
});
const ldapConfig = reactive({
enabled: false,
server_host: '',
server_port: 389,
use_ssl: false,
base_dn: '',
bind_dn: '',
bind_password: '',
user_search_filter: '(cn={username})',
user_attr_map: {
username: 'cn',
name: 'uid',
email: 'mail'
},
timeout: 10
});
// LDAP用户同步相关
const ldapSyncForm = reactive({
searchFilter: ''
});
const ldapUsers = ref([]);
const selectedUsers = ref([]);
const ldapSyncLoading = ref(false);
const syncResult = ref(null);
// 用户属性映射JSON字符串
const userAttrMapJson = ref('');
// LDAP用户表格列定义
const ldapUserColumns = [
{
title: '用户名',
dataIndex: 'username',
key: 'username',
},
{
title: '姓名',
dataIndex: 'name',
key: 'name',
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
},
{
title: '状态',
dataIndex: 'exists',
key: 'exists',
},
];
// 验证JSON格式的自定义验证器
const validateAttrMapJson = (rule, value) => {
// 使用userAttrMapJson的值进行验证
const jsonValue = userAttrMapJson.value;
if (!jsonValue || !jsonValue.trim()) {
return Promise.reject('请输入属性映射配置');
}
try {
const parsed = JSON.parse(jsonValue);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return Promise.reject('属性映射必须是一个JSON对象');
}
// 检查是否包含必要的字段
if (!parsed.username) {
return Promise.reject('属性映射必须包含 username 字段');
}
return Promise.resolve();
} catch (error) {
return Promise.reject('JSON格式错误请检查语法');
}
};
const ldapRules = {
server_host: [
{ required: true, message: '请输入服务器地址', trigger: 'blur' }
],
base_dn: [
{ required: true, message: '请输入Base DN', trigger: 'blur' }
],
bind_dn: [
{ required: true, message: '请输入绑定DN', trigger: 'blur' }
],
bind_password: [
{ required: true, message: '请输入绑定密码', trigger: 'blur' }
],
user_attr_map: [
{ validator: validateAttrMapJson, trigger: 'blur' }
]
};
// 安全配置
const securityFormRef = ref();
const securityConfig = reactive({
@@ -495,7 +885,6 @@ const robotColumns = [
title: '类型',
dataIndex: 'type',
key: 'type',
// width: 120,
},
{
title: '机器人名称',
@@ -512,7 +901,6 @@ const robotColumns = [
title: '安全设置',
dataIndex: 'security_type',
key: 'security_type',
// width: 120,
},
{
title: '备注',
@@ -524,12 +912,10 @@ const robotColumns = [
title: '创建时间',
dataIndex: 'create_time',
key: 'create_time',
// width: 180,
},
{
title: '操作',
key: 'action',
// width: 200,
fixed: 'right',
},
];
@@ -548,7 +934,6 @@ const robotForm = reactive({
remark: '',
});
// 加载状态
const securityLoading = ref(false);
// 标签页切换
@@ -557,6 +942,8 @@ const handleTabChange = (key) => {
loadRobotList();
} else if (key === 'security-config') {
loadBuildTasksList();
} else if (key === 'auth-config') {
fetchLdapConfig();
}
};
@@ -879,9 +1266,277 @@ const handleLogCleanup = async () => {
}
};
// LDAP配置相关方法
const fetchLdapConfig = async () => {
try {
const token = localStorage.getItem('token');
const response = await axios.get('/api/system/ldap/', {
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
const configData = response.data.data;
Object.assign(ldapConfig, configData);
if (configData.bind_password === '******') {
ldapConfig.bind_password = '******'; // 保持标识,表示后端有密码
}
// 初始化属性映射JSON字符串
initUserAttrMapJson();
} else {
message.error(response.data.message || '获取LDAP配置失败');
}
} catch (error) {
console.error('获取LDAP配置失败:', error);
message.error('获取LDAP配置失败');
}
};
const saveLdapConfig = async () => {
try {
await ldapFormRef.value?.validate();
// 验证属性映射JSON格式
try {
if (userAttrMapJson.value.trim()) {
const parsedMap = JSON.parse(userAttrMapJson.value);
ldapConfig.user_attr_map = parsedMap;
}
} catch (error) {
message.error('属性映射JSON格式错误请检查语法');
return;
}
ldapConfigLoading.value = true;
// 准备发送的数据
const configToSave = { ...ldapConfig };
if (configToSave.bind_password === '******') {
delete configToSave.bind_password;
}
const token = localStorage.getItem('token');
const response = await axios.put('/api/system/ldap/', configToSave, {
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
message.success('LDAP配置保存成功');
// 重新获取配置保状态同步
await fetchLdapConfig();
} else {
message.error(response.data.message || '保存失败');
}
} catch (error) {
console.error('保存LDAP配置失败:', error);
message.error('保存失败');
} finally {
ldapConfigLoading.value = false;
}
};
// LDAP启用状态变化
const handleLdapEnabledChange = (enabled) => {
if (!enabled) {
// 禁用LDAP时清除所有配置
ldapConfig.server_host = '';
ldapConfig.server_port = 389;
ldapConfig.use_ssl = false;
ldapConfig.base_dn = '';
ldapConfig.bind_dn = '';
ldapConfig.bind_password = '';
ldapConfig.user_search_filter = '(cn={username})';
ldapConfig.user_attr_map = { username: 'cn', name: 'uid', email: 'mail' };
ldapConfig.timeout = 10;
// 重置属性映射JSON字符串
initUserAttrMapJson();
// 清除测试结果
ldapTestResult.value = null;
syncResult.value = null;
ldapUsers.value = [];
selectedUsers.value = [];
}
};
// 处理属性映射JSON变化
const handleAttrMapChange = () => {
try {
if (userAttrMapJson.value.trim()) {
const parsedMap = JSON.parse(userAttrMapJson.value);
ldapConfig.user_attr_map = parsedMap;
}
} catch (error) {
message.error('JSON格式错误请检查语法');
}
};
// 初始化属性映射JSON字符串
const initUserAttrMapJson = () => {
if (ldapConfig.user_attr_map && Object.keys(ldapConfig.user_attr_map).length > 0) {
userAttrMapJson.value = JSON.stringify(ldapConfig.user_attr_map, null, 2);
} else {
// 提供默认的映射配置
const defaultMapping = {
username: 'cn',
name: 'uid',
email: 'mail'
};
userAttrMapJson.value = JSON.stringify(defaultMapping, null, 2);
ldapConfig.user_attr_map = defaultMapping;
}
};
const handleLdapTest = async () => {
try {
ldapTestLoading.value = true;
ldapTestResult.value = null;
const token = localStorage.getItem('token');
const response = await axios.post('/api/system/ldap/test/', {}, {
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
ldapTestResult.value = {
success: true,
message: response.data.message,
connection_info: response.data.data
};
} else {
ldapTestResult.value = {
success: false,
message: response.data.message
};
}
} catch (error) {
console.error('LDAP测试失败:', error);
ldapTestResult.value = {
success: false,
message: '测试失败,请稍后重试'
};
} finally {
ldapTestLoading.value = false;
}
};
// 搜索LDAP用户
const handleSearchLdapUsers = async () => {
try {
ldapSyncLoading.value = true;
syncResult.value = null;
const token = localStorage.getItem('token');
const response = await axios.post('/api/system/ldap/sync/', {
action: 'search',
search_filter: ldapSyncForm.searchFilter
}, {
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
// 检查用户是否已存在
const existingUsers = await getExistingUsers();
ldapUsers.value = response.data.data.users.map(user => ({
...user,
exists: existingUsers.includes(user.username)
}));
selectedUsers.value = [];
message.success(`找到${ldapUsers.value.length}个LDAP用户`);
} else {
message.error(response.data.message || '搜索用户失败');
}
} catch (error) {
console.error('搜索LDAP用户失败:', error);
message.error('搜索用户失败');
} finally {
ldapSyncLoading.value = false;
}
};
// 获取已存在的用户列表
const getExistingUsers = async () => {
try {
const token = localStorage.getItem('token');
const response = await axios.get('/api/users/', {
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
return response.data.data
.filter(user => user.user_type === 'ldap')
.map(user => user.username);
}
return [];
} catch (error) {
console.error('获取已存在用户失败:', error);
return [];
}
};
// 选择用户
const onSelectUsers = (selectedRowKeys) => {
selectedUsers.value = selectedRowKeys;
};
// 同步选中用户
const handleSyncSelectedUsers = async () => {
if (selectedUsers.value.length === 0) {
message.warning('请选择要同步的用户');
return;
}
try {
ldapSyncLoading.value = true;
syncResult.value = null;
const usersToSync = ldapUsers.value.filter(user =>
selectedUsers.value.includes(user.username)
);
const token = localStorage.getItem('token');
const response = await axios.post('/api/system/ldap/sync/', {
action: 'sync',
users: usersToSync
}, {
headers: { 'Authorization': token }
});
if (response.data.code === 200) {
syncResult.value = {
success: true,
message: response.data.message,
synced_users: response.data.data.synced_users
};
// 刷新用户列表
await handleSearchLdapUsers();
} else {
syncResult.value = {
success: false,
message: response.data.message
};
}
} catch (error) {
console.error('同步用户失败:', error);
syncResult.value = {
success: false,
message: '同步用户失败,请稍后重试'
};
} finally {
ldapSyncLoading.value = false;
}
};
onMounted(() => {
fetchSecurityConfig();
loadBuildTasksList();
if (!userAttrMapJson.value) {
initUserAttrMapJson();
}
});
</script>
@@ -942,4 +1597,12 @@ onMounted(() => {
color: rgba(0, 0, 0, 0.45);
margin-top: 4px;
}
:deep(.ant-input) {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
}
:deep(textarea) {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
}
</style>

View File

@@ -40,6 +40,11 @@
</a-tag>
</a-space>
</template>
<template v-else-if="column.key === 'user_type'">
<a-tag :color="record.user_type === 'ldap' ? 'rgba(56, 158, 13, 0.8)' : 'rgba(22,119,255,0.8)'">
{{ record.user_type === 'ldap' ? 'LDAP' : '系统' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a @click="showEditModal(record)">编辑</a>
@@ -87,12 +92,20 @@
<a-input v-model:value="formState.username" :disabled="!!formState.user_id" />
</a-form-item>
<a-form-item label="姓名" name="name">
<a-input v-model:value="formState.name" />
<a-input
v-model:value="formState.name"
:disabled="formState.user_type === 'ldap'"
/>
<div v-if="formState.user_type === 'ldap'" class="form-help">LDAP用户信息由LDAP服务器管理</div>
</a-form-item>
<a-form-item label="邮箱" name="email">
<a-input v-model:value="formState.email" />
<a-input
v-model:value="formState.email"
:disabled="formState.user_type === 'ldap'"
/>
<div v-if="formState.user_type === 'ldap'" class="form-help">LDAP用户信息由LDAP服务器管理</div>
</a-form-item>
<a-form-item label="密码" name="password">
<a-form-item label="密码" name="password" v-if="formState.user_type !== 'ldap'">
<a-input-password v-model:value="formState.password" />
<div v-if="!!formState.user_id" class="form-help">不修改请留空</div>
</a-form-item>
@@ -148,6 +161,11 @@ const columns = [
dataIndex: 'roles',
key: 'roles',
},
{
title: '用户类型',
dataIndex: 'user_type',
key: 'user_type',
},
{
title: '状态',
dataIndex: 'status',
@@ -254,11 +272,16 @@ const formState = reactive({
email: '',
password: '',
role_ids: [],
status: 1
status: 1,
user_type: 'system'
});
// 动态密码验证规则
const passwordValidator = (rule, value) => {
// LDAP用户不需要验证密码
if (formState.user_type === 'ldap') {
return Promise.resolve();
}
if (formState.user_id && !value) {
return Promise.resolve();
}
@@ -350,6 +373,7 @@ const showEditModal = (record) => {
formState.name = record.name;
formState.email = record.email;
formState.status = record.status;
formState.user_type = record.user_type || 'system';
formState.role_ids = record.roles.map(role => role.role_id);
modalVisible.value = true;
};
@@ -366,7 +390,8 @@ const resetForm = () => {
email: '',
password: '',
role_ids: [],
status: 1
status: 1,
user_type: 'system'
});
};

View File

@@ -38,7 +38,7 @@
cancel-text="取消"
@confirm="handleDelete(record)"
>
<a>删除</a>
<a-button type="link" danger>删除</a-button>
</a-popconfirm>
<a-divider type="vertical" />
<a @click="showPermissionModal(record)">权限配置</a>