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

前端改进:
- 添加创建目录/子菜单的帮助说明
- 子菜单自动生成组件路径(如果用户未填写)
- 添加 autoCreateComponent 参数支持
- 优化菜单类型判断逻辑
2026-01-20 18:15:14 +08:00

412 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<ElDialog
:title="dialogTitle"
:model-value="visible"
@update:model-value="handleCancel"
width="860px"
align-center
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"
:items="formItems"
:rules="rules"
:span="width > 640 ? 12 : 24"
:gutter="20"
label-width="100px"
:show-reset="false"
:show-submit="false"
>
<template #menuType>
<ElRadioGroup v-model="form.menuType" :disabled="disableMenuType">
<ElRadioButton value="menu" label="menu">菜单</ElRadioButton>
<ElRadioButton value="button" label="button">按钮</ElRadioButton>
</ElRadioGroup>
</template>
</ArtForm>
<template #footer>
<span class="dialog-footer">
<ElButton @click="handleCancel"> </ElButton>
<ElButton type="primary" @click="handleSubmit"> </ElButton>
</span>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import type { FormRules } 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'
import type { FormItem } from '@/components/core/forms/art-form/index.vue'
import ArtForm from '@/components/core/forms/art-form/index.vue'
import { useWindowSize } from '@vueuse/core'
const { width } = useWindowSize()
/**
* 创建带 tooltip 的表单标签
* @param label 标签文本
* @param tooltip 提示文本
* @returns 渲染函数
*/
const createLabelTooltip = (label: string, tooltip: string) => {
return () =>
h('span', { class: 'flex items-center' }, [
h('span', label),
h(
ElTooltip,
{
content: tooltip,
placement: 'top'
},
() => h(ElIcon, { class: 'ml-0.5 cursor-help' }, () => h(QuestionFilled))
)
])
}
interface MenuFormData {
id: number
name: string
path: string
label: string
component: string
icon: string
isEnable: boolean
sort: number
isMenu: boolean
keepAlive: boolean
isHide: boolean
isHideTab: boolean
link: string
isIframe: boolean
showBadge: boolean
showTextBadge: string
fixedTab: boolean
activePath: string
roles: string[]
isFullPage: boolean
authName: string
authLabel: string
authIcon: string
authSort: number
}
interface Props {
visible: boolean
editData?: AppRouteRecord | any
type?: 'menu' | 'button'
lockType?: boolean
isDirectory?: boolean // 是否是创建目录模式
}
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'submit', data: MenuFormData): void
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
type: 'menu',
lockType: false,
isDirectory: false
})
const emit = defineEmits<Emits>()
const formRef = ref()
const isEdit = ref(false)
const form = reactive<MenuFormData & { menuType: 'menu' | 'button' }>({
menuType: 'menu',
id: 0,
name: '',
path: '',
label: '',
component: '',
icon: '',
isEnable: true,
sort: 1,
isMenu: true,
keepAlive: true,
isHide: false,
isHideTab: false,
link: '',
isIframe: false,
showBadge: false,
showTextBadge: '',
fixedTab: false,
activePath: '',
roles: [],
isFullPage: false,
authName: '',
authLabel: '',
authIcon: '',
authSort: 1
})
const rules = reactive<FormRules>({
name: [
{ required: true, message: '请输入菜单名称', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
],
path: [{ required: true, message: '请输入路由地址', trigger: 'blur' }],
label: [{ required: true, message: '输入权限标识', trigger: 'blur' }],
authName: [{ required: true, message: '请输入权限名称', trigger: 'blur' }],
authLabel: [{ required: true, message: '请输入权限标识', trigger: 'blur' }]
})
/**
* 表单项配置
*/
const formItems = computed<FormItem[]>(() => {
const baseItems: FormItem[] = [{ label: '菜单类型', key: 'menuType', span: 24 }]
// Switch 组件的 span小屏幕 12大屏幕 6
const switchSpan = width.value < 640 ? 12 : 6
if (form.menuType === 'menu') {
return [
...baseItems,
{ label: '菜单名称', key: 'name', type: 'input', props: { placeholder: '菜单名称' } },
{
label: createLabelTooltip(
'路由地址',
'一级菜单:以 / 开头的绝对路径(如 /dashboard\n二级及以下相对路径如 console、user'
),
key: 'path',
type: 'input',
props: { placeholder: '如:/dashboard 或 console' }
},
{ label: '权限标识', key: 'label', type: 'input', props: { placeholder: '如User' } },
{
label: createLabelTooltip(
'组件路径',
'一级父级菜单:填写 /index/index\n具体页面填写组件路径如 /system/user\n目录菜单留空'
),
key: 'component',
type: 'input',
props: { placeholder: '如:/system/user 或留空' }
},
{ label: '图标', key: 'icon', type: 'input', props: { placeholder: '如ri:user-line' } },
{
label: createLabelTooltip(
'角色权限',
'仅用于前端权限模式:配置角色标识(如 R_SUPER、R_ADMIN\n后端权限模式无需配置'
),
key: 'roles',
type: 'inputtag',
props: { placeholder: '输入角色标识后按回车R_SUPER' }
},
{
label: '菜单排序',
key: 'sort',
type: 'number',
props: { min: 1, controlsPosition: 'right', style: { width: '100%' } }
},
{
label: '外部链接',
key: 'link',
type: 'input',
props: { placeholder: '如https://www.example.com' }
},
{
label: '文本徽章',
key: 'showTextBadge',
type: 'input',
props: { placeholder: '如New、Hot' }
},
{
label: createLabelTooltip(
'激活路径',
'用于详情页等隐藏菜单,指定高亮显示的父级菜单路径\n例如用户详情页高亮显示"用户管理"菜单'
),
key: 'activePath',
type: 'input',
props: { placeholder: '如:/system/user' }
},
{ label: '是否启用', key: 'isEnable', type: 'switch', span: switchSpan },
{ label: '页面缓存', key: 'keepAlive', type: 'switch', span: switchSpan },
{ label: '隐藏菜单', key: 'isHide', type: 'switch', span: switchSpan },
{ label: '是否内嵌', key: 'isIframe', type: 'switch', span: switchSpan },
{ label: '显示徽章', key: 'showBadge', type: 'switch', span: switchSpan },
{ label: '固定标签', key: 'fixedTab', type: 'switch', span: switchSpan },
{ label: '标签隐藏', key: 'isHideTab', type: 'switch', span: switchSpan },
{ label: '全屏页面', key: 'isFullPage', type: 'switch', span: switchSpan }
]
} else {
return [
...baseItems,
{
label: '权限名称',
key: 'authName',
type: 'input',
props: { placeholder: '如:新增、编辑、删除' }
},
{
label: '权限标识',
key: 'authLabel',
type: 'input',
props: { placeholder: '如add、edit、delete' }
},
{
label: '权限排序',
key: 'authSort',
type: 'number',
props: { min: 1, controlsPosition: 'right', style: { width: '100%' } }
}
]
}
})
const dialogTitle = computed(() => {
const type = form.menuType === 'menu' ? '菜单' : '按钮'
return isEdit.value ? `编辑${type}` : `新建${type}`
})
/**
* 是否禁用菜单类型切换
*/
const disableMenuType = computed(() => {
if (isEdit.value) return true
if (!isEdit.value && form.menuType === 'menu' && props.lockType) return true
return false
})
/**
* 重置表单数据
*/
const resetForm = (): void => {
formRef.value?.reset()
form.menuType = 'menu'
}
/**
* 加载表单数据(编辑模式)
*/
const loadFormData = (): void => {
if (!props.editData) return
isEdit.value = true
if (form.menuType === 'menu') {
const row = props.editData
form.id = row.id || 0
form.name = formatMenuTitle(row.meta?.title || '')
form.path = row.path || ''
form.label = row.name || ''
form.component = row.component || ''
form.icon = row.meta?.icon || ''
form.sort = row.meta?.sort || 1
form.isMenu = row.meta?.isMenu ?? true
form.keepAlive = row.meta?.keepAlive ?? false
form.isHide = row.meta?.isHide ?? false
form.isHideTab = row.meta?.isHideTab ?? false
form.isEnable = row.meta?.isEnable ?? true
form.link = row.meta?.link || ''
form.isIframe = row.meta?.isIframe ?? false
form.showBadge = row.meta?.showBadge ?? false
form.showTextBadge = row.meta?.showTextBadge || ''
form.fixedTab = row.meta?.fixedTab ?? false
form.activePath = row.meta?.activePath || ''
form.roles = row.meta?.roles || []
form.isFullPage = row.meta?.isFullPage ?? false
} else {
const row = props.editData
form.authName = row.title || ''
form.authLabel = row.authMark || ''
form.authIcon = row.icon || ''
form.authSort = row.sort || 1
}
}
/**
* 提交表单
*/
const handleSubmit = async (): Promise<void> => {
if (!formRef.value) return
try {
await formRef.value.validate()
emit('submit', { ...form })
ElMessage.success(`${isEdit.value ? '编辑' : '新增'}成功`)
handleCancel()
} catch {
ElMessage.error('表单校验失败,请检查输入')
}
}
/**
* 取消操作
*/
const handleCancel = (): void => {
emit('update:visible', false)
}
/**
* 对话框关闭后的回调
*/
const handleClosed = (): void => {
resetForm()
isEdit.value = false
}
/**
* 监听对话框显示状态
*/
watch(
() => props.visible,
(newVal) => {
if (newVal) {
form.menuType = props.type
nextTick(() => {
if (props.editData) {
loadFormData()
}
})
}
}
)
/**
* 监听菜单类型变化
*/
watch(
() => props.type,
(newType) => {
if (props.visible) {
form.menuType = newType
}
}
)
</script>