feat: 菜单管理功能增强 - 自动创建Vue组件文件

后端改进:
- MenuController 添加自动创建 Vue 组件文件功能
- 创建菜单时自动生成对应的 .vue 文件模板
- 修复路径处理逻辑,确保子菜单使用相对路径
- 添加菜单名称唯一性检查,自动添加时间戳避免重复
- 修复 ViewsPath 配置路径
- 修复文件写入编码为 UTF-8

前端改进:
- 添加创建目录/子菜单的帮助说明
- 子菜单自动生成组件路径(如果用户未填写)
- 添加 autoCreateComponent 参数支持
- 优化菜单类型判断逻辑
This commit is contained in:
lvfengfree 2026-01-20 18:15:14 +08:00
parent dcda5fa528
commit c53f658f91
5 changed files with 320 additions and 23 deletions

View File

@ -64,3 +64,56 @@ export function fetchGetMenuList() {
url: '/api/v3/system/menus/simple' url: '/api/v3/system/menus/simple'
}) })
} }
// 创建菜单
export function fetchCreateMenu(data: {
parentId?: number | null
name: string
path: string
component?: string
title: string
icon?: string
sort?: number
isHide?: boolean
keepAlive?: boolean
link?: string
isIframe?: boolean
roles?: string[]
autoCreateComponent?: boolean // 是否自动创建组件文件
}) {
return request.post({
url: '/api/menu',
params: data,
showSuccessMessage: true
})
}
// 更新菜单
export function fetchUpdateMenu(id: number, data: {
parentId?: number | null
name?: string
path?: string
component?: string
title?: string
icon?: string
sort?: number
isHide?: boolean
keepAlive?: boolean
link?: string
isIframe?: boolean
roles?: string[]
}) {
return request.put({
url: `/api/menu/${id}`,
params: data,
showSuccessMessage: true
})
}
// 删除菜单
export function fetchDeleteMenu(id: number) {
return request.del({
url: `/api/menu/${id}`,
showSuccessMessage: true
})
}

View File

@ -19,7 +19,7 @@
@refresh="handleRefresh" @refresh="handleRefresh"
> >
<template #left> <template #left>
<ElButton v-auth="'add'" @click="handleAddMenu" v-ripple> 添加菜单 </ElButton> <ElButton @click="handleAddDirectory" v-ripple> 添加目录 </ElButton>
<ElButton @click="toggleExpand" v-ripple> <ElButton @click="toggleExpand" v-ripple>
{{ isExpanded ? '收起' : '展开' }} {{ isExpanded ? '收起' : '展开' }}
</ElButton> </ElButton>
@ -43,6 +43,7 @@
:type="dialogType" :type="dialogType"
:editData="editData" :editData="editData"
:lockType="lockMenuType" :lockType="lockMenuType"
:isDirectory="currentParentId === null && !editData?.id"
@submit="handleSubmit" @submit="handleSubmit"
/> />
</ElCard> </ElCard>
@ -55,7 +56,7 @@
import { useTableColumns } from '@/hooks/core/useTableColumns' import { useTableColumns } from '@/hooks/core/useTableColumns'
import type { AppRouteRecord } from '@/types/router' import type { AppRouteRecord } from '@/types/router'
import MenuDialog from './modules/menu-dialog.vue' import MenuDialog from './modules/menu-dialog.vue'
import { fetchGetMenuList } from '@/api/system-manage' import { fetchGetMenuList, fetchCreateMenu, fetchUpdateMenu, fetchDeleteMenu } from '@/api/system-manage'
import { ElTag, ElMessageBox } from 'element-plus' import { ElTag, ElMessageBox } from 'element-plus'
defineOptions({ name: 'Menus' }) defineOptions({ name: 'Menus' })
@ -124,7 +125,8 @@
row: AppRouteRecord row: AppRouteRecord
): 'primary' | 'success' | 'warning' | 'info' | 'danger' => { ): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
if (row.meta?.isAuthButton) return 'danger' if (row.meta?.isAuthButton) return 'danger'
if (row.children?.length) return 'info' // component /index/index /
if (row.children?.length || (row.component === '/index/index' && row.path?.startsWith('/'))) return 'info'
if (row.meta?.link && row.meta?.isIframe) return 'success' if (row.meta?.link && row.meta?.isIframe) return 'success'
if (row.path) return 'primary' if (row.path) return 'primary'
if (row.meta?.link) return 'warning' if (row.meta?.link) return 'warning'
@ -138,7 +140,8 @@
*/ */
const getMenuTypeText = (row: AppRouteRecord): string => { const getMenuTypeText = (row: AppRouteRecord): string => {
if (row.meta?.isAuthButton) return '按钮' if (row.meta?.isAuthButton) return '按钮'
if (row.children?.length) return '目录' // component /index/index
if (row.children?.length || (row.component === '/index/index' && row.path?.startsWith('/'))) return '目录'
if (row.meta?.link && row.meta?.isIframe) return '内嵌' if (row.meta?.link && row.meta?.isIframe) return '内嵌'
if (row.path) return '菜单' if (row.path) return '菜单'
if (row.meta?.link) return '外链' if (row.meta?.link) return '外链'
@ -213,8 +216,8 @@
return h('div', buttonStyle, [ return h('div', buttonStyle, [
h(ArtButtonTable, { h(ArtButtonTable, {
type: 'add', type: 'add',
onClick: () => handleAddAuth(), onClick: () => handleAddChildMenu(row),
title: '新增权限' title: '新增菜单'
}), }),
h(ArtButtonTable, { h(ArtButtonTable, {
type: 'edit', type: 'edit',
@ -222,7 +225,7 @@
}), }),
h(ArtButtonTable, { h(ArtButtonTable, {
type: 'delete', type: 'delete',
onClick: () => handleDeleteMenu() onClick: () => handleDeleteMenu(row)
}) })
]) ])
} }
@ -351,12 +354,28 @@
return convertAuthListToChildren(searchedData) return convertAuthListToChildren(searchedData)
}) })
// ID
const currentParentId = ref<number | null>(null)
/** /**
* 添加菜单 * 添加目录顶级菜单parentId=null
*/ */
const handleAddMenu = (): void => { const handleAddDirectory = (): void => {
dialogType.value = 'menu' dialogType.value = 'menu'
editData.value = null editData.value = null
currentParentId.value = null //
lockMenuType.value = true
dialogVisible.value = true
}
/**
* 添加子菜单在目录下创建parentId=父级id
* @param parentRow 父级目录
*/
const handleAddChildMenu = (parentRow: AppRouteRecord): void => {
dialogType.value = 'menu'
editData.value = null // editData
currentParentId.value = parentRow.id || null // ID
lockMenuType.value = true lockMenuType.value = true
dialogVisible.value = true dialogVisible.value = true
} }
@ -406,6 +425,11 @@
icon?: string icon?: string
roles?: string[] roles?: string[]
sort?: number sort?: number
label?: string
isHide?: boolean
keepAlive?: boolean
link?: string
isIframe?: boolean
[key: string]: any [key: string]: any
} }
@ -413,27 +437,99 @@
* 提交表单数据 * 提交表单数据
* @param formData 表单数据 * @param formData 表单数据
*/ */
const handleSubmit = (formData: MenuFormData): void => { const handleSubmit = async (formData: MenuFormData): Promise<void> => {
console.log('提交数据:', formData) try {
// TODO: API // id
getMenuList() const isEdit = editData.value?.id
if (isEdit) {
//
await fetchUpdateMenu(editData.value.id, {
name: formData.label || formData.name,
path: formData.path,
component: formData.component,
title: formData.name,
icon: formData.icon,
sort: formData.sort,
isHide: formData.isHide,
keepAlive: formData.keepAlive,
link: formData.link,
isIframe: formData.isIframe,
roles: formData.roles
})
} else {
// /
// currentParentId.value=null
const isDirectory = currentParentId.value === null
// /
let menuPath = formData.path
if (!isDirectory && menuPath.startsWith('/')) {
// /
const pathParts = menuPath.split('/').filter(Boolean)
menuPath = pathParts[pathParts.length - 1] || menuPath.replace(/^\//, '')
}
// /
if (isDirectory && !menuPath.startsWith('/')) {
menuPath = '/' + menuPath
}
//
let componentPath = formData.component
if (!isDirectory && !componentPath) {
//
const parentMenu = tableData.value.find(m => m.id === currentParentId.value)
const parentPath = parentMenu?.path?.replace(/^\//, '') || 'pages'
componentPath = `/${parentPath}/${menuPath}`
console.log('自动生成组件路径:', componentPath)
}
const requestData = {
parentId: currentParentId.value, // null
name: formData.label || formData.name,
path: menuPath,
component: isDirectory ? '/index/index' : componentPath, // /index/index
title: formData.name,
icon: formData.icon,
sort: formData.sort || 0,
isHide: formData.isHide || false,
keepAlive: formData.keepAlive || false,
link: formData.link,
isIframe: formData.isIframe || false,
roles: formData.roles,
autoCreateComponent: !isDirectory //
}
console.log('创建菜单请求数据:', requestData, 'isDirectory:', isDirectory, 'currentParentId:', currentParentId.value)
await fetchCreateMenu(requestData)
}
dialogVisible.value = false
getMenuList()
} catch (error) {
console.error('保存失败:', error)
}
} }
/** /**
* 删除菜单 * 删除菜单
* @param row 菜单行数据
*/ */
const handleDeleteMenu = async (): Promise<void> => { const handleDeleteMenu = async (row: AppRouteRecord): Promise<void> => {
try { try {
await ElMessageBox.confirm('确定要删除该菜单吗?删除后无法恢复', '提示', { await ElMessageBox.confirm('确定要删除该菜单吗?删除后无法恢复', '提示', {
confirmButtonText: '确定', confirmButtonText: '确定',
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'warning' type: 'warning'
}) })
ElMessage.success('删除成功') if (row.id) {
getMenuList() await fetchDeleteMenu(row.id)
} catch (error) { getMenuList()
if (error !== 'cancel') { }
ElMessage.error('删除失败') } catch (error: any) {
if (error !== 'cancel' && error?.message !== 'cancel') {
// http
} }
} }
} }

View File

@ -8,6 +8,31 @@
class="menu-dialog" class="menu-dialog"
@closed="handleClosed" @closed="handleClosed"
> >
<!-- 填写说明 -->
<ElAlert
v-if="!isEdit && form.menuType === 'menu'"
:title="isDirectory ? '创建目录说明' : '创建子菜单说明'"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
>
<template #default>
<div v-if="isDirectory" style="line-height: 1.8">
<div><strong>菜单名称</strong>显示在侧边栏的名称"设备管理"</div>
<div><strong>路由地址</strong>必须以 / 开头 /device</div>
<div><strong>权限标识</strong>唯一标识 Device不能与现有的重复</div>
<div><strong>组件路径</strong>留空即可系统会自动设置</div>
</div>
<div v-else style="line-height: 1.8">
<div><strong>菜单名称</strong>显示在侧边栏的名称"设备列表"</div>
<div><strong>路由地址</strong>相对路径不要以 / 开头 list</div>
<div><strong>权限标识</strong>唯一标识 DeviceList不能与现有的重复</div>
<div><strong>组件路径</strong>对应 views 目录下的组件 /device/list</div>
</div>
</template>
</ElAlert>
<ArtForm <ArtForm
ref="formRef" ref="formRef"
v-model="form" v-model="form"
@ -38,7 +63,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FormRules } from 'element-plus' import type { FormRules } from 'element-plus'
import { ElIcon, ElTooltip } from 'element-plus' import { ElIcon, ElTooltip, ElAlert } from 'element-plus'
import { QuestionFilled } from '@element-plus/icons-vue' import { QuestionFilled } from '@element-plus/icons-vue'
import { formatMenuTitle } from '@/utils/router' import { formatMenuTitle } from '@/utils/router'
import type { AppRouteRecord } from '@/types/router' import type { AppRouteRecord } from '@/types/router'
@ -101,6 +126,7 @@
editData?: AppRouteRecord | any editData?: AppRouteRecord | any
type?: 'menu' | 'button' type?: 'menu' | 'button'
lockType?: boolean lockType?: boolean
isDirectory?: boolean //
} }
interface Emits { interface Emits {
@ -111,7 +137,8 @@
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
visible: false, visible: false,
type: 'menu', type: 'menu',
lockType: false lockType: false,
isDirectory: false
}) })
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()

View File

@ -57,11 +57,39 @@ public class MenuController : ControllerBase
[HttpPost("api/menu")] [HttpPost("api/menu")]
public async Task<ActionResult<ApiResponse<Menu>>> CreateMenu([FromBody] CreateMenuRequest request) public async Task<ActionResult<ApiResponse<Menu>>> CreateMenu([FromBody] CreateMenuRequest request)
{ {
Console.WriteLine($"[CreateMenu] 收到请求: ParentId={request.ParentId}, Name={request.Name}, Path={request.Path}, Component={request.Component}");
// 处理路径:子菜单(有 ParentId的路径不能以 / 开头
var menuPath = request.Path;
if (request.ParentId.HasValue && menuPath.StartsWith("/"))
{
// 子菜单:取最后一段路径作为相对路径
var pathParts = menuPath.Split('/', StringSplitOptions.RemoveEmptyEntries);
menuPath = pathParts.Length > 0 ? pathParts[^1] : menuPath.TrimStart('/');
Console.WriteLine($"[CreateMenu] 子菜单路径已修正: {request.Path} -> {menuPath}");
}
// 目录(无 ParentId的路径必须以 / 开头
if (!request.ParentId.HasValue && !menuPath.StartsWith("/"))
{
menuPath = "/" + menuPath;
Console.WriteLine($"[CreateMenu] 目录路径已修正: {request.Path} -> {menuPath}");
}
// 检查 Name 是否唯一,如果重复则自动生成唯一名称
var menuName = request.Name;
if (await _context.Menus.AnyAsync(m => m.Name == menuName))
{
// 生成唯一名称:原名称 + 时间戳
menuName = $"{request.Name}_{DateTime.Now:yyyyMMddHHmmss}";
Console.WriteLine($"[CreateMenu] 菜单名称已修正(避免重复): {request.Name} -> {menuName}");
}
var menu = new Menu var menu = new Menu
{ {
ParentId = request.ParentId, ParentId = request.ParentId,
Name = request.Name, Name = menuName,
Path = request.Path, Path = menuPath,
Component = request.Component, Component = request.Component,
Title = request.Title, Title = request.Title,
Icon = request.Icon, Icon = request.Icon,
@ -76,9 +104,98 @@ public class MenuController : ControllerBase
_context.Menus.Add(menu); _context.Menus.Add(menu);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
Console.WriteLine($"[CreateMenu] 菜单已创建: Id={menu.Id}, ParentId={menu.ParentId}");
// 如果有组件路径且不是目录组件,自动创建 Vue 组件文件
if (!string.IsNullOrEmpty(request.Component) &&
request.Component != "/index/index" &&
request.AutoCreateComponent)
{
try
{
await CreateVueComponentAsync(request.Component, request.Title ?? request.Name);
}
catch (Exception ex)
{
Console.WriteLine($"[CreateMenu] 创建组件文件失败: {ex.Message}");
// 不影响菜单创建,只是记录日志
}
}
return Ok(ApiResponse<Menu>.Success(menu, "菜单创建成功")); return Ok(ApiResponse<Menu>.Success(menu, "菜单创建成功"));
} }
/// <summary>
/// 创建 Vue 组件文件
/// </summary>
private async Task CreateVueComponentAsync(string componentPath, string title)
{
var configuration = HttpContext.RequestServices.GetRequiredService<IConfiguration>();
var viewsPath = configuration["Frontend:ViewsPath"] ?? "../adminSystem/src/views";
// 组件路径格式: /test/page -> test/page.vue
// 统一使用正斜杠,然后转换为系统路径分隔符
var relativePath = componentPath.TrimStart('/').Replace('\\', '/');
// 构建完整路径,确保路径分隔符正确
var fullPath = Path.GetFullPath(Path.Combine(viewsPath, $"{relativePath}.vue"));
var directory = Path.GetDirectoryName(fullPath);
Console.WriteLine($"[CreateVueComponent] viewsPath: {viewsPath}");
Console.WriteLine($"[CreateVueComponent] relativePath: {relativePath}");
Console.WriteLine($"[CreateVueComponent] fullPath: {fullPath}");
Console.WriteLine($"[CreateVueComponent] directory: {directory}");
// 如果文件已存在,不覆盖
if (System.IO.File.Exists(fullPath))
{
Console.WriteLine($"[CreateVueComponent] 组件文件已存在: {fullPath}");
return;
}
// 创建目录
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Console.WriteLine($"[CreateVueComponent] 创建目录: {directory}");
Directory.CreateDirectory(directory);
}
// 生成组件名称 (PascalCase)
var componentName = string.Join("", relativePath.Split('/', '-', '_')
.Where(s => !string.IsNullOrEmpty(s))
.Select(s => char.ToUpper(s[0]) + s.Substring(1)));
// 用于 CSS 类名的路径(使用连字符)
var cssClassName = relativePath.Replace('/', '-').Replace('_', '-');
// 生成 Vue 组件模板
var template = $@"<template>
<div class=""{cssClassName}-page"">
<ElCard>
<template #header>
<span>{title}</span>
</template>
<p> {title} </p>
<p>{componentPath}</p>
</ElCard>
</div>
</template>
<script setup lang=""ts"">
defineOptions({{ name: '{componentName}' }})
</script>
<style scoped>
.{cssClassName}-page {{
padding: 20px;
}}
</style>
";
await System.IO.File.WriteAllTextAsync(fullPath, template, System.Text.Encoding.UTF8);
Console.WriteLine($"[CreateVueComponent] 组件文件已创建: {fullPath}");
}
/// <summary> /// <summary>
/// 更新菜单 /// 更新菜单
/// </summary> /// </summary>
@ -221,6 +338,7 @@ public class CreateMenuRequest
public string? Link { get; set; } public string? Link { get; set; }
public bool IsIframe { get; set; } public bool IsIframe { get; set; }
public List<string>? Roles { get; set; } public List<string>? Roles { get; set; }
public bool AutoCreateComponent { get; set; } = true; // 是否自动创建组件文件
} }
/// <summary> /// <summary>

View File

@ -28,5 +28,8 @@
"Audience": "AmtScannerClient", "Audience": "AmtScannerClient",
"AccessTokenExpirationMinutes": 60, "AccessTokenExpirationMinutes": 60,
"RefreshTokenExpirationDays": 7 "RefreshTokenExpirationDays": 7
},
"Frontend": {
"ViewsPath": "../../adminSystem/src/views"
} }
} }