Files
smart_grid_iot/client/src/views/system/menu/index.vue
T
andyesfly 026ec34471 refactor(useTableScroll): 重构表格滚动高度计算逻辑,适配表头高度扣除
1.  优化滚动高度计算方式,改为容器总高减去表头和分页器高度,计算更准确
2.  新增nextTick等待DOM渲染完成后再进行尺寸测量
3.  调整默认最小高度为100px,优化变量命名和注释
4.  简化菜单页面的模板代码格式,统一代码风格
5.  更新useTableScroll的调用参数,移除旧的自定义paginationHeight传参
2026-08-21 19:59:22 +08:00

355 lines
9.7 KiB
Vue

<template>
<div class="menu-management">
<a-card title="菜单管理">
<template #extra>
<a-button type="primary" @click="handleAdd()">
<template #icon>
<PlusOutlined />
</template>
新增菜单
</a-button>
</template>
<div class="table-wrapper" ref="wrapperRef">
<a-table :columns="columns" :data-source="menuList" :loading="loading" :pagination="false" row-key="id"
:scroll="{ x: 'max-content', y: tableHeight }" :expandable="{
defaultExpandAllRows: false,
showExpandColumn: true,
}">
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'menuType'">
<a-tag :color="record.menuType === 1 ? 'blue' : 'green'">
{{ record.menuType === 1 ? '目录' : '菜单' }}
</a-tag>
</template>
<template v-else-if="column.dataIndex === 'visible'">
<a-tag :color="record.visible === 1 ? 'success' : 'default'">
{{ record.visible === 1 ? '显示' : '隐藏' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleAdd(record)">
<PlusOutlined />新增子菜单
</a-button>
<a-button type="link" size="small" @click="handleEdit(record)">
<EditOutlined />编辑
</a-button>
<a-popconfirm title="确定删除该菜单?" ok-text="确定" cancel-text="取消" @confirm="handleDelete(record)">
<a-button type="link" size="small" danger>
<DeleteOutlined />删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
</a-card>
<a-modal v-model:open="modalVisible" :title="modalTitle" :confirm-loading="modalLoading" width="640px"
@ok="handleSubmit" @cancel="handleCancel">
<a-form ref="formRef" :model="formData" :rules="formRules" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="上级菜单" name="parentId">
<a-tree-select v-model:value="formData.parentId" :tree-data="parentOptions"
:field-names="{ label: 'menuName', value: 'id', children: 'children' }" allow-clear tree-default-expand-all
placeholder="请选择上级菜单" />
</a-form-item>
<a-form-item label="菜单类型" name="menuType">
<a-radio-group v-model:value="formData.menuType">
<a-radio :value="1">目录</a-radio>
<a-radio :value="2">菜单</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="菜单名称" name="menuName">
<a-input v-model:value="formData.menuName" placeholder="请输入菜单名称" />
</a-form-item>
<a-form-item label="路由路径" name="menuPath">
<a-input v-model:value="formData.menuPath" placeholder="如 /system/user" />
</a-form-item>
<a-form-item label="组件" name="component">
<a-input v-model:value="formData.component" placeholder="如 system/user/index" />
</a-form-item>
<a-form-item label="图标" name="icon">
<a-input v-model:value="formData.icon" placeholder="如 UserOutlined" />
</a-form-item>
<a-form-item label="排序" name="sortOrder">
<a-input-number v-model:value="formData.sortOrder" :min="0" :max="999" style="width: 100%" />
</a-form-item>
<a-form-item label="权限标识" name="permission">
<a-input v-model:value="formData.permission" placeholder="如 user:list" />
</a-form-item>
<a-form-item label="可见性" name="visible">
<a-switch :checked="formData.visible === 1" checked-children="显示" un-checked-children="隐藏"
@change="(checked: boolean) => (formData.visible = checked ? 1 : 0)" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue';
import { menuApi } from '@/api/menu';
import { useTableScroll } from '@/composables/useTableScroll';
const { wrapperRef, tableHeight } = useTableScroll();
interface MenuItem {
id: number;
parentId?: number | null;
menuName: string;
menuPath?: string;
component?: string;
icon?: string;
menuType: number;
sortOrder: number;
permission?: string;
visible: number;
children?: MenuItem[];
[key: string]: any;
}
const loading = ref(false);
const menuList = ref<MenuItem[]>([]);
const columns = [
{ title: '菜单名称', dataIndex: 'menuName', key: 'menuName', width: 120, ellipsis: true },
{ title: '路由路径', dataIndex: 'menuPath', key: 'menuPath', width: 160 },
{ title: '组件', dataIndex: 'component', key: 'component', width: 180 },
{ title: '图标', dataIndex: 'icon', key: 'icon', width: 120 },
{ title: '类型', dataIndex: 'menuType', key: 'menuType', width: 80, align: 'center' as const },
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder', width: 80, align: 'center' as const },
{ title: '权限标识', dataIndex: 'permission', key: 'permission', width: 150 },
{ title: '可见性', dataIndex: 'visible', key: 'visible', width: 90, align: 'center' as const },
{ title: '操作', key: 'action', width: 240, fixed: 'right' as const },
];
// 上级菜单树(编辑时排除自身及后代节点,防止循环引用)
const parentOptions = computed(() => {
const excludeId = isEdit.value ? editId.value : null;
const filtered = excludeId ? filterTree(menuList.value, excludeId) : menuList.value;
return [{ id: 0, menuName: '根目录', children: filtered }];
});
function filterTree(nodes: MenuItem[], excludeId: number): MenuItem[] {
const result: MenuItem[] = [];
for (const node of nodes) {
if (node.id === excludeId) continue;
const children = node.children?.length ? filterTree(node.children, excludeId) : undefined;
if (children) {
result.push({ ...node, children });
} else {
result.push(node);
}
}
return result;
}
const modalVisible = ref(false);
const modalLoading = ref(false);
const modalTitle = ref('');
const formRef = ref();
const isEdit = ref(false);
const editId = ref<number | null>(null);
const formData = reactive({
parentId: 0,
menuType: 2,
menuName: '',
menuPath: '',
component: '',
icon: '',
sortOrder: 0,
permission: '',
visible: 1,
});
const formRules = {
menuName: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
};
onMounted(() => {
fetchMenuTree();
});
async function fetchMenuTree() {
loading.value = true;
try {
const res: any = await menuApi.getMenuTree();
if (res.code === 200) {
menuList.value = res.data || [];
}
} catch (error) {
message.error('获取菜单列表失败');
} finally {
loading.value = false;
}
}
function handleAdd(parent?: MenuItem) {
isEdit.value = false;
editId.value = null;
modalTitle.value = '新增菜单';
resetForm();
if (parent) {
formData.parentId = parent.id;
formData.menuType = 2;
}
modalVisible.value = true;
}
function handleEdit(record: MenuItem) {
isEdit.value = true;
editId.value = record.id;
modalTitle.value = '编辑菜单';
formData.parentId = record.parentId || 0;
formData.menuType = record.menuType;
formData.menuName = record.menuName;
formData.menuPath = record.menuPath || '';
formData.component = record.component || '';
formData.icon = record.icon || '';
formData.sortOrder = record.sortOrder ?? 0;
formData.permission = record.permission || '';
formData.visible = record.visible ? 1 : 0;
modalVisible.value = true;
}
function resetForm() {
formData.parentId = 0;
formData.menuType = 2;
formData.menuName = '';
formData.menuPath = '';
formData.component = '';
formData.icon = '';
formData.sortOrder = 0;
formData.permission = '';
formData.visible = 1;
formRef.value?.resetFields();
}
async function handleSubmit() {
try {
await formRef.value?.validate();
} catch {
return;
}
modalLoading.value = true;
try {
const payload: any = {
parentId: formData.parentId || null,
menuType: formData.menuType,
menuName: formData.menuName,
menuPath: formData.menuPath,
component: formData.component,
icon: formData.icon,
sortOrder: formData.sortOrder,
permission: formData.permission,
visible: formData.visible,
};
let res: any;
if (isEdit.value && editId.value) {
res = await menuApi.updateMenu(editId.value, payload);
} else {
res = await menuApi.createMenu(payload);
}
if (res.code === 200) {
message.success(isEdit.value ? '更新成功' : '创建成功');
modalVisible.value = false;
fetchMenuTree();
} else {
message.error(res.message || '操作失败');
}
} catch (error) {
message.error('操作失败');
} finally {
modalLoading.value = false;
}
}
function handleCancel() {
modalVisible.value = false;
resetForm();
}
async function handleDelete(record: MenuItem) {
try {
const res: any = await menuApi.deleteMenu(record.id);
if (res.code === 200) {
message.success('删除成功');
fetchMenuTree();
} else {
message.error(res.message || '删除失败');
}
} catch (error) {
message.error('删除失败');
}
}
</script>
<style scoped lang="less">
.menu-management {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 20px;
box-sizing: border-box;
:deep(.ant-card) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
:deep(.ant-card-body) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
:deep(.ant-btn-link) {
padding: 0 4px;
}
:deep(.ant-table) {
height: 100%;
}
:deep(.ant-table-row-expand-icon) {
display: inline-flex;
align-items: center;
justify-content: center;
}
:deep(.ant-table-cell) {
white-space: nowrap;
}
}
</style>