platform-vue/src/views/system/menus/manager.vue
2026-04-01 00:03:39 +08:00

596 lines
15 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>
<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>