feat: 菜单管理功能增强 - 自动创建Vue组件文件
后端改进: - MenuController 添加自动创建 Vue 组件文件功能 - 创建菜单时自动生成对应的 .vue 文件模板 - 修复路径处理逻辑,确保子菜单使用相对路径 - 添加菜单名称唯一性检查,自动添加时间戳避免重复 - 修复 ViewsPath 配置路径 - 修复文件写入编码为 UTF-8 前端改进: - 添加创建目录/子菜单的帮助说明 - 子菜单自动生成组件路径(如果用户未填写) - 添加 autoCreateComponent 参数支持 - 优化菜单类型判断逻辑
This commit is contained in:
parent
dcda5fa528
commit
c53f658f91
@ -64,3 +64,56 @@ export function fetchGetMenuList() {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #left>
|
||||
<ElButton v-auth="'add'" @click="handleAddMenu" v-ripple> 添加菜单 </ElButton>
|
||||
<ElButton @click="handleAddDirectory" v-ripple> 添加目录 </ElButton>
|
||||
<ElButton @click="toggleExpand" v-ripple>
|
||||
{{ isExpanded ? '收起' : '展开' }}
|
||||
</ElButton>
|
||||
@ -43,6 +43,7 @@
|
||||
:type="dialogType"
|
||||
:editData="editData"
|
||||
:lockType="lockMenuType"
|
||||
:isDirectory="currentParentId === null && !editData?.id"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</ElCard>
|
||||
@ -55,7 +56,7 @@
|
||||
import { useTableColumns } from '@/hooks/core/useTableColumns'
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
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'
|
||||
|
||||
defineOptions({ name: 'Menus' })
|
||||
@ -124,7 +125,8 @@
|
||||
row: AppRouteRecord
|
||||
): 'primary' | 'success' | 'warning' | 'info' | '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.path) return 'primary'
|
||||
if (row.meta?.link) return 'warning'
|
||||
@ -138,7 +140,8 @@
|
||||
*/
|
||||
const getMenuTypeText = (row: AppRouteRecord): string => {
|
||||
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.path) return '菜单'
|
||||
if (row.meta?.link) return '外链'
|
||||
@ -213,8 +216,8 @@
|
||||
return h('div', buttonStyle, [
|
||||
h(ArtButtonTable, {
|
||||
type: 'add',
|
||||
onClick: () => handleAddAuth(),
|
||||
title: '新增权限'
|
||||
onClick: () => handleAddChildMenu(row),
|
||||
title: '新增菜单'
|
||||
}),
|
||||
h(ArtButtonTable, {
|
||||
type: 'edit',
|
||||
@ -222,7 +225,7 @@
|
||||
}),
|
||||
h(ArtButtonTable, {
|
||||
type: 'delete',
|
||||
onClick: () => handleDeleteMenu()
|
||||
onClick: () => handleDeleteMenu(row)
|
||||
})
|
||||
])
|
||||
}
|
||||
@ -351,12 +354,28 @@
|
||||
return convertAuthListToChildren(searchedData)
|
||||
})
|
||||
|
||||
// 当前正在创建子菜单的父级ID
|
||||
const currentParentId = ref<number | null>(null)
|
||||
|
||||
/**
|
||||
* 添加菜单
|
||||
* 添加目录(顶级菜单,parentId=null)
|
||||
*/
|
||||
const handleAddMenu = (): void => {
|
||||
const handleAddDirectory = (): void => {
|
||||
dialogType.value = 'menu'
|
||||
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
|
||||
dialogVisible.value = true
|
||||
}
|
||||
@ -406,6 +425,11 @@
|
||||
icon?: string
|
||||
roles?: string[]
|
||||
sort?: number
|
||||
label?: string
|
||||
isHide?: boolean
|
||||
keepAlive?: boolean
|
||||
link?: string
|
||||
isIframe?: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
@ -413,27 +437,99 @@
|
||||
* 提交表单数据
|
||||
* @param formData 表单数据
|
||||
*/
|
||||
const handleSubmit = (formData: MenuFormData): void => {
|
||||
console.log('提交数据:', formData)
|
||||
// TODO: 调用API保存数据
|
||||
getMenuList()
|
||||
const handleSubmit = async (formData: MenuFormData): Promise<void> => {
|
||||
try {
|
||||
// 判断是否是编辑模式(有id)
|
||||
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 {
|
||||
await ElMessageBox.confirm('确定要删除该菜单吗?删除后无法恢复', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
ElMessage.success('删除成功')
|
||||
getMenuList()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
if (row.id) {
|
||||
await fetchDeleteMenu(row.id)
|
||||
getMenuList()
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel' && error?.message !== 'cancel') {
|
||||
// 错误已由 http 工具处理
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,31 @@
|
||||
class="menu-dialog"
|
||||
@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
|
||||
ref="formRef"
|
||||
v-model="form"
|
||||
@ -38,7 +63,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { formatMenuTitle } from '@/utils/router'
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
@ -101,6 +126,7 @@
|
||||
editData?: AppRouteRecord | any
|
||||
type?: 'menu' | 'button'
|
||||
lockType?: boolean
|
||||
isDirectory?: boolean // 是否是创建目录模式
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@ -111,7 +137,8 @@
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visible: false,
|
||||
type: 'menu',
|
||||
lockType: false
|
||||
lockType: false,
|
||||
isDirectory: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
@ -57,11 +57,39 @@ public class MenuController : ControllerBase
|
||||
[HttpPost("api/menu")]
|
||||
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
|
||||
{
|
||||
ParentId = request.ParentId,
|
||||
Name = request.Name,
|
||||
Path = request.Path,
|
||||
Name = menuName,
|
||||
Path = menuPath,
|
||||
Component = request.Component,
|
||||
Title = request.Title,
|
||||
Icon = request.Icon,
|
||||
@ -75,9 +103,98 @@ public class MenuController : ControllerBase
|
||||
|
||||
_context.Menus.Add(menu);
|
||||
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, "菜单创建成功"));
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// 更新菜单
|
||||
@ -221,6 +338,7 @@ public class CreateMenuRequest
|
||||
public string? Link { get; set; }
|
||||
public bool IsIframe { get; set; }
|
||||
public List<string>? Roles { get; set; }
|
||||
public bool AutoCreateComponent { get; set; } = true; // 是否自动创建组件文件
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -28,5 +28,8 @@
|
||||
"Audience": "AmtScannerClient",
|
||||
"AccessTokenExpirationMinutes": 60,
|
||||
"RefreshTokenExpirationDays": 7
|
||||
},
|
||||
"Frontend": {
|
||||
"ViewsPath": "../../adminSystem/src/views"
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user