596 lines
15 KiB
Vue
596 lines
15 KiB
Vue
<template>
|
||
<div class="container-box">
|
||
<div class="header-bar">
|
||
<h2>菜单管理</h2>
|
||
<div class="header-actions">
|
||
<el-button @click="expandAll">
|
||
<el-icon>
|
||
<FolderOpened />
|
||
</el-icon>
|
||
全部展开
|
||
</el-button>
|
||
<el-button @click="collapseAll">
|
||
<el-icon>
|
||
<Folder />
|
||
</el-icon>
|
||
全部折叠
|
||
</el-button>
|
||
<el-button type="primary" @click="handleAddMenu">
|
||
<el-icon>
|
||
<Plus />
|
||
</el-icon>
|
||
添加菜单
|
||
</el-button>
|
||
<el-button @click="refresh" :loading="loading">
|
||
<el-icon>
|
||
<Refresh />
|
||
</el-icon>
|
||
刷新
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<el-divider></el-divider>
|
||
|
||
<!-- 树形表格 -->
|
||
<el-table
|
||
ref="tableRef"
|
||
:data="menuTree"
|
||
style="width: 100%"
|
||
row-key="id"
|
||
border
|
||
v-loading="loading"
|
||
element-loading-text="正在加载..."
|
||
:tree-props="{
|
||
children: 'children',
|
||
hasChildren: 'hasChildren',
|
||
}"
|
||
@row-click="handleRowClick"
|
||
>
|
||
<el-table-column prop="title" label="菜单名称" width="200">
|
||
<template #default="scope">
|
||
<div class="menu-item">
|
||
<i
|
||
v-if="scope.row.icon"
|
||
:class="scope.row.icon"
|
||
class="menu-icon"
|
||
></i>
|
||
<span>{{ scope.row.title }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column
|
||
prop="id"
|
||
width="60"
|
||
label="ID"
|
||
align="center"
|
||
></el-table-column>
|
||
|
||
<el-table-column prop="path" label="路由地址"></el-table-column>
|
||
|
||
<el-table-column
|
||
prop="MenuType"
|
||
label="菜单类型"
|
||
width="120"
|
||
align="center"
|
||
>
|
||
<template #default="scope">
|
||
<el-tag :type="getMenuTypeTagType(scope.row.type)">
|
||
{{ getMenuTypeTitle(scope.row.type) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column
|
||
prop="sort"
|
||
label="排序"
|
||
width="80"
|
||
align="center"
|
||
></el-table-column>
|
||
|
||
<el-table-column prop="is_visible" label="是否显示" width="100" align="center">
|
||
<template #default="scope">
|
||
<el-tag :type="scope.row.is_visible === 1 ? 'success' : 'danger'">{{ scope.row.is_visible === 1 ? '显示' : '隐藏' }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column prop="is_platform" label="平台端" width="100" align="center">
|
||
<template #default="scope">
|
||
<el-tag :type="scope.row.is_platform === 1 ? 'success' : 'info'">
|
||
{{ scope.row.is_platform === 1 ? "是" : "否" }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||
<template #default="scope">
|
||
<el-switch
|
||
v-model="scope.row.status"
|
||
:active-value="1"
|
||
:inactive-value="0"
|
||
@change="handleStatusChange(scope.row)"
|
||
@click.stop
|
||
:disabled="!scope.row.id"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column label="操作" width="280" fixed="right" align="center">
|
||
<template #default="scope">
|
||
<el-button
|
||
size="small"
|
||
text
|
||
@click.stop="handleAddSubMenu(scope.row)"
|
||
:disabled="scope.row.type === 3"
|
||
>
|
||
<el-icon>
|
||
<CirclePlus />
|
||
</el-icon>
|
||
<span>子菜单</span>
|
||
</el-button>
|
||
|
||
<el-button size="small" text @click.stop="handleEditMenu(scope.row)">
|
||
<el-icon>
|
||
<Edit />
|
||
</el-icon>
|
||
<span>编辑</span>
|
||
</el-button>
|
||
|
||
<el-button
|
||
size="small"
|
||
text
|
||
type="danger"
|
||
@click.stop="handleDeleteMenu(scope.row)"
|
||
>
|
||
<el-icon>
|
||
<Delete />
|
||
</el-icon>
|
||
<span>删除</span>
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<!-- 引入编辑组件 -->
|
||
<MenuEdit
|
||
v-model:visible="dialogVisible"
|
||
:menu="dialogMenu"
|
||
:parent-menu-options="parentMenuOptions"
|
||
:dialog-type="dialogType"
|
||
:parent-title="dialogParentTitle"
|
||
@save="handleMenuSave"
|
||
@cancel="handleMenuCancel"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted, watch } from "vue";
|
||
import { ElMessage, ElMessageBox, ElForm } from "element-plus";
|
||
import {
|
||
Plus,
|
||
CirclePlus,
|
||
Edit,
|
||
Delete,
|
||
Refresh,
|
||
FolderOpened,
|
||
Folder,
|
||
} from "@element-plus/icons-vue";
|
||
import {
|
||
getAllMenus,
|
||
updateMenuStatus,
|
||
createMenu,
|
||
updateMenu,
|
||
deleteMenu,
|
||
} from "@/api/menu";
|
||
import MenuEdit from "./components/edit.vue";
|
||
|
||
// 定义菜单数据类型
|
||
interface Menu {
|
||
id: number;
|
||
pid: number;
|
||
title: string;
|
||
path: string;
|
||
component_path: string;
|
||
icon: string;
|
||
sort: number;
|
||
status: 0 | 1;
|
||
is_visible?: 0 | 1;
|
||
is_platform?: 0 | 1;
|
||
type: 1 | 2 | 3; // 1:目录 2:页面 3:接口
|
||
permission: string;
|
||
children?: Menu[];
|
||
hasChildren?: boolean;
|
||
}
|
||
|
||
// 菜单树形数据
|
||
const menuTree = ref<Menu[]>([]);
|
||
const loading = ref(false);
|
||
|
||
// 表格引用
|
||
const tableRef = ref<any>(null);
|
||
|
||
// 对话框相关变量
|
||
const dialogVisible = ref(false);
|
||
const dialogMenu = ref<Partial<Menu> | null>(null);
|
||
const dialogType = ref<"add" | "edit" | "addSub">("add");
|
||
const dialogParentTitle = ref("");
|
||
|
||
// 父级菜单选项
|
||
const parentMenuOptions = ref<Menu[]>([]);
|
||
|
||
let fetchMenusPromise: Promise<any> | null = null;
|
||
|
||
// 菜单树递归排序:按 sort 升序,sort 相同按 id 升序
|
||
function sortMenuTree(menus: Menu[]): Menu[] {
|
||
return menus
|
||
.map((item) => ({
|
||
...item,
|
||
children: item.children ? sortMenuTree(item.children) : [],
|
||
}))
|
||
.sort((a, b) => {
|
||
const sortA = Number(a.sort ?? 999999);
|
||
const sortB = Number(b.sort ?? 999999);
|
||
if (sortA === sortB) {
|
||
return Number(a.id ?? 0) - Number(b.id ?? 0);
|
||
}
|
||
return sortA - sortB;
|
||
});
|
||
}
|
||
|
||
const fetchMenus = async () => {
|
||
if (fetchMenusPromise) {
|
||
return fetchMenusPromise;
|
||
}
|
||
|
||
loading.value = true;
|
||
|
||
fetchMenusPromise = (async () => {
|
||
try {
|
||
const result = await getAllMenus();
|
||
if (result.code === 200) {
|
||
const sortedMenus = sortMenuTree(result.data || []);
|
||
menuTree.value = sortedMenus;
|
||
parentMenuOptions.value = [
|
||
{
|
||
id: 0,
|
||
pid: 0,
|
||
title: "顶级菜单",
|
||
children: [],
|
||
} as Menu,
|
||
...sortedMenus,
|
||
];
|
||
} else {
|
||
ElMessage.error("获取菜单失败: " + result.message);
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error("获取菜单数据失败: " + (error as Error).message);
|
||
} finally {
|
||
loading.value = false;
|
||
fetchMenusPromise = null;
|
||
}
|
||
})();
|
||
|
||
return fetchMenusPromise;
|
||
};
|
||
|
||
// 刷新界面
|
||
async function refresh() {
|
||
loading.value = true;
|
||
try {
|
||
await fetchMenus();
|
||
ElMessage.success("刷新成功");
|
||
} catch (error) {
|
||
ElMessage.error("刷新失败");
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
// 获取所有菜单行数据(包括子节点)
|
||
function getAllMenuRows(menuList: Menu[]): Menu[] {
|
||
const rows: Menu[] = [];
|
||
menuList.forEach((menu) => {
|
||
rows.push(menu);
|
||
if (menu.children && menu.children.length > 0) {
|
||
rows.push(...getAllMenuRows(menu.children));
|
||
}
|
||
});
|
||
return rows;
|
||
}
|
||
|
||
// 全部展开
|
||
function expandAll() {
|
||
if (!tableRef.value) return;
|
||
const allRows = getAllMenuRows(menuTree.value);
|
||
allRows.forEach((row) => {
|
||
if (row.children && row.children.length > 0) {
|
||
tableRef.value.toggleRowExpansion(row, true);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 全部折叠
|
||
function collapseAll() {
|
||
if (!tableRef.value) return;
|
||
const allRows = getAllMenuRows(menuTree.value);
|
||
allRows.forEach((row) => {
|
||
if (row.children && row.children.length > 0) {
|
||
tableRef.value.toggleRowExpansion(row, false);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 处理行点击事件 - 展开/折叠树形结构
|
||
const handleRowClick = (row: Menu) => {
|
||
if (!tableRef.value) return;
|
||
|
||
// 检查该行是否有子节点
|
||
const hasChildren = row.children && row.children.length > 0;
|
||
|
||
if (hasChildren) {
|
||
// 切换展开/折叠状态(toggleRowExpansion 会自动切换当前状态)
|
||
tableRef.value.toggleRowExpansion(row);
|
||
}
|
||
};
|
||
|
||
// 构建菜单树(处理父子关系)
|
||
const buildMenuTree = (menuList: Menu[]): Menu[] => {
|
||
return menuList;
|
||
};
|
||
|
||
// 获取菜单类型名称
|
||
const getMenuTypeTitle = (type: number) => {
|
||
const typeMap = { 1: "目录", 2: "页面", 3: "接口" };
|
||
return typeMap[type as keyof typeof typeMap] || "未知类型";
|
||
};
|
||
|
||
// 获取菜单类型标签样式
|
||
const getMenuTypeTagType = (type: number) => {
|
||
const typeMap = { 1: "primary", 2: "success", 3: "info" };
|
||
return typeMap[type as keyof typeof typeMap] || "default";
|
||
};
|
||
|
||
// 处理状态变更
|
||
const handleStatusChange = async (menu: Menu) => {
|
||
try {
|
||
const result = await updateMenuStatus(menu.id, menu.status);
|
||
if (!result.success) {
|
||
ElMessage.error(result.message);
|
||
// 状态更新失败时回滚
|
||
menu.status = menu.status === 1 ? 0 : 1;
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error("更新状态失败: " + (error as Error).message);
|
||
menu.status = menu.status === 1 ? 0 : 1;
|
||
}
|
||
};
|
||
|
||
// 添加子菜单
|
||
const handleAddSubMenu = (parentMenu: Menu) => {
|
||
dialogType.value = "addSub";
|
||
dialogParentTitle.value = parentMenu.title;
|
||
dialogMenu.value = {
|
||
id: 0,
|
||
pid: parentMenu.id,
|
||
title: "",
|
||
path: "",
|
||
component_path: "",
|
||
icon: "",
|
||
sort: 0,
|
||
status: 1,
|
||
is_visible: 1,
|
||
is_platform: 1,
|
||
type: parentMenu.type === 2 ? 1 : parentMenu.type,
|
||
permission: "",
|
||
};
|
||
dialogVisible.value = true;
|
||
};
|
||
|
||
// 编辑菜单
|
||
const handleEditMenu = (menu: Menu) => {
|
||
dialogType.value = "edit";
|
||
dialogMenu.value = { ...menu };
|
||
dialogVisible.value = true;
|
||
};
|
||
|
||
// 删除菜单
|
||
const handleDeleteMenu = (menu: Menu) => {
|
||
ElMessageBox.confirm(
|
||
`确定要删除菜单 "${menu.title}" 吗?${menu.hasChildren ? "其下所有子菜单也将被删除。" : ""}`,
|
||
"确认删除",
|
||
{
|
||
confirmButtonText: "确定",
|
||
cancelButtonText: "取消",
|
||
type: "warning",
|
||
},
|
||
).then(async () => {
|
||
try {
|
||
const result = await deleteMenu(menu.id);
|
||
if (result.success) {
|
||
ElMessage.success("删除成功");
|
||
fetchMenus();
|
||
} else {
|
||
ElMessage.error("删除失败: " + result.message);
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error("删除失败: " + (error as Error).message);
|
||
}
|
||
});
|
||
};
|
||
|
||
// 添加菜单
|
||
const handleAddMenu = () => {
|
||
dialogType.value = "add";
|
||
dialogMenu.value = null;
|
||
dialogVisible.value = true;
|
||
};
|
||
|
||
// 处理菜单保存
|
||
const handleMenuSave = async (menu: Partial<Menu>) => {
|
||
try {
|
||
// 解决后端时间字段问题:过滤掉不需要的字段
|
||
const payload = { ...menu };
|
||
|
||
// 确保 pid 是整数类型(后端要求必须是整数)
|
||
// 处理数组情况:如果 pid 是数组(el-cascader 返回的是路径数组),取最后一个元素
|
||
let pidValue: any = payload.pid;
|
||
if (Array.isArray(pidValue)) {
|
||
pidValue = pidValue.length > 0 ? pidValue[pidValue.length - 1] : null;
|
||
}
|
||
|
||
// 强制转换为整数
|
||
if (pidValue === null || pidValue === undefined || pidValue === "") {
|
||
payload.pid = 0;
|
||
} else {
|
||
const parsedPid = parseInt(String(pidValue), 10);
|
||
if (isNaN(parsedPid)) {
|
||
payload.pid = 0;
|
||
} else {
|
||
payload.pid = parsedPid;
|
||
}
|
||
}
|
||
|
||
// 最终验证:确保 payload.pid 是数字类型,不是数组
|
||
if (Array.isArray(payload.pid)) {
|
||
payload.pid =
|
||
Array.isArray(payload.pid) && payload.pid.length > 0
|
||
? parseInt(String(payload.pid[payload.pid.length - 1]), 10) || 0
|
||
: 0;
|
||
}
|
||
|
||
// 确保是数字类型
|
||
if (typeof payload.pid !== "number") {
|
||
payload.pid = parseInt(String(payload.pid), 10) || 0;
|
||
}
|
||
|
||
if (menu.id === 0) {
|
||
// 新增菜单
|
||
const result = await createMenu(payload as Menu);
|
||
if (result.code === 200) {
|
||
// 修改这里,检查 code 而不是 success
|
||
ElMessage.success(result.msg || "菜单添加成功");
|
||
dialogVisible.value = false;
|
||
await fetchMenus();
|
||
} else {
|
||
ElMessage.error(result.msg || "添加失败");
|
||
}
|
||
} else {
|
||
// 编辑菜单
|
||
const result = await updateMenu(menu.id!, payload as Menu);
|
||
if (result.code === 200) {
|
||
// 修改这里,检查 code 而不是 success
|
||
ElMessage.success(result.msg || "更新成功");
|
||
dialogVisible.value = false;
|
||
await fetchMenus();
|
||
} else {
|
||
ElMessage.error(result.msg || "更新失败");
|
||
}
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error("操作失败: " + (error as Error).message);
|
||
}
|
||
};
|
||
|
||
// 处理菜单取消
|
||
const handleMenuCancel = () => {
|
||
dialogVisible.value = false;
|
||
};
|
||
|
||
// 组件挂载时加载菜单
|
||
onMounted(() => {
|
||
fetchMenus();
|
||
});
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
.header-bar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 16px 20px;
|
||
}
|
||
|
||
.header-bar h2 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid #f2f3f5;
|
||
}
|
||
|
||
.card-header span {
|
||
font-size: 18px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* 表格核心样式 */
|
||
:deep(.el-table) {
|
||
border-radius: 0;
|
||
width: 100% !important;
|
||
}
|
||
|
||
:deep(.el-table__body td) {
|
||
padding: 12px 0;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
/* 展开图标与菜单内容对齐 */
|
||
:deep(.el-table__expand-icon) {
|
||
margin: 0 !important;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
:deep(.el-table__expand-icon-cell) {
|
||
padding: 0 8px !important;
|
||
}
|
||
|
||
/* 菜单项样式 */
|
||
.menu-item {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
.menu-icon {
|
||
font-size: 16px;
|
||
width: 20px;
|
||
text-align: center;
|
||
}
|
||
|
||
/* 隐藏无子女菜单的展开图标 */
|
||
:deep(.el-table__expand-icon--hidden) {
|
||
visibility: hidden;
|
||
width: 24px;
|
||
}
|
||
|
||
:deep(.el-table__expand-icon) {
|
||
margin-right: 8px !important;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
/* 对话框样式精简 */
|
||
:deep(.el-dialog__body) {
|
||
padding: 20px;
|
||
max-height: 60vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
:deep(.el-form-item) {
|
||
margin-bottom: 16px;
|
||
}
|
||
</style>
|