From b37ed48b9d3781a31920786db1dc2aa4495e05fd Mon Sep 17 00:00:00 2001 From: Netfan Date: Fri, 7 Mar 2025 16:03:08 +0800 Subject: [PATCH] feat: role management page with component `tree` (#5675) * feat: add shadcn tree * fix: update vbenTree component * feat: role management demo page * feat: add cellSwitch renderer for vxeTable * chore: remove tree examples --- apps/backend-mock/api/system/role/list.ts | 83 +++++ apps/backend-mock/api/table/list.ts | 2 +- apps/backend-mock/middleware/1.api.ts | 2 +- apps/backend-mock/utils/mock-data.ts | 11 + packages/@core/base/icons/src/lucide.ts | 3 + .../@core/ui-kit/shadcn-ui/src/ui/index.ts | 1 + .../ui-kit/shadcn-ui/src/ui/tree/index.ts | 2 + .../ui-kit/shadcn-ui/src/ui/tree/tree.vue | 301 ++++++++++++++++++ .../effects/common-ui/src/components/index.ts | 2 + playground/src/adapter/vxe-table.ts | 33 +- playground/src/api/index.ts | 1 + playground/src/api/system/index.ts | 3 + playground/src/api/system/role.ts | 55 ++++ .../src/locales/langs/en-US/system.json | 13 + .../src/locales/langs/zh-CN/system.json | 15 + .../src/router/routes/modules/system.ts | 9 + playground/src/views/system/role/data.ts | 127 ++++++++ playground/src/views/system/role/list.vue | 164 ++++++++++ .../src/views/system/role/modules/form.vue | 139 ++++++++ 19 files changed, 963 insertions(+), 3 deletions(-) create mode 100644 apps/backend-mock/api/system/role/list.ts create mode 100644 packages/@core/ui-kit/shadcn-ui/src/ui/tree/index.ts create mode 100644 packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue create mode 100644 playground/src/api/system/index.ts create mode 100644 playground/src/api/system/role.ts create mode 100644 playground/src/views/system/role/data.ts create mode 100644 playground/src/views/system/role/list.vue create mode 100644 playground/src/views/system/role/modules/form.vue diff --git a/apps/backend-mock/api/system/role/list.ts b/apps/backend-mock/api/system/role/list.ts new file mode 100644 index 00000000..4d5f923e --- /dev/null +++ b/apps/backend-mock/api/system/role/list.ts @@ -0,0 +1,83 @@ +import { faker } from '@faker-js/faker'; +import { verifyAccessToken } from '~/utils/jwt-utils'; +import { getMenuIds, MOCK_MENU_LIST } from '~/utils/mock-data'; +import { unAuthorizedResponse, usePageResponseSuccess } from '~/utils/response'; + +const formatterCN = new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', +}); + +const menuIds = getMenuIds(MOCK_MENU_LIST); + +function generateMockDataList(count: number) { + const dataList = []; + + for (let i = 0; i < count; i++) { + const dataItem: Record = { + id: faker.string.uuid(), + name: faker.commerce.product(), + status: faker.helpers.arrayElement([0, 1]), + createTime: formatterCN.format( + faker.date.between({ from: '2022-01-01', to: '2025-01-01' }), + ), + permissions: faker.helpers.arrayElements(menuIds), + remark: faker.lorem.sentence(), + }; + + dataList.push(dataItem); + } + + return dataList; +} + +const mockData = generateMockDataList(100); + +export default eventHandler(async (event) => { + const userinfo = verifyAccessToken(event); + if (!userinfo) { + return unAuthorizedResponse(event); + } + + const { + page = 1, + pageSize = 20, + name, + id, + remark, + startTime, + endTime, + status, + } = getQuery(event); + let listData = structuredClone(mockData); + if (name) { + listData = listData.filter((item) => + item.name.toLowerCase().includes(String(name).toLowerCase()), + ); + } + if (id) { + listData = listData.filter((item) => + item.id.toLowerCase().includes(String(id).toLowerCase()), + ); + } + if (remark) { + listData = listData.filter((item) => + item.remark?.toLowerCase()?.includes(String(remark).toLowerCase()), + ); + } + if (startTime) { + listData = listData.filter((item) => item.createTime >= startTime); + } + if (endTime) { + listData = listData.filter((item) => item.createTime <= endTime); + } + if (['0', '1'].includes(status as string)) { + listData = listData.filter((item) => item.status === Number(status)); + } + return usePageResponseSuccess(page as string, pageSize as string, listData); +}); diff --git a/apps/backend-mock/api/table/list.ts b/apps/backend-mock/api/table/list.ts index 55b88eaa..3e6f705b 100644 --- a/apps/backend-mock/api/table/list.ts +++ b/apps/backend-mock/api/table/list.ts @@ -1,6 +1,6 @@ import { faker } from '@faker-js/faker'; import { verifyAccessToken } from '~/utils/jwt-utils'; -import { unAuthorizedResponse } from '~/utils/response'; +import { unAuthorizedResponse, usePageResponseSuccess } from '~/utils/response'; function generateMockDataList(count: number) { const dataList = []; diff --git a/apps/backend-mock/middleware/1.api.ts b/apps/backend-mock/middleware/1.api.ts index f3e5212e..bad9a41a 100644 --- a/apps/backend-mock/middleware/1.api.ts +++ b/apps/backend-mock/middleware/1.api.ts @@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => { ['DELETE', 'PATCH', 'POST', 'PUT'].includes(event.method) && event.path.startsWith('/api/system/') ) { - await sleep(Math.floor(Math.random() * 1000)); + await sleep(Math.floor(Math.random() * 2000)); return forbiddenResponse(event, '演示环境,禁止修改'); } }); diff --git a/apps/backend-mock/utils/mock-data.ts b/apps/backend-mock/utils/mock-data.ts index fd7b969b..192f30a0 100644 --- a/apps/backend-mock/utils/mock-data.ts +++ b/apps/backend-mock/utils/mock-data.ts @@ -377,3 +377,14 @@ export const MOCK_MENU_LIST = [ path: '/about', }, ]; + +export function getMenuIds(menus: any[]) { + const ids: number[] = []; + menus.forEach((item) => { + ids.push(item.id); + if (item.children && item.children.length > 0) { + ids.push(...getMenuIds(item.children)); + } + }); + return ids; +} diff --git a/packages/@core/base/icons/src/lucide.ts b/packages/@core/base/icons/src/lucide.ts index 751a80ad..44d07f94 100644 --- a/packages/@core/base/icons/src/lucide.ts +++ b/packages/@core/base/icons/src/lucide.ts @@ -55,6 +55,9 @@ export { SearchX, Settings, Shrink, + Square, + SquareCheckBig, + SquareMinus, Sun, SunMoon, SwatchBook, diff --git a/packages/@core/ui-kit/shadcn-ui/src/ui/index.ts b/packages/@core/ui-kit/shadcn-ui/src/ui/index.ts index 02e76145..5c9e8046 100644 --- a/packages/@core/ui-kit/shadcn-ui/src/ui/index.ts +++ b/packages/@core/ui-kit/shadcn-ui/src/ui/index.ts @@ -27,3 +27,4 @@ export * from './textarea'; export * from './toggle'; export * from './toggle-group'; export * from './tooltip'; +export * from './tree'; diff --git a/packages/@core/ui-kit/shadcn-ui/src/ui/tree/index.ts b/packages/@core/ui-kit/shadcn-ui/src/ui/tree/index.ts new file mode 100644 index 00000000..1abd4c50 --- /dev/null +++ b/packages/@core/ui-kit/shadcn-ui/src/ui/tree/index.ts @@ -0,0 +1,2 @@ +export { default as VbenTree } from './tree.vue'; +export type { FlattenedItem } from 'radix-vue'; diff --git a/packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue b/packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue new file mode 100644 index 00000000..46866979 --- /dev/null +++ b/packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue @@ -0,0 +1,301 @@ + + diff --git a/packages/effects/common-ui/src/components/index.ts b/packages/effects/common-ui/src/components/index.ts index d1f0f937..bf99bc41 100644 --- a/packages/effects/common-ui/src/components/index.ts +++ b/packages/effects/common-ui/src/components/index.ts @@ -22,6 +22,8 @@ export { VbenLoading, VbenPinInput, VbenSpinner, + VbenTree, } from '@vben-core/shadcn-ui'; +export type { FlattenedItem } from '@vben-core/shadcn-ui'; export { globalShareState } from '@vben-core/shared/global-state'; diff --git a/playground/src/adapter/vxe-table.ts b/playground/src/adapter/vxe-table.ts index b4a1b3c8..cb24b561 100644 --- a/playground/src/adapter/vxe-table.ts +++ b/playground/src/adapter/vxe-table.ts @@ -8,7 +8,7 @@ import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table'; import { get, isFunction, isString } from '@vben/utils'; import { objectOmit } from '@vueuse/core'; -import { Button, Image, Popconfirm, Tag } from 'ant-design-vue'; +import { Button, Image, Popconfirm, Switch, Tag } from 'ant-design-vue'; import { $t } from '#/locales'; @@ -94,6 +94,34 @@ setupVbenVxeTable({ }, }); + vxeUI.renderer.add('CellSwitch', { + renderTableDefault({ attrs, props }, { column, row }) { + const loadingKey = `__loading_${column.field}`; + const finallyProps = { + checkedChildren: $t('common.enabled'), + checkedValue: 1, + unCheckedChildren: $t('common.disabled'), + unCheckedValue: 0, + ...props, + checked: row[column.field], + loading: row[loadingKey] ?? false, + 'onUpdate:checked': onChange, + }; + async function onChange(newVal: any) { + row[loadingKey] = true; + try { + const result = await attrs?.beforeChange?.(newVal, row); + if (result !== false) { + row[column.field] = newVal; + } + } finally { + row[loadingKey] = false; + } + } + return h(Switch, finallyProps); + }, + }); + /** * 注册表格的操作按钮渲染器 */ @@ -183,6 +211,9 @@ setupVbenVxeTable({ return h( Popconfirm, { + getPopupContainer(el) { + return el.closest('tbody') || document.body; + }, placement: 'topLeft', title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']), ...props, diff --git a/playground/src/api/index.ts b/playground/src/api/index.ts index ab806c62..3c3fa0d2 100644 --- a/playground/src/api/index.ts +++ b/playground/src/api/index.ts @@ -1,2 +1,3 @@ export * from './core'; export * from './examples'; +export * from './system'; diff --git a/playground/src/api/system/index.ts b/playground/src/api/system/index.ts new file mode 100644 index 00000000..f2a248f1 --- /dev/null +++ b/playground/src/api/system/index.ts @@ -0,0 +1,3 @@ +export * from './dept'; +export * from './menu'; +export * from './role'; diff --git a/playground/src/api/system/role.ts b/playground/src/api/system/role.ts new file mode 100644 index 00000000..60b465ae --- /dev/null +++ b/playground/src/api/system/role.ts @@ -0,0 +1,55 @@ +import type { Recordable } from '@vben/types'; + +import { requestClient } from '#/api/request'; + +export namespace SystemRoleApi { + export interface SystemRole { + [key: string]: any; + id: string; + name: string; + permissions: string[]; + remark?: string; + status: 0 | 1; + } +} + +/** + * 获取角色列表数据 + */ +async function getRoleList(params: Recordable) { + return requestClient.get>( + '/system/role/list', + { params }, + ); +} + +/** + * 创建角色 + * @param data 角色数据 + */ +async function createRole(data: Omit) { + return requestClient.post('/system/role', data); +} + +/** + * 更新角色 + * + * @param id 角色 ID + * @param data 角色数据 + */ +async function updateRole( + id: string, + data: Omit, +) { + return requestClient.put(`/system/role/${id}`, data); +} + +/** + * 删除角色 + * @param id 角色 ID + */ +async function deleteRole(id: string) { + return requestClient.delete(`/system/role/${id}`); +} + +export { createRole, deleteRole, getRoleList, updateRole }; diff --git a/playground/src/locales/langs/en-US/system.json b/playground/src/locales/langs/en-US/system.json index 7c1ae2f8..003dfbbe 100644 --- a/playground/src/locales/langs/en-US/system.json +++ b/playground/src/locales/langs/en-US/system.json @@ -48,5 +48,18 @@ "none": "None" }, "badgeVariants": "Badge Style" + }, + "role": { + "title": "Role Management", + "list": "Role List", + "name": "Role", + "roleName": "Role Name", + "id": "Role ID", + "status": "Status", + "remark": "Remark", + "createTime": "Creation Time", + "operation": "Operation", + "permissions": "Permissions", + "setPermissions": "Permissions" } } diff --git a/playground/src/locales/langs/zh-CN/system.json b/playground/src/locales/langs/zh-CN/system.json index 04b9f8a9..b0f5e7fa 100644 --- a/playground/src/locales/langs/zh-CN/system.json +++ b/playground/src/locales/langs/zh-CN/system.json @@ -1,5 +1,6 @@ { "dept": { + "list": "部门列表", "createTime": "创建时间", "deptName": "部门名称", "name": "部门", @@ -10,6 +11,7 @@ "title": "部门管理" }, "menu": { + "list": "菜单列表", "activeIcon": "激活图标", "activePath": "激活路径", "activePathHelp": "跳转到当前路由时,需要激活的菜单路径。\n当不在导航菜单中显示时,需要指定激活路径", @@ -48,5 +50,18 @@ "typeLink": "外链", "typeMenu": "菜单" }, + "role": { + "title": "角色管理", + "list": "角色列表", + "name": "角色", + "roleName": "角色名称", + "id": "角色ID", + "status": "状态", + "remark": "备注", + "createTime": "创建时间", + "operation": "操作", + "permissions": "权限", + "setPermissions": "授权" + }, "title": "系统管理" } diff --git a/playground/src/router/routes/modules/system.ts b/playground/src/router/routes/modules/system.ts index 11dd4843..e1bf7125 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/role', + name: 'SystemRole', + meta: { + icon: 'mdi:account-group', + title: $t('system.role.title'), + }, + component: () => import('#/views/system/role/list.vue'), + }, { path: '/system/menu', name: 'SystemMenu', diff --git a/playground/src/views/system/role/data.ts b/playground/src/views/system/role/data.ts new file mode 100644 index 00000000..255b6cc7 --- /dev/null +++ b/playground/src/views/system/role/data.ts @@ -0,0 +1,127 @@ +import type { VbenFormSchema } from '#/adapter/form'; +import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table'; +import type { SystemRoleApi } from '#/api'; + +import { $t } from '#/locales'; + +export function useFormSchema(): VbenFormSchema[] { + return [ + { + component: 'Input', + fieldName: 'name', + label: $t('system.role.roleName'), + rules: 'required', + }, + { + component: 'RadioGroup', + componentProps: { + buttonStyle: 'solid', + options: [ + { label: $t('common.enabled'), value: 1 }, + { label: $t('common.disabled'), value: 0 }, + ], + optionType: 'button', + }, + defaultValue: 1, + fieldName: 'status', + label: $t('system.role.status'), + }, + { + component: 'Textarea', + fieldName: 'remark', + label: $t('system.role.remark'), + }, + { + component: 'Input', + fieldName: 'permissions', + formItemClass: 'items-start', + label: $t('system.role.setPermissions'), + modelPropName: 'modelValue', + }, + ]; +} + +export function useGridFormSchema(): VbenFormSchema[] { + return [ + { + component: 'Input', + fieldName: 'name', + label: $t('system.role.roleName'), + }, + { component: 'Input', fieldName: 'id', label: $t('system.role.id') }, + { + component: 'Select', + componentProps: { + allowClear: true, + options: [ + { label: $t('common.enabled'), value: 1 }, + { label: $t('common.disabled'), value: 0 }, + ], + }, + fieldName: 'status', + label: $t('system.role.status'), + }, + { + component: 'Input', + fieldName: 'remark', + label: $t('system.role.remark'), + }, + { + component: 'RangePicker', + fieldName: 'createTime', + label: $t('system.role.createTime'), + }, + ]; +} + +export function useColumns( + onActionClick: OnActionClickFn, + onStatusChange?: (newStatus: any, row: T) => PromiseLike, +): VxeTableGridOptions['columns'] { + return [ + { + field: 'name', + title: $t('system.role.roleName'), + width: 200, + }, + { + field: 'id', + title: $t('system.role.id'), + width: 200, + }, + { + cellRender: { + attrs: { beforeChange: onStatusChange }, + name: onStatusChange ? 'CellSwitch' : 'CellTag', + }, + field: 'status', + title: $t('system.role.status'), + width: 100, + }, + { + field: 'remark', + minWidth: 100, + title: $t('system.role.remark'), + }, + { + field: 'createTime', + title: $t('system.role.createTime'), + width: 200, + }, + { + align: 'center', + cellRender: { + attrs: { + nameField: 'name', + nameTitle: $t('system.role.name'), + onClick: onActionClick, + }, + name: 'CellOperation', + }, + field: 'operation', + fixed: 'right', + title: $t('system.role.operation'), + width: 130, + }, + ]; +} diff --git a/playground/src/views/system/role/list.vue b/playground/src/views/system/role/list.vue new file mode 100644 index 00000000..b5e383a6 --- /dev/null +++ b/playground/src/views/system/role/list.vue @@ -0,0 +1,164 @@ + + diff --git a/playground/src/views/system/role/modules/form.vue b/playground/src/views/system/role/modules/form.vue new file mode 100644 index 00000000..1b6d00e8 --- /dev/null +++ b/playground/src/views/system/role/modules/form.vue @@ -0,0 +1,139 @@ + + +