- 添加基于 VbenAdmin + Vue3 + Element Plus 的前端管理系统 - 包含完整的 UI 组件库和工具链 - 支持多应用架构 (web-ele, backend-mock, playground) - 包含完整的开发规范和配置 - 修复 admin 目录的子模块问题,确保正确提交
662 lines
18 KiB
Vue
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> |