Files
schoolNews/schoolNewsWeb/src/views/admin/manage/system/MenuManageView.vue
2025-10-30 16:40:56 +08:00

902 lines
23 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>
<AdminLayout
title="系统管理"
subtitle="管理用户、角色、权限、部门等系统信息"
>
<div class="menu-manage">
<div class="header">
<h2>菜单管理</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增菜单
</el-button>
</div>
<div class="tree-container">
<el-tree
ref="treeRef"
v-loading="loading"
:data="menuList"
:props="treeProps"
node-key="menuID"
:default-expand-all="true"
:expand-on-click-node="false"
:check-on-click-node="false"
draggable
:allow-drop="allowDrop"
@node-drop="handleNodeDrop"
class="menu-tree"
>
<template #default="{ data }">
<div class="custom-tree-node">
<div class="node-label">
<img
v-if="data.icon"
:src="PUBLIC_IMG_PATH + '/' + data.icon"
class="node-icon"
:alt="data.name"
/>
<span class="node-name">{{ data.name }}</span>
<el-tag
:type="getMenuTypeTagType(data.type)"
size="small"
>
{{ getMenuTypeText(data.type) }}
</el-tag>
</div>
<div class="node-info">
<span class="info-item">ID: {{ data.menuID }}</span>
<span class="info-item" v-if="data.url">{{ data.url }}</span>
<span class="info-item" v-if="data.component">{{ data.component }}</span>
<span class="info-item">排序: {{ data.orderNum || 0 }}</span>
</div>
<div class="node-actions">
<el-button size="small" type="primary" @click.stop="handleAddChild(data)">
子菜单
</el-button>
<el-button size="small" type="success" @click.stop="handleEdit(data)">
编辑
</el-button>
<el-button size="small" type="warning" @click.stop="handleBindPermission(data)">
权限
</el-button>
<el-button size="small" type="danger" @click.stop="handleDelete(data)">
删除
</el-button>
</div>
</div>
</template>
</el-tree>
</div>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑菜单' : '新增菜单'"
width="600px"
@close="resetForm"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="父菜单" prop="parentID">
<el-cascader
v-model="formData.parentID"
:options="parentMenuOptions"
:props="cascaderProps"
placeholder="请选择父菜单"
clearable
style="width: 100%"
/>
</el-form-item>
<el-form-item label="菜单名称" prop="name">
<el-input
v-model="formData.name"
placeholder="请输入菜单名称"
clearable
/>
</el-form-item>
<el-form-item label="菜单描述" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入菜单描述"
/>
</el-form-item>
<el-form-item label="菜单路径" prop="url">
<el-input
v-model="formData.url"
placeholder="请输入菜单路径"
clearable
/>
</el-form-item>
<el-form-item label="菜单组件" prop="component">
<el-input
v-model="formData.component"
placeholder="请输入菜单组件"
clearable
/>
</el-form-item>
<el-form-item label="菜单图标" prop="icon">
<el-input
v-model="formData.icon"
placeholder="请输入菜单图标"
clearable
/>
</el-form-item>
<el-form-item label="菜单类型" prop="type">
<el-select v-model="formData.type" placeholder="请选择菜单类型" style="width: 100%">
<el-option label="侧边栏" :value="0" />
<el-option label="导航栏" :value="1" />
<el-option label="按钮" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="排序" prop="orderNum">
<el-input-number
v-model="formData.orderNum"
:min="0"
:max="999"
placeholder="请输入排序"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitting"
>
确定
</el-button>
</template>
</el-dialog>
<!-- 绑定权限对话框 -->
<GenericSelector
v-model:visible="bindPermissionDialogVisible"
:title="`绑定权限 - ${currentMenu?.name || ''}`"
left-title="可选权限"
right-title="已选权限"
:fetch-available-api="fetchAllPermissions"
:fetch-selected-api="fetchMenuPermissions"
:filter-selected="filterPermissions"
:item-config="{ id: 'permissionID', label: 'name', sublabel: 'code' }"
:use-tree="true"
:tree-transform="transformPermissionsToTree"
:tree-props="{ children: 'children', label: 'displayName', id: 'permissionID' }"
:only-leaf-selectable="true"
unit-name=""
search-placeholder="搜索权限名称或编码..."
@confirm="handlePermissionConfirm"
@cancel="resetBindList"
/>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { menuApi } from '@/apis/system/menu';
import { permissionApi } from '@/apis/system/permission';
import { SysMenu, SysPermission } from '@/types';
import { AdminLayout } from '@/views/admin';
import { PUBLIC_IMG_PATH } from '@/config';
import { GenericSelector } from '@/components/base';
defineOptions({
name: 'MenuManageView'
});
import { ref, onMounted, reactive, computed } from 'vue';
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus';
import { Plus } from '@element-plus/icons-vue';
// 数据状态
const menuList = ref<SysMenu[]>([]);
const loading = ref(false);
const submitting = ref(false);
const treeRef = ref();
// 权限绑定相关数据
const currentMenu = ref<SysMenu | null>(null);
// 对话框状态
const dialogVisible = ref(false);
const isEdit = ref(false);
const formRef = ref<FormInstance>();
const bindPermissionDialogVisible = ref(false);
// 表单数据
const formData = reactive<SysMenu>({
name: '',
description: '',
url: '',
component: '',
icon: '',
type: 0,
orderNum: 0,
parentID: undefined
});
// 表单验证规则
const formRules: FormRules = {
name: [
{ required: true, message: '请输入菜单名称', trigger: 'blur' },
{ min: 2, max: 50, message: '菜单名称长度在 2 到 50 个字符', trigger: 'blur' }
],
url: [
{ max: 200, message: '菜单路径不能超过 200 个字符', trigger: 'blur' }
],
component: [
{ max: 200, message: '菜单组件不能超过 200 个字符', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择菜单类型', trigger: 'change' }
]
};
// 级联选择器配置
const cascaderProps = {
value: 'value',
label: 'label',
children: 'children',
checkStrictly: true
};
// 树形组件配置
const treeProps = {
children: 'children',
label: 'name'
};
// 父菜单选项
const parentMenuOptions = computed(() => {
return buildParentMenuOptions(menuList.value);
});
// 构建父菜单选项
function buildParentMenuOptions(menus: SysMenu[]): any[] {
const result: any[] = [];
function processNode(menu: SysMenu): any {
const option = {
value: menu.menuID,
label: menu.name,
children: menu.children && menu.children.length > 0
? menu.children.map(processNode)
: undefined
};
return option;
}
menus.forEach(menu => {
result.push(processNode(menu));
});
return result;
}
// 获取菜单类型标签类型
function getMenuTypeTagType(type: number | undefined): string {
switch (type) {
case 0: return 'primary';
case 1: return 'success';
case 2: return 'warning';
default: return 'info';
}
}
// 获取菜单类型文本
function getMenuTypeText(type: number | undefined): string {
switch (type) {
case 0: return '侧边栏';
case 1: return '导航栏';
case 2: return '按钮';
default: return '未知';
}
}
// 加载菜单列表
async function loadMenuList() {
try {
loading.value = true;
const result = await menuApi.getAllMenuList();
const rawData = result.dataList || [];
console.log('原始菜单数据:', rawData);
// 将扁平数据转换为树形结构
menuList.value = buildTree(rawData);
console.log('转换后的树形数据:', menuList.value);
} catch (error) {
console.error('加载菜单列表失败:', error);
ElMessage.error('加载菜单列表失败');
} finally {
loading.value = false;
}
}
// 将扁平数据转换为树形结构
function buildTree(flatData: SysMenu[]): SysMenu[] {
if (!flatData || flatData.length === 0) {
return [];
}
const tree: SysMenu[] = [];
const map = new Map<string, SysMenu>();
const maxDepth = flatData.length; // 最多遍历len层
// 初始化所有节点
flatData.forEach(item => {
if (item.menuID) {
map.set(item.menuID, { ...item, children: [] });
}
});
// 循环构建树结构最多遍历maxDepth次
for (let depth = 0; depth < maxDepth; depth++) {
let hasChanges = false;
flatData.forEach(item => {
if (!item.menuID) return;
const node = map.get(item.menuID);
if (!node) return;
// 如果节点已经在树中,跳过
if (isNodeInTree(node, tree)) {
return;
}
if (!item.parentID || item.parentID === '0' || item.parentID === '') {
// 根节点
if (!isNodeInTree(node, tree)) {
tree.push(node);
hasChanges = true;
}
} else {
// 查找父节点
const parent = map.get(item.parentID);
if (parent && isNodeInTree(parent, tree)) {
if (!parent.children) {
parent.children = [];
}
if (!parent.children.includes(node)) {
parent.children.push(node);
hasChanges = true;
}
}
}
});
// 如果没有变化,说明树构建完成
if (!hasChanges) {
break;
}
}
// 清理空的children数组
function cleanEmptyChildren(nodes: SysMenu[]) {
nodes.forEach(node => {
if (node.children && node.children.length === 0) {
delete node.children;
} else if (node.children && node.children.length > 0) {
cleanEmptyChildren(node.children);
}
});
}
cleanEmptyChildren(tree);
return tree;
}
// 检查节点是否已经在树中
function isNodeInTree(node: SysMenu, tree: SysMenu[]): boolean {
for (const treeNode of tree) {
if (treeNode.menuID === node.menuID) {
return true;
}
if (treeNode.children && isNodeInTree(node, treeNode.children)) {
return true;
}
}
return false;
}
// 新增菜单
function handleAdd() {
isEdit.value = false;
formData.parentID = undefined;
dialogVisible.value = true;
}
// 新增子菜单
function handleAddChild(row: SysMenu) {
isEdit.value = false;
formData.parentID = row.menuID;
dialogVisible.value = true;
}
// 编辑菜单
function handleEdit(row: SysMenu) {
isEdit.value = true;
Object.assign(formData, row);
dialogVisible.value = true;
}
// 删除菜单
async function handleDelete(row: SysMenu) {
try {
await ElMessageBox.confirm(
`确定要删除菜单 "${row.name}" 吗?`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
);
await menuApi.deleteMenu(row.menuID!);
ElMessage.success('删除成功');
await loadMenuList();
} catch (error) {
if (error !== 'cancel') {
console.error('删除菜单失败:', error);
ElMessage.error('删除菜单失败');
}
}
}
// 提交表单
async function handleSubmit() {
if (!formRef.value) return;
try {
await formRef.value.validate();
submitting.value = true;
if (isEdit.value) {
await menuApi.updateMenu(formData);
ElMessage.success('更新成功');
} else {
await menuApi.createMenu(formData);
ElMessage.success('新增成功');
}
dialogVisible.value = false;
await loadMenuList();
} catch (error) {
if (error !== false) { // 表单验证失败时error为false
console.error('提交失败:', error);
ElMessage.error(isEdit.value ? '更新失败' : '新增失败');
}
} finally {
submitting.value = false;
}
}
// 重置表单
function resetForm() {
if (formRef.value) {
formRef.value.resetFields();
}
Object.assign(formData, {
name: '',
description: '',
url: '',
component: '',
icon: '',
type: 0,
orderNum: 0,
parentID: undefined
});
}
// 获取所有可选权限的接口
async function fetchAllPermissions() {
const permission: SysPermission = {
permissionID: undefined,
name: undefined,
code: undefined,
description: undefined,
};
return await permissionApi.getPermissionList(permission);
}
// 获取菜单已绑定权限的接口
async function fetchMenuPermissions() {
if (!currentMenu.value?.menuID) {
return {
success: true,
dataList: [],
code: 200,
message: '',
login: true,
auth: true
};
}
return await menuApi.getMenuPermission(currentMenu.value.menuID);
}
// 过滤已选项
function filterPermissions(available: any[], selected: any[]) {
const selectedIds = new Set(selected.map(item => item.permissionID));
return available.filter(item => !selectedIds.has(item.permissionID));
}
// 将权限按模块ID构建树形结构
function transformPermissionsToTree(flatData: any[]) {
if (!flatData || flatData.length === 0) {
return [];
}
// 按模块分组
const moduleMap = new Map<string, any>();
const tree: any[] = [];
flatData.forEach(item => {
const moduleID = item.moduleID;
if (!moduleID) {
// 没有模块ID的权限作为根节点
tree.push({
...item,
displayName: item.name,
isModule: false
});
return;
}
if (!moduleMap.has(moduleID)) {
// 创建模块节点
moduleMap.set(moduleID, {
permissionID: moduleID, // 使用moduleID作为ID确保唯一性
displayName: item.moduleName || moduleID,
moduleID: moduleID,
moduleName: item.moduleName,
moduleCode: item.moduleCode,
moduleDescription: item.moduleDescription,
children: [],
isModule: true // 标记这是模块节点
});
}
// 添加权限到模块的children中
const moduleNode = moduleMap.get(moduleID);
if (moduleNode) {
moduleNode.children.push({
...item,
displayName: item.name,
isModule: false // 标记这是权限节点
});
}
});
// 将所有模块添加到树中
moduleMap.forEach(moduleNode => {
tree.push(moduleNode);
});
return tree;
}
// 查看绑定权限
async function handleBindPermission(row: SysMenu) {
currentMenu.value = row;
bindPermissionDialogVisible.value = true;
}
// 重置绑定列表
function resetBindList() {
currentMenu.value = null;
}
// 权限选择确认 - 在confirm时提交请求
async function handlePermissionConfirm(items: SysPermission[]) {
if (!currentMenu.value || !currentMenu.value.menuID) {
ElMessage.error('菜单信息不完整');
return;
}
try {
submitting.value = true;
// 获取当前已绑定的权限
const currentBoundResult = await menuApi.getMenuPermission(currentMenu.value.menuID);
const currentBoundIds = (currentBoundResult.dataList || []).map(p => p.permissionID).filter((id): id is string => !!id);
// 新选择的权限ID过滤掉模块节点只保留权限节点
const newSelectedIds = items
.filter((item: any) => !item.isModule) // 只保留权限节点,过滤模块节点
.map(p => p.permissionID)
.filter((id): id is string => !!id);
// 找出需要绑定的权限(新增的)
const permissionsToBind = newSelectedIds.filter(id => !currentBoundIds.includes(id));
// 找出需要解绑的权限(移除的)
const permissionsToUnbind = currentBoundIds.filter(id => !newSelectedIds.includes(id));
// 构建需要绑定的权限对象数组
if (permissionsToBind.length > 0) {
const permissionsToBindObjects = items
.filter((item: any) => !item.isModule && item.permissionID && permissionsToBind.includes(item.permissionID));
const bindMenu = {
...currentMenu.value,
permissions: permissionsToBindObjects,
menus: [{menuID: currentMenu.value.menuID}]
};
await permissionApi.bindMenu(bindMenu);
}
// 构建需要解绑的权限对象数组
if (permissionsToUnbind.length > 0) {
const permissionsToUnbindObjects = (currentBoundResult.dataList || []).filter(p => p.permissionID && permissionsToUnbind.includes(p.permissionID));
const unbindMenu = {
...currentMenu.value,
permissions: permissionsToUnbindObjects,
menus: [{menuID: currentMenu.value.menuID}]
};
await permissionApi.unbindMenu(unbindMenu);
}
ElMessage.success('权限绑定保存成功');
bindPermissionDialogVisible.value = false;
resetBindList();
// 刷新菜单列表
await loadMenuList();
} catch (error) {
console.error('保存权限绑定失败:', error);
ElMessage.error('保存权限绑定失败');
} finally {
submitting.value = false;
}
}
// 页面加载时获取菜单列表
onMounted(() => {
loadMenuList();
});
// 节点拖拽逻辑
function allowDrop(draggingNode: any, dropNode: any) {
// 禁止拖拽到自己或子节点
return !isDescendant(draggingNode.data, dropNode.data);
}
// 判断是否是后代节点
function isDescendant(ancestor: SysMenu, node: SysMenu): boolean {
if (ancestor.menuID === node.menuID) return true;
if (node.children) {
return node.children.some(child => isDescendant(ancestor, child));
}
return false;
}
// 处理节点拖拽
async function handleNodeDrop(draggingNode: any, dropNode: any, dropType: string) {
try {
const dragData = draggingNode.data as SysMenu;
const dropData = dropNode.data as SysMenu;
// 更新被拖拽节点的父ID
if (dropType === 'inner') {
dragData.parentID = dropData.menuID;
} else {
dragData.parentID = dropData.parentID;
}
// 更新到后端
await menuApi.updateMenu(dragData);
ElMessage.success('菜单移动成功');
// 重新加载菜单列表以确保数据一致性
await loadMenuList();
} catch (error) {
console.error('移动菜单失败:', error);
ElMessage.error('移动菜单失败');
// 重新加载菜单列表以恢复原始状态
await loadMenuList();
}
}
</script>
<style scoped lang="scss">
.menu-manage {
background: #FFFFFF;
padding: 24px;
border-radius: 14px;
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
margin: 0;
color: #303133;
font-size: 20px;
font-weight: 600;
}
}
.tree-container {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
padding: 20px;
.menu-tree {
.custom-tree-node {
display: flex;
align-items: center;
width: 100%;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
.node-label {
display: flex;
align-items: center;
flex: 1;
min-width: 200px;
.node-icon {
margin-right: 8px;
width: 16px;
height: 16px;
object-fit: contain;
}
.node-name {
font-size: 16px;
font-weight: 500;
color: #303133;
margin-right: 12px;
}
.el-tag {
margin-left: 8px;
}
}
.node-info {
display: flex;
flex-wrap: wrap;
gap: 12px;
flex: 1;
margin: 0 20px;
.info-item {
font-size: 12px;
color: #909399;
background: #f4f4f5;
padding: 2px 6px;
border-radius: 4px;
white-space: nowrap;
}
}
.node-actions {
display: flex;
gap: 6px;
opacity: 0;
transition: opacity 0.3s;
.el-button {
padding: 4px 8px;
font-size: 12px;
}
}
&:hover {
background-color: #f5f7fa;
border-radius: 4px;
.node-actions {
opacity: 1;
}
}
}
}
// Element Plus Tree 组件样式覆盖
:deep(.el-tree-node__content) {
height: auto !important;
padding: 0 !important;
}
:deep(.el-tree-node__expand-icon) {
padding: 6px;
color: #409EFF;
}
:deep(.el-tree-node) {
white-space: normal;
}
:deep(.el-tree-node__children) {
padding-left: 24px !important;
}
}
.el-table {
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
}
// 对话框样式优化
:deep(.el-dialog) {
.el-dialog__header {
padding: 20px 20px 10px;
border-bottom: 1px solid #ebeef5;
.el-dialog__title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
}
.el-dialog__body {
padding: 20px;
}
.el-dialog__footer {
padding: 10px 20px 20px;
border-top: 1px solid #ebeef5;
}
}
// 表单样式优化
:deep(.el-form) {
.el-form-item__label {
font-weight: 500;
color: #606266;
}
.el-input__wrapper {
box-shadow: 0 0 0 1px #dcdfe6 inset;
&:hover {
box-shadow: 0 0 0 1px #c0c4cc inset;
}
&.is-focus {
box-shadow: 0 0 0 1px #409eff inset;
}
}
.el-textarea__inner {
box-shadow: 0 0 0 1px #dcdfe6 inset;
&:hover {
box-shadow: 0 0 0 1px #c0c4cc inset;
}
&:focus {
box-shadow: 0 0 0 1px #409eff inset;
}
}
}
// 按钮样式优化
:deep(.el-button) {
&.is-link {
padding: 0;
margin-right: 12px;
&:last-child {
margin-right: 0;
}
}
}
</style>