1. 优化滚动高度计算方式,改为容器总高减去表头和分页器高度,计算更准确 2. 新增nextTick等待DOM渲染完成后再进行尺寸测量 3. 调整默认最小高度为100px,优化变量命名和注释 4. 简化菜单页面的模板代码格式,统一代码风格 5. 更新useTableScroll的调用参数,移除旧的自定义paginationHeight传参
355 lines
9.7 KiB
Vue
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>
|