menu-dialog.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. <template>
  2. <ElDialog
  3. :title="dialogTitle"
  4. :model-value="visible"
  5. @update:model-value="handleCancel"
  6. width="860px"
  7. align-center
  8. class="menu-dialog"
  9. @closed="handleClosed"
  10. >
  11. <ArtForm
  12. ref="formRef"
  13. v-model="form"
  14. :items="formItems"
  15. :rules="rules"
  16. :span="width > 640 ? 12 : 24"
  17. :gutter="20"
  18. label-width="100px"
  19. :show-reset="false"
  20. :show-submit="false"
  21. >
  22. <template #menuType>
  23. <ElRadioGroup v-model="form.menuType" :disabled="disableMenuType">
  24. <ElRadioButton value="menu" label="menu">菜单</ElRadioButton>
  25. <ElRadioButton value="button" label="button">按钮</ElRadioButton>
  26. </ElRadioGroup>
  27. </template>
  28. </ArtForm>
  29. <template #footer>
  30. <div class="dialog-footer">
  31. <ElButton class="btn-ghost" @click="handleCancel">取消</ElButton>
  32. <ElButton class="btn-main" type="primary" @click="handleSubmit">确定</ElButton>
  33. </div>
  34. </template>
  35. </ElDialog>
  36. </template>
  37. <script setup lang="ts">
  38. import type { FormRules } from 'element-plus'
  39. import { ElIcon, ElTooltip } from 'element-plus'
  40. import { QuestionFilled } from '@element-plus/icons-vue'
  41. import { formatMenuTitle } from '@/utils/router'
  42. import type { AppRouteRecord } from '@/types/router'
  43. import type { FormItem } from '@/components/core/forms/art-form/index.vue'
  44. import ArtForm from '@/components/core/forms/art-form/index.vue'
  45. import { useWindowSize } from '@vueuse/core'
  46. const { width } = useWindowSize()
  47. /**
  48. * 创建带 tooltip 的表单标签
  49. * @param label 标签文本
  50. * @param tooltip 提示文本
  51. * @returns 渲染函数
  52. */
  53. const createLabelTooltip = (label: string, tooltip: string) => {
  54. return () =>
  55. h('span', { class: 'flex items-center' }, [
  56. h('span', label),
  57. h(
  58. ElTooltip,
  59. {
  60. content: tooltip,
  61. placement: 'top'
  62. },
  63. () => h(ElIcon, { class: 'ml-0.5 cursor-help' }, () => h(QuestionFilled))
  64. )
  65. ])
  66. }
  67. interface MenuFormData {
  68. id: number
  69. name: string
  70. path: string
  71. label: string
  72. component: string
  73. icon: string
  74. isEnable: boolean
  75. sort: number
  76. isMenu: boolean
  77. keepAlive: boolean
  78. isHide: boolean
  79. isHideTab: boolean
  80. link: string
  81. isIframe: boolean
  82. showBadge: boolean
  83. showTextBadge: string
  84. fixedTab: boolean
  85. activePath: string
  86. roles: string[]
  87. isFullPage: boolean
  88. authName: string
  89. authLabel: string
  90. authIcon: string
  91. authSort: number
  92. }
  93. interface Props {
  94. visible: boolean
  95. editData?: AppRouteRecord | any
  96. type?: 'menu' | 'button'
  97. lockType?: boolean
  98. }
  99. interface Emits {
  100. (e: 'update:visible', value: boolean): void
  101. (e: 'submit', data: MenuFormData): void
  102. }
  103. const props = withDefaults(defineProps<Props>(), {
  104. visible: false,
  105. type: 'menu',
  106. lockType: false
  107. })
  108. const emit = defineEmits<Emits>()
  109. const formRef = ref()
  110. const isEdit = ref(false)
  111. const form = reactive<MenuFormData & { menuType: 'menu' | 'button' }>({
  112. menuType: 'menu',
  113. id: 0,
  114. name: '',
  115. path: '',
  116. label: '',
  117. component: '',
  118. icon: '',
  119. isEnable: true,
  120. sort: 1,
  121. isMenu: true,
  122. keepAlive: true,
  123. isHide: false,
  124. isHideTab: false,
  125. link: '',
  126. isIframe: false,
  127. showBadge: false,
  128. showTextBadge: '',
  129. fixedTab: false,
  130. activePath: '',
  131. roles: [],
  132. isFullPage: false,
  133. authName: '',
  134. authLabel: '',
  135. authIcon: '',
  136. authSort: 1
  137. })
  138. const rules = reactive<FormRules>({
  139. name: [
  140. { required: true, message: '请输入菜单名称', trigger: 'blur' },
  141. { min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
  142. ],
  143. path: [{ required: true, message: '请输入路由地址', trigger: 'blur' }],
  144. label: [{ required: true, message: '输入权限标识', trigger: 'blur' }],
  145. authName: [{ required: true, message: '请输入权限名称', trigger: 'blur' }],
  146. authLabel: [{ required: true, message: '请输入权限标识', trigger: 'blur' }]
  147. })
  148. /**
  149. * 表单项配置
  150. */
  151. const formItems = computed<FormItem[]>(() => {
  152. const baseItems: FormItem[] = [{ label: '菜单类型', key: 'menuType', span: 24 }]
  153. // Switch 组件的 span:小屏幕 12,大屏幕 6
  154. const switchSpan = width.value < 640 ? 12 : 6
  155. if (form.menuType === 'menu') {
  156. return [
  157. ...baseItems,
  158. { label: '菜单名称', key: 'name', type: 'input', props: { placeholder: '菜单名称' } },
  159. {
  160. label: createLabelTooltip(
  161. '路由地址',
  162. '一级菜单:以 / 开头的绝对路径(如 /dashboard)\n二级及以下:相对路径(如 console、user)'
  163. ),
  164. key: 'path',
  165. type: 'input',
  166. props: { placeholder: '如:/dashboard 或 console' }
  167. },
  168. { label: '权限标识', key: 'label', type: 'input', props: { placeholder: '如:User' } },
  169. {
  170. label: createLabelTooltip(
  171. '组件路径',
  172. '一级父级菜单:填写 /index/index\n具体页面:填写组件路径(如 /system/user)\n目录菜单:留空'
  173. ),
  174. key: 'component',
  175. type: 'input',
  176. props: { placeholder: '如:/system/user 或留空' }
  177. },
  178. { label: '图标', key: 'icon', type: 'input', props: { placeholder: '如:ri:user-line' } },
  179. {
  180. label: createLabelTooltip(
  181. '角色权限',
  182. '仅用于前端权限模式:配置角色标识(如 R_SUPER、R_ADMIN)\n后端权限模式:无需配置'
  183. ),
  184. key: 'roles',
  185. type: 'inputtag',
  186. props: { placeholder: '输入角色标识后按回车,如:R_SUPER' }
  187. },
  188. {
  189. label: '菜单排序',
  190. key: 'sort',
  191. type: 'number',
  192. props: { min: 1, controlsPosition: 'right', style: { width: '100%' } }
  193. },
  194. {
  195. label: '外部链接',
  196. key: 'link',
  197. type: 'input',
  198. props: { placeholder: '如:https://www.example.com' }
  199. },
  200. {
  201. label: '文本徽章',
  202. key: 'showTextBadge',
  203. type: 'input',
  204. props: { placeholder: '如:New、Hot' }
  205. },
  206. {
  207. label: createLabelTooltip(
  208. '激活路径',
  209. '用于详情页等隐藏菜单,指定高亮显示的父级菜单路径\n例如:用户详情页高亮显示"用户管理"菜单'
  210. ),
  211. key: 'activePath',
  212. type: 'input',
  213. props: { placeholder: '如:/system/user' }
  214. },
  215. { label: '是否启用', key: 'isEnable', type: 'switch', span: switchSpan },
  216. { label: '页面缓存', key: 'keepAlive', type: 'switch', span: switchSpan },
  217. { label: '隐藏菜单', key: 'isHide', type: 'switch', span: switchSpan },
  218. { label: '是否内嵌', key: 'isIframe', type: 'switch', span: switchSpan },
  219. { label: '显示徽章', key: 'showBadge', type: 'switch', span: switchSpan },
  220. { label: '固定标签', key: 'fixedTab', type: 'switch', span: switchSpan },
  221. { label: '标签隐藏', key: 'isHideTab', type: 'switch', span: switchSpan },
  222. { label: '全屏页面', key: 'isFullPage', type: 'switch', span: switchSpan }
  223. ]
  224. } else {
  225. return [
  226. ...baseItems,
  227. {
  228. label: '权限名称',
  229. key: 'authName',
  230. type: 'input',
  231. props: { placeholder: '如:新增、编辑、删除' }
  232. },
  233. {
  234. label: '权限标识',
  235. key: 'authLabel',
  236. type: 'input',
  237. props: { placeholder: '如:add、edit、delete' }
  238. },
  239. {
  240. label: '权限排序',
  241. key: 'authSort',
  242. type: 'number',
  243. props: { min: 1, controlsPosition: 'right', style: { width: '100%' } }
  244. }
  245. ]
  246. }
  247. })
  248. const dialogTitle = computed(() => {
  249. const type = form.menuType === 'menu' ? '菜单' : '按钮'
  250. return isEdit.value ? `编辑${type}` : `新建${type}`
  251. })
  252. /**
  253. * 是否禁用菜单类型切换
  254. */
  255. const disableMenuType = computed(() => {
  256. if (isEdit.value) return true
  257. if (!isEdit.value && form.menuType === 'menu' && props.lockType) return true
  258. return false
  259. })
  260. /**
  261. * 重置表单数据
  262. */
  263. const resetForm = (): void => {
  264. formRef.value?.reset()
  265. form.menuType = 'menu'
  266. }
  267. /**
  268. * 加载表单数据(编辑模式)
  269. */
  270. const loadFormData = (): void => {
  271. if (!props.editData) return
  272. isEdit.value = true
  273. if (form.menuType === 'menu') {
  274. const row = props.editData
  275. form.id = row.id || 0
  276. form.name = formatMenuTitle(row.meta?.title || '')
  277. form.path = row.path || ''
  278. form.label = row.name || ''
  279. form.component = row.component || ''
  280. form.icon = row.meta?.icon || ''
  281. form.sort = row.meta?.sort || 1
  282. form.isMenu = row.meta?.isMenu ?? true
  283. form.keepAlive = row.meta?.keepAlive ?? false
  284. form.isHide = row.meta?.isHide ?? false
  285. form.isHideTab = row.meta?.isHideTab ?? false
  286. form.isEnable = row.meta?.isEnable ?? true
  287. form.link = row.meta?.link || ''
  288. form.isIframe = row.meta?.isIframe ?? false
  289. form.showBadge = row.meta?.showBadge ?? false
  290. form.showTextBadge = row.meta?.showTextBadge || ''
  291. form.fixedTab = row.meta?.fixedTab ?? false
  292. form.activePath = row.meta?.activePath || ''
  293. form.roles = row.meta?.roles || []
  294. form.isFullPage = row.meta?.isFullPage ?? false
  295. } else {
  296. const row = props.editData
  297. form.authName = row.title || ''
  298. form.authLabel = row.authMark || ''
  299. form.authIcon = row.icon || ''
  300. form.authSort = row.sort || 1
  301. }
  302. }
  303. /**
  304. * 提交表单
  305. */
  306. const handleSubmit = async (): Promise<void> => {
  307. if (!formRef.value) return
  308. try {
  309. await formRef.value.validate()
  310. emit('submit', { ...form })
  311. ElMessage.success(`${isEdit.value ? '编辑' : '新增'}成功`)
  312. handleCancel()
  313. } catch {
  314. ElMessage.error('表单校验失败,请检查输入')
  315. }
  316. }
  317. /**
  318. * 取消操作
  319. */
  320. const handleCancel = (): void => {
  321. emit('update:visible', false)
  322. }
  323. /**
  324. * 对话框关闭后的回调
  325. */
  326. const handleClosed = (): void => {
  327. resetForm()
  328. isEdit.value = false
  329. }
  330. /**
  331. * 监听对话框显示状态
  332. */
  333. watch(
  334. () => props.visible,
  335. (newVal) => {
  336. if (newVal) {
  337. form.menuType = props.type
  338. nextTick(() => {
  339. if (props.editData) {
  340. loadFormData()
  341. }
  342. })
  343. }
  344. }
  345. )
  346. /**
  347. * 监听菜单类型变化
  348. */
  349. watch(
  350. () => props.type,
  351. (newType) => {
  352. if (props.visible) {
  353. form.menuType = newType
  354. }
  355. }
  356. )
  357. </script>
  358. <style scoped lang="scss">
  359. .dialog-footer {
  360. display: flex;
  361. justify-content: flex-end;
  362. gap: 8px;
  363. }
  364. </style>