diff --git a/apps/backend-mock/api/system/menu/list.ts b/apps/backend-mock/api/system/menu/list.ts new file mode 100644 index 00000000..5328b2fd --- /dev/null +++ b/apps/backend-mock/api/system/menu/list.ts @@ -0,0 +1,12 @@ +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { MOCK_MENU_LIST } from '~/utils/mock-data'; +import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response'; + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + + return useResponseSuccess(MOCK_MENU_LIST); +}); diff --git a/apps/backend-mock/api/system/menu/name-exists.ts b/apps/backend-mock/api/system/menu/name-exists.ts new file mode 100644 index 00000000..5599c22b --- /dev/null +++ b/apps/backend-mock/api/system/menu/name-exists.ts @@ -0,0 +1,28 @@ +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { MOCK_MENU_LIST } from '~/utils/mock-data'; +import { unAuthorizedResponse } from '~/utils/response'; + +const namesMap: Record = {}; + +function getNames(menus: any[]) { + menus.forEach((menu) => { + namesMap[menu.name] = String(menu.id); + if (menu.children) { + getNames(menu.children); + } + }); +} +getNames(MOCK_MENU_LIST); + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + const { id, name } = getQuery(event); + + return (name as string) in namesMap && + (!id || namesMap[name as string] !== String(id)) + ? useResponseSuccess(true) + : useResponseSuccess(false); +}); diff --git a/apps/backend-mock/api/system/menu/path-exists.ts b/apps/backend-mock/api/system/menu/path-exists.ts new file mode 100644 index 00000000..64774f79 --- /dev/null +++ b/apps/backend-mock/api/system/menu/path-exists.ts @@ -0,0 +1,28 @@ +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { MOCK_MENU_LIST } from '~/utils/mock-data'; +import { unAuthorizedResponse } from '~/utils/response'; + +const pathMap: Record = { '/': 0 }; + +function getPaths(menus: any[]) { + menus.forEach((menu) => { + pathMap[menu.path] = String(menu.id); + if (menu.children) { + getPaths(menu.children); + } + }); +} +getPaths(MOCK_MENU_LIST); + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + const { id, path } = getQuery(event); + + return (path as string) in pathMap && + (!id || pathMap[path as string] !== String(id)) + ? useResponseSuccess(true) + : useResponseSuccess(false); +}); diff --git a/apps/backend-mock/utils/mock-data.ts b/apps/backend-mock/utils/mock-data.ts index 8fa12fe5..fd7b969b 100644 --- a/apps/backend-mock/utils/mock-data.ts +++ b/apps/backend-mock/utils/mock-data.ts @@ -185,3 +185,195 @@ export const MOCK_MENUS = [ username: 'jack', }, ]; + +export const MOCK_MENU_LIST = [ + { + id: 1, + name: 'Workspace', + status: 1, + type: 'menu', + icon: 'mdi:dashboard', + path: '/workspace', + component: '/dashboard/workspace/index', + meta: { + icon: 'carbon:workspace', + title: 'page.dashboard.workspace', + affixTab: true, + order: 0, + }, + }, + { + id: 2, + meta: { + icon: 'carbon:settings', + order: 9997, + title: 'system.title', + badge: 'new', + badgeType: 'normal', + badgeVariants: 'primary', + }, + status: 1, + type: 'catalog', + name: 'System', + path: '/system', + children: [ + { + id: 201, + pid: 2, + path: '/system/menu', + name: 'SystemMenu', + authCode: 'System:Menu:List', + status: 1, + type: 'menu', + meta: { + icon: 'carbon:menu', + title: 'system.menu.title', + }, + component: '/system/menu/list', + children: [ + { + id: 20_101, + pid: 201, + name: 'SystemMenuCreate', + status: 1, + type: 'button', + authCode: 'System:Menu:Create', + meta: { title: 'common.create' }, + }, + { + id: 20_102, + pid: 201, + name: 'SystemMenuEdit', + status: 1, + type: 'button', + authCode: 'System:Menu:Edit', + meta: { title: 'common.edit' }, + }, + { + id: 20_103, + pid: 201, + name: 'SystemMenuDelete', + status: 1, + type: 'button', + authCode: 'System:Menu:Delete', + meta: { title: 'common.delete' }, + }, + ], + }, + { + id: 202, + pid: 2, + path: '/system/dept', + name: 'SystemDept', + status: 1, + type: 'menu', + authCode: 'System:Dept:List', + meta: { + icon: 'carbon:container-services', + title: 'system.dept.title', + }, + component: '/system/dept/list', + children: [ + { + id: 20_401, + pid: 201, + name: 'SystemDeptCreate', + status: 1, + type: 'button', + authCode: 'System:Dept:Create', + meta: { title: 'common.create' }, + }, + { + id: 20_402, + pid: 201, + name: 'SystemDeptEdit', + status: 1, + type: 'button', + authCode: 'System:Dept:Edit', + meta: { title: 'common.edit' }, + }, + { + id: 20_403, + pid: 201, + name: 'SystemDeptDelete', + status: 1, + type: 'button', + authCode: 'System:Dept:Delete', + meta: { title: 'common.delete' }, + }, + ], + }, + ], + }, + { + id: 9, + meta: { + badgeType: 'dot', + order: 9998, + title: 'demos.vben.title', + icon: 'carbon:data-center', + }, + name: 'Project', + path: '/vben-admin', + type: 'catalog', + status: 1, + children: [ + { + id: 901, + pid: 9, + name: 'VbenDocument', + path: '/vben-admin/document', + component: 'IFrameView', + type: 'embedded', + status: 1, + meta: { + icon: 'carbon:book', + iframeSrc: 'https://doc.vben.pro', + title: 'demos.vben.document', + }, + }, + { + id: 902, + pid: 9, + name: 'VbenGithub', + path: '/vben-admin/github', + component: 'IFrameView', + type: 'link', + status: 1, + meta: { + icon: 'carbon:logo-github', + link: 'https://github.com/vbenjs/vue-vben-admin', + title: 'Github', + }, + }, + { + id: 903, + pid: 9, + name: 'VbenAntdv', + path: '/vben-admin/antdv', + component: 'IFrameView', + type: 'link', + status: 0, + meta: { + icon: 'carbon:hexagon-vertical-solid', + badgeType: 'dot', + link: 'https://ant.vben.pro', + title: 'demos.vben.antdv', + }, + }, + ], + }, + { + id: 10, + component: '_core/about/index', + type: 'menu', + status: 1, + meta: { + icon: 'lucide:copyright', + order: 9999, + title: 'demos.vben.about', + }, + name: 'About', + path: '/about', + }, +]; diff --git a/packages/@core/ui-kit/form-ui/src/form-api.ts b/packages/@core/ui-kit/form-ui/src/form-api.ts index c3d7b2bb..db09d7c7 100644 --- a/packages/@core/ui-kit/form-ui/src/form-api.ts +++ b/packages/@core/ui-kit/form-ui/src/form-api.ts @@ -93,9 +93,9 @@ export class FormApi { return this.state; } - async getValues() { + async getValues>() { const form = await this.getForm(); - return form.values ? this.handleRangeTimeValue(form.values) : {}; + return (form.values ? this.handleRangeTimeValue(form.values) : {}) as T; } async isFieldValid(fieldName: string) { diff --git a/packages/locales/src/langs/en-US/common.json b/packages/locales/src/langs/en-US/common.json index 304ee3e7..440af82b 100644 --- a/packages/locales/src/langs/en-US/common.json +++ b/packages/locales/src/langs/en-US/common.json @@ -16,5 +16,7 @@ "disabled": "Disabled", "edit": "Edit", "delete": "Delete", - "create": "Create" + "create": "Create", + "yes": "Yes", + "no": "No" } diff --git a/packages/locales/src/langs/en-US/ui.json b/packages/locales/src/langs/en-US/ui.json index fcf3cc12..5bfd5d07 100644 --- a/packages/locales/src/langs/en-US/ui.json +++ b/packages/locales/src/langs/en-US/ui.json @@ -4,7 +4,10 @@ "selectRequired": "Please select {0}", "minLength": "{0} must be at least {1} characters", "maxLength": "{0} can be at most {1} characters", - "length": "{0} must be {1} characters long" + "length": "{0} must be {1} characters long", + "alreadyExists": "{0} `{1}` already exists", + "startWith": "{0} must start with `{1}`", + "invalidURL": "Please input a valid URL" }, "actionTitle": { "edit": "Modify {0}", diff --git a/packages/locales/src/langs/zh-CN/common.json b/packages/locales/src/langs/zh-CN/common.json index 86591f73..95ec5f7d 100644 --- a/packages/locales/src/langs/zh-CN/common.json +++ b/packages/locales/src/langs/zh-CN/common.json @@ -16,5 +16,7 @@ "disabled": "已禁用", "edit": "修改", "delete": "删除", - "create": "新增" + "create": "新增", + "yes": "是", + "no": "否" } diff --git a/packages/locales/src/langs/zh-CN/ui.json b/packages/locales/src/langs/zh-CN/ui.json index 23cd2f20..c0679581 100644 --- a/packages/locales/src/langs/zh-CN/ui.json +++ b/packages/locales/src/langs/zh-CN/ui.json @@ -4,7 +4,10 @@ "selectRequired": "请选择{0}", "minLength": "{0}至少{1}个字符", "maxLength": "{0}最多{1}个字符", - "length": "{0}长度必须为{1}个字符" + "length": "{0}长度必须为{1}个字符", + "alreadyExists": "{0} `{1}` 已存在", + "startWith": "{0}必须以 {1} 开头", + "invalidURL": "请输入有效的链接" }, "actionTitle": { "edit": "修改{0}", diff --git a/playground/src/adapter/vxe-table.ts b/playground/src/adapter/vxe-table.ts index 1f329881..b4a1b3c8 100644 --- a/playground/src/adapter/vxe-table.ts +++ b/playground/src/adapter/vxe-table.ts @@ -5,7 +5,7 @@ import { h } from 'vue'; import { IconifyIcon } from '@vben/icons'; import { $te } from '@vben/locales'; import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table'; -import { isFunction, isString } from '@vben/utils'; +import { get, isFunction, isString } from '@vben/utils'; import { objectOmit } from '@vueuse/core'; import { Button, Image, Popconfirm, Tag } from 'ant-design-vue'; @@ -77,8 +77,8 @@ setupVbenVxeTable({ // 单元格渲染: Tag vxeUI.renderer.add('CellTag', { renderTableDefault({ options, props }, { column, row }) { - const value = row[column.field]; - const tagOptions = options || [ + const value = get(row, column.field); + const tagOptions = options ?? [ { color: 'success', label: $t('common.enabled'), value: 1 }, { color: 'error', label: $t('common.disabled'), value: 0 }, ]; @@ -87,7 +87,7 @@ setupVbenVxeTable({ Tag, { ...props, - ...objectOmit(tagItem, ['label']), + ...objectOmit(tagItem ?? {}, ['label']), }, { default: () => tagItem?.label ?? value }, ); diff --git a/playground/src/api/system/menu.ts b/playground/src/api/system/menu.ts new file mode 100644 index 00000000..507a5aec --- /dev/null +++ b/playground/src/api/system/menu.ts @@ -0,0 +1,158 @@ +import type { Recordable } from '@vben/types'; + +import { requestClient } from '#/api/request'; + +export namespace SystemMenuApi { + /** 徽标颜色集合 */ + export const BadgeVariants = [ + 'default', + 'destructive', + 'primary', + 'success', + 'warning', + ] as const; + /** 徽标类型集合 */ + export const BadgeTypes = ['dot', 'normal'] as const; + /** 菜单类型集合 */ + export const MenuTypes = [ + 'catalog', + 'menu', + 'embedded', + 'link', + 'button', + ] as const; + /** 系统菜单 */ + export interface SystemMenu { + [key: string]: any; + /** 后端权限标识 */ + authCode: string; + /** 子级 */ + children?: SystemMenu[]; + /** 组件 */ + component?: string; + /** 菜单ID */ + id: string; + /** 菜单元数据 */ + meta?: { + /** 激活时显示的图标 */ + activeIcon?: string; + /** 作为路由时,需要激活的菜单的Path */ + activePath?: string; + /** 固定在标签栏 */ + affixTab?: boolean; + /** 在标签栏固定的顺序 */ + affixTabOrder?: number; + /** 徽标内容(当徽标类型为normal时有效) */ + badge?: string; + /** 徽标类型 */ + badgeType?: (typeof BadgeTypes)[number]; + /** 徽标颜色 */ + badgeVariants?: (typeof BadgeVariants)[number]; + /** 在菜单中隐藏下级 */ + hideChildrenInMenu?: boolean; + /** 在面包屑中隐藏 */ + hideInBreadcrumb?: boolean; + /** 在菜单中隐藏 */ + hideInMenu?: boolean; + /** 在标签栏中隐藏 */ + hideInTab?: boolean; + /** 菜单图标 */ + icon?: string; + /** 内嵌Iframe的URL */ + iframeSrc?: string; + /** 是否缓存页面 */ + keepAlive?: boolean; + /** 外链页面的URL */ + link?: string; + /** 同一个路由最大打开的标签数 */ + maxNumOfOpenTab?: number; + /** 无需基础布局 */ + noBasicLayout?: boolean; + /** 是否在新窗口打开 */ + openInNewWindow?: boolean; + /** 菜单排序 */ + order?: number; + /** 额外的路由参数 */ + query?: Recordable; + /** 菜单标题 */ + title?: string; + }; + /** 菜单名称 */ + name: string; + /** 路由路径 */ + path: string; + /** 父级ID */ + pid: string; + /** 重定向 */ + redirect?: string; + /** 菜单类型 */ + type: (typeof MenuTypes)[number]; + } +} + +/** + * 获取菜单数据列表 + */ +async function getMenuList() { + return requestClient.get>( + '/system/menu/list', + ); +} + +async function isMenuNameExists( + name: string, + id?: SystemMenuApi.SystemMenu['id'], +) { + return requestClient.get('/system/menu/name-exists', { + params: { id, name }, + }); +} + +async function isMenuPathExists( + path: string, + id?: SystemMenuApi.SystemMenu['id'], +) { + return requestClient.get('/system/menu/path-exists', { + params: { id, path }, + }); +} + +/** + * 创建菜单 + * @param data 菜单数据 + */ +async function createMenu( + data: Omit, +) { + return requestClient.post('/system/menu', data); +} + +/** + * 更新菜单 + * + * @param id 菜单 ID + * @param data 菜单数据 + */ +async function updateMenu( + id: string, + data: Omit, +) { + return requestClient.put(`/system/menu/${id}`, data); +} + +/** + * 删除菜单 + * @param id 菜单 ID + */ +async function deleteMenu(id: string) { + return requestClient.delete(`/system/menu/${id}`); +} + +export { + createMenu, + deleteMenu, + getMenuList, + isMenuNameExists, + isMenuPathExists, + updateMenu, +}; diff --git a/playground/src/locales/langs/en-US/system.json b/playground/src/locales/langs/en-US/system.json index 20f9fd11..7c1ae2f8 100644 --- a/playground/src/locales/langs/en-US/system.json +++ b/playground/src/locales/langs/en-US/system.json @@ -9,5 +9,44 @@ "remark": "Remark", "operation": "Operation", "parentDept": "Parent Department" + }, + "menu": { + "title": "Menu Management", + "parent": "Parent Menu", + "menuTitle": "Title", + "menuName": "Menu Name", + "name": "Menu", + "type": "Type", + "typeCatalog": "Catalog", + "typeMenu": "Menu", + "typeButton": "Button", + "typeLink": "Link", + "typeEmbedded": "Embedded", + "icon": "Icon", + "activeIcon": "Active Icon", + "activePath": "Active Path", + "path": "Route Path", + "component": "Component", + "status": "Status", + "authCode": "Auth Code", + "badge": "Badge", + "operation": "Operation", + "linkSrc": "Link Address", + "affixTab": "Affix In Tabs", + "keepAlive": "Keep Alive", + "hideInMenu": "Hide In Menu", + "hideInTab": "Hide In Tabbar", + "hideChildrenInMenu": "Hide Children In Menu", + "hideInBreadcrumb": "Hide In Breadcrumb", + "advancedSettings": "Other Settings", + "activePathMustExist": "The path could not find a valid menu", + "activePathHelp": "When jumping to the current route, \nthe menu path that needs to be activated must be specified when it does not display in the navigation menu.", + "badgeType": { + "title": "Badge Type", + "dot": "Dot", + "normal": "Text", + "none": "None" + }, + "badgeVariants": "Badge Style" } } diff --git a/playground/src/locales/langs/zh-CN/system.json b/playground/src/locales/langs/zh-CN/system.json index 7fa1f1b1..04b9f8a9 100644 --- a/playground/src/locales/langs/zh-CN/system.json +++ b/playground/src/locales/langs/zh-CN/system.json @@ -9,5 +9,44 @@ "status": "状态", "title": "部门管理" }, + "menu": { + "activeIcon": "激活图标", + "activePath": "激活路径", + "activePathHelp": "跳转到当前路由时,需要激活的菜单路径。\n当不在导航菜单中显示时,需要指定激活路径", + "activePathMustExist": "该路径未能找到有效的菜单", + "advancedSettings": "其它设置", + "affixTab": "固定在标签", + "authCode": "权限标识", + "badge": "徽章内容", + "badgeVariants": "徽标样式", + "badgeType": { + "dot": "点", + "none": "无", + "normal": "文字", + "title": "徽标类型" + }, + "component": "页面组件", + "hideChildrenInMenu": "隐藏子菜单", + "hideInBreadcrumb": "在面包屑中隐藏", + "hideInMenu": "隐藏菜单", + "hideInTab": "在标签栏中隐藏", + "icon": "图标", + "keepAlive": "缓存标签页", + "linkSrc": "链接地址", + "menuName": "菜单名称", + "menuTitle": "标题", + "name": "菜单", + "operation": "操作", + "parent": "上级菜单", + "path": "路由地址", + "status": "状态", + "title": "菜单管理", + "type": "类型", + "typeButton": "按钮", + "typeCatalog": "目录", + "typeEmbedded": "内嵌", + "typeLink": "外链", + "typeMenu": "菜单" + }, "title": "系统管理" } diff --git a/playground/src/router/routes/index.ts b/playground/src/router/routes/index.ts index e6fb1440..275eb837 100644 --- a/playground/src/router/routes/index.ts +++ b/playground/src/router/routes/index.ts @@ -34,4 +34,14 @@ const coreRouteNames = traverseTreeValues(coreRoutes, (route) => route.name); /** 有权限校验的路由列表,包含动态路由和静态路由 */ const accessRoutes = [...dynamicRoutes, ...staticRoutes]; -export { accessRoutes, coreRouteNames, routes }; + +const componentKeys: string[] = Object.keys( + import.meta.glob('../../views/**/*.vue'), +) + .filter((item) => !item.includes('/modules/')) + .map((v) => { + const path = v.replace('../../views/', '/'); + return path.endsWith('.vue') ? path.slice(0, -4) : path; + }); + +export { accessRoutes, componentKeys, coreRouteNames, routes }; diff --git a/playground/src/router/routes/modules/system.ts b/playground/src/router/routes/modules/system.ts index 0de34651..11dd4843 100644 --- a/playground/src/router/routes/modules/system.ts +++ b/playground/src/router/routes/modules/system.ts @@ -12,6 +12,15 @@ const routes: RouteRecordRaw[] = [ name: 'System', path: '/system', children: [ + { + path: '/system/menu', + name: 'SystemMenu', + meta: { + icon: 'mdi:menu', + title: $t('system.menu.title'), + }, + component: () => import('#/views/system/menu/list.vue'), + }, { path: '/system/dept', name: 'SystemDept', diff --git a/playground/src/views/system/menu/data.ts b/playground/src/views/system/menu/data.ts new file mode 100644 index 00000000..75190b4a --- /dev/null +++ b/playground/src/views/system/menu/data.ts @@ -0,0 +1,109 @@ +import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table'; +import type { SystemMenuApi } from '#/api/system/menu'; + +import { $t } from '#/locales'; + +export function getMenuTypeOptions() { + return [ + { + color: 'processing', + label: $t('system.menu.typeCatalog'), + value: 'catalog', + }, + { color: 'default', label: $t('system.menu.typeMenu'), value: 'menu' }, + { color: 'error', label: $t('system.menu.typeButton'), value: 'button' }, + { + color: 'success', + label: $t('system.menu.typeEmbedded'), + value: 'embedded', + }, + { color: 'warning', label: $t('system.menu.typeLink'), value: 'link' }, + ]; +} + +export function useColumns( + onActionClick: OnActionClickFn, +): VxeTableGridOptions['columns'] { + return [ + { + align: 'left', + field: 'meta.title', + fixed: 'left', + slots: { default: 'title' }, + title: $t('system.menu.menuTitle'), + treeNode: true, + width: 250, + }, + { + align: 'center', + cellRender: { name: 'CellTag', options: getMenuTypeOptions() }, + field: 'type', + title: $t('system.menu.type'), + width: 100, + }, + { + field: 'authCode', + title: $t('system.menu.authCode'), + width: 200, + }, + { + align: 'left', + field: 'path', + title: $t('system.menu.path'), + width: 200, + }, + + { + align: 'left', + field: 'component', + formatter: ({ row }) => { + switch (row.type) { + case 'catalog': + case 'menu': { + return row.component ?? ''; + } + case 'embedded': { + return row.meta?.iframeSrc ?? ''; + } + case 'link': { + return row.meta?.link ?? ''; + } + } + return ''; + }, + minWidth: 200, + title: $t('system.menu.component'), + }, + { + cellRender: { name: 'CellTag' }, + field: 'status', + title: $t('system.menu.status'), + width: 100, + }, + + { + align: 'right', + cellRender: { + attrs: { + nameField: 'name', + onClick: onActionClick, + }, + name: 'CellOperation', + options: [ + { + code: 'append', + text: '新增下级', + }, + 'edit', // 默认的编辑按钮 + 'delete', // 默认的删除按钮 + ], + }, + field: 'operation', + fixed: 'right', + headerAlign: 'center', + showOverflow: false, + title: $t('system.menu.operation'), + width: 200, + }, + ]; +} diff --git a/playground/src/views/system/menu/list.vue b/playground/src/views/system/menu/list.vue new file mode 100644 index 00000000..9ab45644 --- /dev/null +++ b/playground/src/views/system/menu/list.vue @@ -0,0 +1,162 @@ + + + diff --git a/playground/src/views/system/menu/modules/form.vue b/playground/src/views/system/menu/modules/form.vue new file mode 100644 index 00000000..f04cb4f7 --- /dev/null +++ b/playground/src/views/system/menu/modules/form.vue @@ -0,0 +1,521 @@ + +