Files
wwjcloud/admin/apps/web-ele/src/views/common/rbac/menu/index.vue
万物街 dc6e9baec0 feat: 添加完整的前端管理系统 (VbenAdmin)
- 添加基于 VbenAdmin + Vue3 + Element Plus 的前端管理系统
- 包含完整的 UI 组件库和工具链
- 支持多应用架构 (web-ele, backend-mock, playground)
- 包含完整的开发规范和配置
- 修复 admin 目录的子模块问题,确保正确提交
2025-08-23 13:24:04 +08:00

662 lines
18 KiB
Vue

<template>
<Page>
<div class="p-4">
<!-- 搜索表单 -->
<el-card class="mb-4">
<el-form :model="searchForm" inline>
<el-form-item label="菜单名称">
<el-input
v-model="searchForm.keyword"
placeholder="请输入菜单名称"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="显示" :value="1" />
<el-option label="隐藏" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="菜单类型">
<el-select v-model="searchForm.type" placeholder="请选择类型" clearable>
<el-option label="目录" value="catalog" />
<el-option label="菜单" value="menu" />
<el-option label="按钮" value="button" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<Icon icon="ep:search" class="mr-1" />
搜索
</el-button>
<el-button @click="handleReset">
<Icon icon="ep:refresh" class="mr-1" />
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作按钮 -->
<el-card class="mb-4">
<div class="action-buttons">
<el-button type="primary" @click="handleAdd">
<Icon icon="ep:plus" class="mr-1" />
新增菜单
</el-button>
<el-button type="success" @click="handleExpandAll">
<Icon icon="ep:d-arrow-right" class="mr-1" />
展开全部
</el-button>
<el-button type="info" @click="handleCollapseAll">
<Icon icon="ep:d-arrow-left" class="mr-1" />
收起全部
</el-button>
</div>
<!-- 数据表格 -->
<el-table
v-loading="loading"
:data="tableData"
row-key="menuId"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
:default-expand-all="false"
ref="tableRef"
>
<el-table-column prop="title" label="菜单名称" min-width="200">
<template #default="{ row }">
<div class="menu-title">
<Icon :icon="row.icon || 'ep:folder'" class="mr-2" />
<span>{{ row.title }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="80">
<template #default="{ row }">
<el-tag :type="getTypeTagType(row.type)">
{{ getTypeText(row.type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="path" label="路由路径" min-width="150" show-overflow-tooltip />
<el-table-column prop="component" label="组件路径" min-width="150" show-overflow-tooltip />
<el-table-column prop="permission" label="权限标识" min-width="150" show-overflow-tooltip />
<el-table-column prop="sort" label="排序" width="80" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '显示' : '隐藏' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180">
<template #default="{ row }">
{{ formatTime(row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="success" size="small" @click="handleAddChild(row)">
新增子菜单
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="800px"
@close="handleDialogClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="上级菜单" prop="parentId">
<el-tree-select
v-model="formData.parentId"
:data="menuTreeData"
:props="treeSelectProps"
placeholder="请选择上级菜单"
check-strictly
:render-after-expand="false"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="菜单类型" prop="type">
<el-radio-group v-model="formData.type" @change="handleTypeChange">
<el-radio label="catalog">目录</el-radio>
<el-radio label="menu">菜单</el-radio>
<el-radio label="button">按钮</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="菜单名称" prop="title">
<el-input v-model="formData.title" placeholder="请输入菜单名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="菜单图标" prop="icon">
<el-input v-model="formData.icon" placeholder="请输入图标名称">
<template #prepend>
<Icon :icon="formData.icon || 'ep:folder'" />
</template>
</el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20" v-if="formData.type !== 'button'">
<el-col :span="12">
<el-form-item label="路由路径" prop="path">
<el-input v-model="formData.path" placeholder="请输入路由路径" />
</el-form-item>
</el-col>
<el-col :span="12" v-if="formData.type === 'menu'">
<el-form-item label="组件路径" prop="component">
<el-input v-model="formData.component" placeholder="请输入组件路径" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="权限标识" prop="permission">
<el-input v-model="formData.permission" placeholder="请输入权限标识" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20" v-if="formData.type !== 'button'">
<el-col :span="12">
<el-form-item label="是否隐藏" prop="hidden">
<el-radio-group v-model="formData.hidden">
<el-radio :label="0">显示</el-radio>
<el-radio :label="1">隐藏</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="是否缓存" prop="keepAlive">
<el-radio-group v-model="formData.keepAlive">
<el-radio :label="1">缓存</el-radio>
<el-radio :label="0">不缓存</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
确定
</el-button>
</template>
</el-dialog>
</div>
</Page>
</template>
<script lang="ts" setup>
// 1. Vue 相关导入
import { ref, reactive, onMounted, computed, type FormInstance } from 'vue';
// 2. Element Plus 组件导入
import {
ElButton,
ElCard,
ElCol,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElMessage,
ElMessageBox,
ElOption,
ElRadio,
ElRadioGroup,
ElRow,
ElSelect,
ElTable,
ElTableColumn,
ElTag,
ElTreeSelect,
type ElTable,
} from 'element-plus';
// 3. 图标组件导入
import { Icon } from '@iconify/vue';
// 4. Vben 组件导入
import { Page } from '@vben/common-ui';
// 5. 项目内部导入
import {
getMenuListApi,
getMenuTreeApi,
createMenuApi,
updateMenuApi,
deleteMenuApi,
type Menu,
type CreateMenuParams,
type UpdateMenuParams,
type MenuTreeNode,
} from '#/api/common/rbac';
// 响应式数据
const loading = ref(false);
const submitLoading = ref(false);
const tableData = ref<Menu[]>([]);
const menuTreeData = ref<MenuTreeNode[]>([]);
const currentParent = ref<Menu | null>(null);
// 搜索表单
const searchForm = reactive({
keyword: '',
status: undefined as number | undefined,
type: undefined as string | undefined,
});
// 对话框
const dialogVisible = ref(false);
const isEdit = ref(false);
const isAddChild = ref(false);
const formRef = ref<FormInstance>();
const tableRef = ref<InstanceType<typeof ElTable>>();
// 表单数据
const formData = reactive<CreateMenuParams & { menuId?: number }>({
parentId: 0,
title: '',
type: 'menu',
path: '',
component: '',
icon: '',
permission: '',
sort: 0,
hidden: 0,
keepAlive: 1,
status: 1,
remark: '',
});
// 树形选择器配置
const treeSelectProps = {
value: 'menuId',
label: 'title',
children: 'children',
};
// 表单验证规则
const formRules = {
title: [
{ required: true, message: '请输入菜单名称', trigger: 'blur' },
{ min: 2, max: 50, message: '菜单名称长度在 2 到 50 个字符', trigger: 'blur' },
],
type: [
{ required: true, message: '请选择菜单类型', trigger: 'change' },
],
path: [
{
validator: (rule: any, value: string, callback: any) => {
if (formData.type !== 'button' && !value) {
callback(new Error('请输入路由路径'));
} else {
callback();
}
},
trigger: 'blur',
},
],
component: [
{
validator: (rule: any, value: string, callback: any) => {
if (formData.type === 'menu' && !value) {
callback(new Error('请输入组件路径'));
} else {
callback();
}
},
trigger: 'blur',
},
],
permission: [
{ required: true, message: '请输入权限标识', trigger: 'blur' },
],
};
// 计算属性
const dialogTitle = computed(() => {
if (isAddChild.value) {
return `新增子菜单 - ${currentParent.value?.title}`;
}
return isEdit.value ? '编辑菜单' : '新增菜单';
});
// 方法
const formatTime = (timestamp: number) => {
if (!timestamp) return '-';
return new Date(timestamp * 1000).toLocaleString();
};
const getTypeText = (type: string) => {
const map = {
catalog: '目录',
menu: '菜单',
button: '按钮',
};
return map[type as keyof typeof map] || type;
};
const getTypeTagType = (type: string) => {
const map = {
catalog: 'warning',
menu: 'primary',
button: 'success',
};
return map[type as keyof typeof map] || 'info';
};
const loadData = async () => {
loading.value = true;
try {
const params = {
keyword: searchForm.keyword || undefined,
status: searchForm.status,
type: searchForm.type,
};
const result = await getMenuListApi(params);
tableData.value = result;
} catch (error) {
ElMessage.error('加载数据失败');
} finally {
loading.value = false;
}
};
const loadMenuTree = async () => {
try {
const result = await getMenuTreeApi();
// 添加根节点
menuTreeData.value = [
{
menuId: 0,
title: '根目录',
children: result,
},
];
} catch (error) {
ElMessage.error('加载菜单树失败');
}
};
const handleSearch = () => {
loadData();
};
const handleReset = () => {
searchForm.keyword = '';
searchForm.status = undefined;
searchForm.type = undefined;
handleSearch();
};
const handleExpandAll = () => {
const table = tableRef.value;
if (table) {
const expandAll = (data: Menu[]) => {
data.forEach(row => {
table.toggleRowExpansion(row, true);
if (row.children) {
expandAll(row.children);
}
});
};
expandAll(tableData.value);
}
};
const handleCollapseAll = () => {
const table = tableRef.value;
if (table) {
const collapseAll = (data: Menu[]) => {
data.forEach(row => {
table.toggleRowExpansion(row, false);
if (row.children) {
collapseAll(row.children);
}
});
};
collapseAll(tableData.value);
}
};
const handleAdd = async () => {
isEdit.value = false;
isAddChild.value = false;
currentParent.value = null;
await loadMenuTree();
resetForm();
dialogVisible.value = true;
};
const handleAddChild = async (row: Menu) => {
isEdit.value = false;
isAddChild.value = true;
currentParent.value = row;
await loadMenuTree();
resetForm();
formData.parentId = row.menuId;
dialogVisible.value = true;
};
const handleEdit = async (row: Menu) => {
isEdit.value = true;
isAddChild.value = false;
currentParent.value = null;
await loadMenuTree();
Object.assign(formData, {
menuId: row.menuId,
parentId: row.parentId,
title: row.title,
type: row.type,
path: row.path,
component: row.component,
icon: row.icon,
permission: row.permission,
sort: row.sort,
hidden: row.hidden,
keepAlive: row.keepAlive,
status: row.status,
remark: row.remark,
});
dialogVisible.value = true;
};
const handleDelete = async (row: Menu) => {
try {
await ElMessageBox.confirm(
`确定要删除菜单 "${row.title}" 吗?删除后子菜单也会被删除!`,
'确认删除',
{
type: 'warning',
}
);
await deleteMenuApi(row.menuId);
ElMessage.success('删除成功');
loadData();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败');
}
}
};
const handleTypeChange = (type: string) => {
// 根据类型清空相关字段
if (type === 'button') {
formData.path = '';
formData.component = '';
formData.hidden = 0;
formData.keepAlive = 0;
} else if (type === 'catalog') {
formData.component = '';
}
};
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
submitLoading.value = true;
if (isEdit.value) {
const updateData: UpdateMenuParams = {
menuId: formData.menuId!,
parentId: formData.parentId,
title: formData.title,
type: formData.type,
path: formData.path,
component: formData.component,
icon: formData.icon,
permission: formData.permission,
sort: formData.sort,
hidden: formData.hidden,
keepAlive: formData.keepAlive,
status: formData.status,
remark: formData.remark,
};
await updateMenuApi(updateData);
ElMessage.success('更新成功');
} else {
const createData: CreateMenuParams = {
parentId: formData.parentId,
title: formData.title,
type: formData.type,
path: formData.path,
component: formData.component,
icon: formData.icon,
permission: formData.permission,
sort: formData.sort,
hidden: formData.hidden,
keepAlive: formData.keepAlive,
status: formData.status,
remark: formData.remark,
};
await createMenuApi(createData);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
loadData();
} catch (error) {
ElMessage.error(isEdit.value ? '更新失败' : '创建失败');
} finally {
submitLoading.value = false;
}
};
const handleDialogClose = () => {
formRef.value?.resetFields();
resetForm();
};
const resetForm = () => {
Object.assign(formData, {
menuId: undefined,
parentId: 0,
title: '',
type: 'menu',
path: '',
component: '',
icon: '',
permission: '',
sort: 0,
hidden: 0,
keepAlive: 1,
status: 1,
remark: '',
});
};
// 生命周期
onMounted(() => {
loadData();
});
</script>
<style scoped>
.menu-page {
padding: 20px;
}
.search-form {
background: #fff;
padding: 20px;
border-radius: 4px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.action-buttons {
background: #fff;
padding: 20px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.menu-title {
display: flex;
align-items: center;
}
</style>