feat: 添加完整的前端管理系统 (VbenAdmin)
- 添加基于 VbenAdmin + Vue3 + Element Plus 的前端管理系统 - 包含完整的 UI 组件库和工具链 - 支持多应用架构 (web-ele, backend-mock, playground) - 包含完整的开发规范和配置 - 修复 admin 目录的子模块问题,确保正确提交
This commit is contained in:
662
admin/apps/web-ele/src/views/common/rbac/menu/index.vue
Normal file
662
admin/apps/web-ele/src/views/common/rbac/menu/index.vue
Normal file
@@ -0,0 +1,662 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user