484 lines
11 KiB
Vue
484 lines
11 KiB
Vue
<template>
|
||
<div class="pages-sidebar-layout permission-page">
|
||
<div class="left-box">
|
||
<div class="header">
|
||
<div class="search-wrapper">
|
||
<a-input v-model:value="menuFilterText" placeholder="搜索权限名称或编码..." allow-clear
|
||
@change="handleMenuSearch">
|
||
<template #prefix>
|
||
<SearchOutlined style="color: rgba(0, 0, 0, 0.45)" />
|
||
</template>
|
||
</a-input>
|
||
</div>
|
||
<div class="actions">
|
||
<a-space size="small">
|
||
<a-tooltip title="展开全部">
|
||
<a-button type="text" size="small" @click="handleExpandAll">
|
||
<template #icon><UnorderedListOutlined /></template>
|
||
</a-button>
|
||
</a-tooltip>
|
||
<a-tooltip title="折叠全部">
|
||
<a-button type="text" size="small" @click="handleCollapseAll">
|
||
<template #icon><OrderedListOutlined /></template>
|
||
</a-button>
|
||
</a-tooltip>
|
||
<a-tooltip title="添加根权限">
|
||
<a-button type="text" size="small" @click="handleAdd(null)">
|
||
<template #icon><PlusOutlined /></template>
|
||
</a-button>
|
||
</a-tooltip>
|
||
</a-space>
|
||
</div>
|
||
</div>
|
||
<div class="body">
|
||
<a-spin :spinning="loading" :delay="200">
|
||
<a-tree
|
||
ref="treeRef"
|
||
v-model:selectedKeys="selectedMenuKeys"
|
||
v-model:checkedKeys="checkedMenuKeys"
|
||
v-model:expandedKeys="expandedKeys"
|
||
:tree-data="filteredMenuTree"
|
||
:field-names="{ title: 'title', key: 'id', children: 'children' }"
|
||
show-line
|
||
checkable
|
||
:check-strictly="false"
|
||
:expand-on-click-node="false"
|
||
block-node
|
||
@select="onMenuSelect"
|
||
@check="onMenuCheck">
|
||
<template #icon="{ dataRef }">
|
||
<FolderOutlined v-if="dataRef.type === 'menu' && dataRef.children?.length" />
|
||
<FolderOpenOutlined v-else-if="dataRef.type === 'menu'" />
|
||
<ApiOutlined v-else-if="dataRef.type === 'api'" />
|
||
<ControlOutlined v-else />
|
||
</template>
|
||
<template #title="{ dataRef }">
|
||
<span class="tree-node-content">
|
||
<span class="tree-node-title">{{ dataRef.title }}</span>
|
||
<a-tag v-if="dataRef.name" class="tree-node-code" size="small">{{ dataRef.name }}</a-tag>
|
||
<a-tag v-if="dataRef.type !== 'menu'" :color="getTypeColor(dataRef.type)" size="small">
|
||
{{ getTypeLabel(dataRef.type) }}
|
||
</a-tag>
|
||
<span v-if="!dataRef.status" class="tree-node-disabled">
|
||
<StopOutlined />
|
||
</span>
|
||
</span>
|
||
</template>
|
||
</a-tree>
|
||
</a-spin>
|
||
</div>
|
||
</div>
|
||
<div class="right-box">
|
||
<div class="header">
|
||
<div class="title-wrapper">
|
||
<span class="title">{{ selectedMenu?.title || '请选择权限节点' }}</span>
|
||
<a-tag v-if="selectedMenu" :color="getTypeColor(selectedMenu.type)" size="small">
|
||
{{ getTypeLabel(selectedMenu.type) }}
|
||
</a-tag>
|
||
</div>
|
||
<a-space>
|
||
<a-button v-if="checkedMenuKeys.length > 0" danger size="small" @click="handleDeleteBatch">
|
||
<template #icon><DeleteOutlined /></template>
|
||
批量删除 ({{ checkedMenuKeys.length }})
|
||
</a-button>
|
||
<a-button type="link" size="small" @click="handleRefresh">
|
||
<template #icon><ReloadOutlined /></template>
|
||
刷新
|
||
</a-button>
|
||
</a-space>
|
||
</div>
|
||
<div class="body">
|
||
<a-spin :spinning="detailLoading" :delay="200">
|
||
<save-form v-if="selectedMenu" :menu="menuTree" :menu-id="selectedMenu.id" :parent-id="parentId"
|
||
@success="handleSaveSuccess" />
|
||
<a-empty v-else description="请选择左侧权限节点后操作" :image-size="100" />
|
||
</a-spin>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted, nextTick } from 'vue'
|
||
import { message, Modal } from 'ant-design-vue'
|
||
import {
|
||
SearchOutlined,
|
||
ReloadOutlined,
|
||
PlusOutlined,
|
||
FolderOutlined,
|
||
FolderOpenOutlined,
|
||
ApiOutlined,
|
||
ControlOutlined,
|
||
UnorderedListOutlined,
|
||
OrderedListOutlined,
|
||
DeleteOutlined,
|
||
StopOutlined
|
||
} from '@ant-design/icons-vue'
|
||
import saveForm from './components/SaveForm.vue'
|
||
import authApi from '@/api/auth'
|
||
|
||
defineOptions({
|
||
name: 'authPermission'
|
||
})
|
||
|
||
// 菜单树数据
|
||
const menuTree = ref([])
|
||
const filteredMenuTree = ref([])
|
||
const selectedMenuKeys = ref([])
|
||
const checkedMenuKeys = ref([])
|
||
const expandedKeys = ref([])
|
||
const menuFilterText = ref('')
|
||
|
||
// 当前选中的菜单
|
||
const selectedMenu = ref(null)
|
||
const parentId = ref(null)
|
||
|
||
// 加载状态
|
||
const loading = ref(false)
|
||
const detailLoading = ref(false)
|
||
|
||
// 树引用
|
||
const treeRef = ref()
|
||
|
||
// 加载权限树
|
||
const loadMenuTree = async () => {
|
||
try {
|
||
loading.value = true
|
||
const res = await authApi.permissions.tree.get()
|
||
if (res.code === 200) {
|
||
menuTree.value = res.data || []
|
||
filteredMenuTree.value = res.data || []
|
||
// 默认展开第一层
|
||
expandAllKeys(menuTree.value, 1)
|
||
} else {
|
||
message.error(res.message || '加载权限树失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('加载权限树失败:', error)
|
||
message.error('加载权限树失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 刷新
|
||
const handleRefresh = () => {
|
||
loadMenuTree()
|
||
if (selectedMenu.value) {
|
||
// 重新获取当前选中的权限详情
|
||
const menuNode = findMenuNode(menuTree.value, selectedMenu.value.id)
|
||
if (menuNode) {
|
||
selectedMenu.value = menuNode
|
||
}
|
||
}
|
||
}
|
||
|
||
// 搜索权限
|
||
const handleMenuSearch = (e) => {
|
||
const keyword = (e.target?.value || '').trim()
|
||
menuFilterText.value = keyword
|
||
if (!keyword) {
|
||
filteredMenuTree.value = menuTree.value
|
||
return
|
||
}
|
||
|
||
// 递归过滤权限树(支持搜索名称和编码)
|
||
const filterTree = (nodes) => {
|
||
return nodes.reduce((acc, node) => {
|
||
const titleMatch = node.title && node.title.toLowerCase().includes(keyword.toLowerCase())
|
||
const nameMatch = node.name && node.name.toLowerCase().includes(keyword.toLowerCase())
|
||
const isMatch = titleMatch || nameMatch
|
||
const filteredChildren = node.children ? filterTree(node.children) : []
|
||
|
||
if (isMatch || filteredChildren.length > 0) {
|
||
acc.push({
|
||
...node,
|
||
children: filteredChildren.length > 0 ? filteredChildren : undefined
|
||
})
|
||
}
|
||
return acc
|
||
}, [])
|
||
}
|
||
|
||
filteredMenuTree.value = filterTree(menuTree.value)
|
||
// 搜索时展开所有匹配节点
|
||
expandAllKeys(filteredMenuTree.value)
|
||
}
|
||
|
||
// 查找权限节点
|
||
const findMenuNode = (tree, id) => {
|
||
for (const node of tree) {
|
||
if (node.id === id) {
|
||
return node
|
||
}
|
||
if (node.children && node.children.length > 0) {
|
||
const found = findMenuNode(node.children, id)
|
||
if (found) return found
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
// 查找父节点ID
|
||
const findParentId = (tree, id) => {
|
||
for (const node of tree) {
|
||
if (node.children && node.children.length > 0) {
|
||
const child = node.children.find(child => child.id === id)
|
||
if (child) {
|
||
return node.id
|
||
}
|
||
const found = findParentId(node.children, id)
|
||
if (found !== null) return found
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
// 限制选择事件
|
||
const onMenuSelect = (selectedKeys, { selected }) => {
|
||
if (selected) {
|
||
const menuId = selectedKeys[0]
|
||
const menuNode = findMenuNode(menuTree.value, menuId)
|
||
selectedMenu.value = menuNode
|
||
parentId.value = findParentId(menuTree.value, menuId)
|
||
} else {
|
||
selectedMenu.value = null
|
||
parentId.value = null
|
||
}
|
||
}
|
||
|
||
// 限制勾选事件
|
||
const onMenuCheck = (checkedKeys, info) => {
|
||
console.log('checkedKeys:', checkedKeys, 'info:', info)
|
||
}
|
||
|
||
// 获取所有节点ID(用于展开/折叠)
|
||
const getAllKeys = (nodes) => {
|
||
const keys = []
|
||
const traverse = (items) => {
|
||
items.forEach(item => {
|
||
keys.push(item.id)
|
||
if (item.children?.length) {
|
||
traverse(item.children)
|
||
}
|
||
})
|
||
}
|
||
traverse(nodes)
|
||
return keys
|
||
}
|
||
|
||
// 展开全部
|
||
const handleExpandAll = () => {
|
||
expandedKeys.value = getAllKeys(filteredMenuTree.value)
|
||
}
|
||
|
||
// 折叠全部
|
||
const handleCollapseAll = () => {
|
||
expandedKeys.value = []
|
||
}
|
||
|
||
// 自动展开指定层级的节点
|
||
const expandAllKeys = (nodes, maxLevel = 3) => {
|
||
const keys = []
|
||
const traverse = (items, level = 1) => {
|
||
items.forEach(item => {
|
||
if (level < maxLevel && item.children?.length) {
|
||
keys.push(item.id)
|
||
traverse(item.children, level + 1)
|
||
}
|
||
})
|
||
}
|
||
traverse(nodes)
|
||
expandedKeys.value = keys
|
||
}
|
||
|
||
// 获取权限类型标签
|
||
const getTypeLabel = (type) => {
|
||
const typeMap = {
|
||
menu: '菜单',
|
||
api: '接口',
|
||
button: '按钮',
|
||
url: '链接'
|
||
}
|
||
return typeMap[type] || type
|
||
}
|
||
|
||
// 获取权限类型颜色
|
||
const getTypeColor = (type) => {
|
||
const colorMap = {
|
||
menu: 'blue',
|
||
api: 'green',
|
||
button: 'orange',
|
||
url: 'purple'
|
||
}
|
||
return colorMap[type] || 'default'
|
||
}
|
||
|
||
// 批量删除权限
|
||
const handleDeleteBatch = async () => {
|
||
if (checkedMenuKeys.value.length === 0) {
|
||
message.warning('请选择需要删除的权限')
|
||
return
|
||
}
|
||
|
||
Modal.confirm({
|
||
title: '确认删除',
|
||
content: `确定删除已选择的 ${checkedMenuKeys.value.length} 个权限吗?`,
|
||
okText: '删除',
|
||
okType: 'danger',
|
||
cancelText: '取消',
|
||
onOk: async () => {
|
||
try {
|
||
const res = await authApi.permissions.batchDelete.post({ ids: checkedMenuKeys.value })
|
||
if (res.code === 200) {
|
||
message.success('删除成功')
|
||
|
||
// 如果当前选中的权限被删除了,清空选择
|
||
if (selectedMenu.value && checkedMenuKeys.value.includes(selectedMenu.value.id)) {
|
||
selectedMenu.value = null
|
||
selectedMenuKeys.value = []
|
||
}
|
||
|
||
checkedMenuKeys.value = []
|
||
await loadMenuTree()
|
||
} else {
|
||
message.error(res.message || '删除失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('删除权限失败:', error)
|
||
message.error('删除失败')
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// 保存成功回调
|
||
const handleSaveSuccess = async () => {
|
||
await loadMenuTree()
|
||
// 重新设置当前选中的权限
|
||
if (selectedMenu.value) {
|
||
const menuNode = findMenuNode(menuTree.value, selectedMenu.value.id)
|
||
selectedMenu.value = menuNode
|
||
}
|
||
message.success('保存成功')
|
||
}
|
||
|
||
// 初始化
|
||
onMounted(() => {
|
||
loadMenuTree()
|
||
})
|
||
|
||
defineExpose({
|
||
loadMenuTree,
|
||
handleExpandAll,
|
||
handleCollapseAll
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.permission-page {
|
||
.left-box {
|
||
.header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 8px;
|
||
|
||
.search-wrapper {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.actions {
|
||
flex-shrink: 0;
|
||
}
|
||
}
|
||
|
||
.body {
|
||
padding: 8px;
|
||
|
||
:deep(.ant-tree) {
|
||
background: transparent;
|
||
|
||
.ant-tree-node-content-wrapper {
|
||
padding: 4px 0;
|
||
transition: background-color 0.2s;
|
||
|
||
&:hover {
|
||
background-color: #f5f5f5;
|
||
}
|
||
}
|
||
|
||
.ant-tree-switcher {
|
||
color: rgba(0, 0, 0, 0.45);
|
||
}
|
||
|
||
.ant-tree-iconEle {
|
||
margin-right: 6px;
|
||
}
|
||
}
|
||
|
||
.tree-node-content {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
|
||
.tree-node-title {
|
||
font-size: 14px;
|
||
color: #262626;
|
||
}
|
||
|
||
.tree-node-code {
|
||
font-family: 'Consolas', 'Monaco', monospace;
|
||
font-size: 11px;
|
||
background: #f0f0f0;
|
||
border: none;
|
||
padding: 1px 6px;
|
||
border-radius: 2px;
|
||
color: #595959;
|
||
}
|
||
|
||
.tree-node-disabled {
|
||
color: #ff4d4f;
|
||
margin-left: 4px;
|
||
}
|
||
}
|
||
|
||
:deep(.ant-tree-treenode-selected) {
|
||
> .ant-tree-node-content-wrapper {
|
||
background-color: #e6f7ff;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.right-box {
|
||
.header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 12px 16px;
|
||
|
||
.title-wrapper {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
|
||
.title {
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
color: #262626;
|
||
}
|
||
}
|
||
}
|
||
|
||
.body {
|
||
padding: 16px;
|
||
overflow-y: auto;
|
||
background: #fff;
|
||
}
|
||
}
|
||
}
|
||
</style>
|