Browse Source

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
Netfan 2 weeks ago
parent
commit
b37ed48b9d

+ 83 - 0
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<string, any> = {
+      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);
+});

+ 1 - 1
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 = [];

+ 1 - 1
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, '演示环境,禁止修改');
   }
 });

+ 11 - 0
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;
+}

+ 3 - 0
packages/@core/base/icons/src/lucide.ts

@@ -55,6 +55,9 @@ export {
   SearchX,
   Settings,
   Shrink,
+  Square,
+  SquareCheckBig,
+  SquareMinus,
   Sun,
   SunMoon,
   SwatchBook,

+ 1 - 0
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';

+ 2 - 0
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';

+ 301 - 0
packages/@core/ui-kit/shadcn-ui/src/ui/tree/tree.vue

@@ -0,0 +1,301 @@
+<script lang="ts" setup>
+import type { Arrayable } from '@vueuse/core';
+import type { FlattenedItem } from 'radix-vue';
+
+import type { ClassType, Recordable } from '@vben-core/typings';
+
+import { onMounted, ref, watch, watchEffect } from 'vue';
+
+import { ChevronRight, IconifyIcon } from '@vben-core/icons';
+import { cn, get } from '@vben-core/shared/utils';
+
+import { useVModel } from '@vueuse/core';
+import { TreeItem, TreeRoot } from 'radix-vue';
+
+import { Checkbox } from '../checkbox';
+
+interface TreeProps {
+  /** 单选时允许取消已有选项 */
+  allowClear?: boolean;
+  /** 显示边框 */
+  bordered?: boolean;
+  /** 取消父子关联选择 */
+  checkStrictly?: boolean;
+  /** 子级字段名 */
+  childrenField?: string;
+  /** 默认展开的键 */
+  defaultExpandedKeys?: Array<number | string>;
+  /** 默认展开的级别(优先级高于defaultExpandedKeys) */
+  defaultExpandedLevel?: number;
+  /** 默认值 */
+  defaultValue?: Arrayable<number | string>;
+  /** 禁用 */
+  disabled?: boolean;
+  /** 自定义节点类名 */
+  getNodeClass?: (item: FlattenedItem<Recordable<any>>) => string;
+  iconField?: string;
+  /** label字段 */
+  labelField?: string;
+  /** 当前值 */
+  modelValue?: Arrayable<number | string>;
+  /** 是否多选 */
+  multiple?: boolean;
+  /** 显示由iconField指定的图标 */
+  showIcon?: boolean;
+  /** 启用展开收缩动画 */
+  transition?: boolean;
+  /** 树数据 */
+  treeData: Recordable<any>[];
+  /** 值字段 */
+  valueField?: string;
+}
+const props = withDefaults(defineProps<TreeProps>(), {
+  allowClear: false,
+  bordered: false,
+  checkStrictly: false,
+  defaultExpandedKeys: () => [],
+  disabled: false,
+  expanded: () => [],
+  iconField: 'icon',
+  labelField: 'label',
+  modelValue: () => [],
+  multiple: false,
+  showIcon: true,
+  transition: false,
+  valueField: 'value',
+  childrenField: 'children',
+});
+
+const emits = defineEmits<{
+  expand: [value: FlattenedItem<Recordable<any>>];
+  select: [value: FlattenedItem<Recordable<any>>];
+  'update:modelValue': [value: Arrayable<Recordable<any>>];
+}>();
+
+interface InnerFlattenItem<T = Recordable<any>> {
+  hasChildren: boolean;
+  level: number;
+  value: T;
+}
+
+function flatten<T = Recordable<any>>(
+  items: T[],
+  childrenField: string = 'children',
+  level = 0,
+): InnerFlattenItem<T>[] {
+  const result: InnerFlattenItem<T>[] = [];
+  items.forEach((item) => {
+    const children = get(item, childrenField) as Array<T>;
+    const val = {
+      hasChildren: Array.isArray(children) && children.length > 0,
+      level,
+      value: item,
+    };
+    result.push(val);
+    if (val.hasChildren)
+      result.push(...flatten(children, childrenField, level + 1));
+  });
+  return result;
+}
+
+const flattenData = ref<Array<InnerFlattenItem>>([]);
+const modelValue = useVModel(props, 'modelValue', emits, {
+  deep: true,
+  defaultValue: props.defaultValue ?? [],
+  passive: (props.modelValue === undefined) as false,
+});
+const expanded = ref<Array<number | string>>(props.defaultExpandedKeys ?? []);
+
+const treeValue = ref();
+
+onMounted(() => {
+  watchEffect(() => {
+    flattenData.value = flatten(props.treeData, props.childrenField);
+    updateTreeValue();
+    if (
+      props.defaultExpandedLevel !== undefined &&
+      props.defaultExpandedLevel > 0
+    )
+      expandToLevel(props.defaultExpandedLevel);
+  });
+});
+
+function getItemByValue(value: number | string) {
+  return flattenData.value.find(
+    (item) => get(item.value, props.valueField) === value,
+  )?.value;
+}
+
+function updateTreeValue() {
+  const val = modelValue.value;
+  treeValue.value = Array.isArray(val)
+    ? val.map((v) => getItemByValue(v))
+    : getItemByValue(val);
+}
+
+watch(
+  modelValue,
+  () => {
+    updateTreeValue();
+  },
+  { deep: true, immediate: true },
+);
+
+function updateModelValue(val: Arrayable<Recordable<any>>) {
+  modelValue.value = Array.isArray(val)
+    ? val.map((v) => get(v, props.valueField))
+    : get(val, props.valueField);
+}
+
+function expandToLevel(level: number) {
+  const keys: string[] = [];
+  flattenData.value.forEach((item) => {
+    if (item.level <= level - 1) {
+      keys.push(get(item.value, props.valueField));
+    }
+  });
+  expanded.value = keys;
+}
+
+function collapseNodes(value: Arrayable<number | string>) {
+  const keys = new Set(Array.isArray(value) ? value : [value]);
+  expanded.value = expanded.value.filter((key) => !keys.has(key));
+}
+
+function expandNodes(value: Arrayable<number | string>) {
+  const keys = [...(Array.isArray(value) ? value : [value])];
+  keys.forEach((key) => {
+    if (expanded.value.includes(key)) return;
+    const item = getItemByValue(key);
+    if (item) {
+      expanded.value.push(key);
+    }
+  });
+}
+
+function expandAll() {
+  expanded.value = flattenData.value
+    .filter((item) => item.hasChildren)
+    .map((item) => get(item.value, props.valueField));
+}
+
+function collapseAll() {
+  expanded.value = [];
+}
+
+function onToggle(item: FlattenedItem<Recordable<any>>) {
+  emits('expand', item);
+}
+function onSelect(item: FlattenedItem<Recordable<any>>) {
+  emits('select', item);
+}
+
+defineExpose({
+  collapseAll,
+  collapseNodes,
+  expandAll,
+  expandNodes,
+  expandToLevel,
+  getItemByValue,
+});
+</script>
+<template>
+  <TreeRoot
+    :get-key="(item) => get(item, valueField)"
+    :get-children="(item) => get(item, childrenField)"
+    :items="treeData"
+    :model-value="treeValue"
+    v-model:expanded="expanded as string[]"
+    :default-expanded="defaultExpandedKeys as string[]"
+    :propagate-select="!checkStrictly"
+    :multiple="multiple"
+    :disabled="disabled"
+    :selection-behavior="allowClear || multiple ? 'toggle' : 'replace'"
+    @update:model-value="updateModelValue"
+    v-slot="{ flattenItems }"
+    :class="
+      cn(
+        'text-blackA11 select-none list-none rounded-lg p-2 text-sm font-medium',
+        $attrs.class as unknown as ClassType,
+        bordered ? 'border' : '',
+      )
+    "
+  >
+    <div class="w-full" v-if="$slots.header">
+      <slot name="header"> </slot>
+    </div>
+    <TreeItem
+      v-for="item in flattenItems"
+      v-slot="{
+        isExpanded,
+        isSelected,
+        isIndeterminate,
+        handleSelect,
+        handleToggle,
+      }"
+      :key="item._id"
+      :style="{ 'padding-left': `${item.level - 0.5}rem` }"
+      :class="
+        cn('cursor-pointer', getNodeClass?.(item), {
+          'data-[selected]:bg-accent': !multiple,
+        })
+      "
+      v-bind="item.bind"
+      @select="
+        (event) => {
+          if (event.detail.originalEvent.type === 'click') {
+            // event.preventDefault();
+          }
+          onSelect(item);
+        }
+      "
+      @toggle="
+        (event) => {
+          if (event.detail.originalEvent.type === 'click') {
+            event.preventDefault();
+          }
+          onToggle(item);
+        }
+      "
+      class="tree-node focus:ring-grass8 my-0.5 flex items-center rounded px-2 py-1 outline-none focus:ring-2"
+    >
+      <ChevronRight
+        v-if="item.hasChildren"
+        class="size-4 cursor-pointer transition"
+        :class="{ 'rotate-90': isExpanded }"
+        @click.stop="handleToggle"
+      />
+      <div v-else class="h-4 w-4">
+        <!-- <IconifyIcon v-if="item.value.icon" :icon="item.value.icon" /> -->
+      </div>
+      <Checkbox
+        v-if="multiple"
+        :checked="isSelected"
+        :indeterminate="isIndeterminate"
+        @click.stop="handleSelect"
+      />
+      <div
+        class="flex items-center gap-1 pl-2"
+        @click="
+          ($event) => {
+            $event.stopPropagation();
+            $event.preventDefault();
+            handleSelect();
+          }
+        "
+      >
+        <slot name="node" v-bind="item">
+          <IconifyIcon
+            class="size-4"
+            v-if="showIcon && get(item.value, iconField)"
+            :icon="get(item.value, iconField)"
+          />
+          {{ get(item.value, labelField) }}
+        </slot>
+      </div>
+    </TreeItem>
+    <div class="w-full" v-if="$slots.footer">
+      <slot name="footer"> </slot>
+    </div>
+  </TreeRoot>
+</template>

+ 2 - 0
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';

+ 32 - 1
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,

+ 1 - 0
playground/src/api/index.ts

@@ -1,2 +1,3 @@
 export * from './core';
 export * from './examples';
+export * from './system';

+ 3 - 0
playground/src/api/system/index.ts

@@ -0,0 +1,3 @@
+export * from './dept';
+export * from './menu';
+export * from './role';

+ 55 - 0
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<any>) {
+  return requestClient.get<Array<SystemRoleApi.SystemRole>>(
+    '/system/role/list',
+    { params },
+  );
+}
+
+/**
+ * 创建角色
+ * @param data 角色数据
+ */
+async function createRole(data: Omit<SystemRoleApi.SystemRole, 'id'>) {
+  return requestClient.post('/system/role', data);
+}
+
+/**
+ * 更新角色
+ *
+ * @param id 角色 ID
+ * @param data 角色数据
+ */
+async function updateRole(
+  id: string,
+  data: Omit<SystemRoleApi.SystemRole, 'id'>,
+) {
+  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 };

+ 13 - 0
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"
   }
 }

+ 15 - 0
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": "系统管理"
 }

+ 9 - 0
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',

+ 127 - 0
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<T = SystemRoleApi.SystemRole>(
+  onActionClick: OnActionClickFn<T>,
+  onStatusChange?: (newStatus: any, row: T) => PromiseLike<boolean | undefined>,
+): 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,
+    },
+  ];
+}

+ 164 - 0
playground/src/views/system/role/list.vue

@@ -0,0 +1,164 @@
+<script lang="ts" setup>
+import type { Recordable } from '@vben/types';
+
+import type {
+  OnActionClickParams,
+  VxeTableGridOptions,
+} from '#/adapter/vxe-table';
+import type { SystemRoleApi } from '#/api';
+
+import { Page, useVbenDrawer } from '@vben/common-ui';
+import { Plus } from '@vben/icons';
+
+import { Button, message, Modal } from 'ant-design-vue';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import { deleteRole, getRoleList, updateRole } from '#/api';
+import { $t } from '#/locales';
+
+import { useColumns, useGridFormSchema } from './data';
+import Form from './modules/form.vue';
+
+const [FormDrawer, formDrawerApi] = useVbenDrawer({
+  connectedComponent: Form,
+  destroyOnClose: true,
+});
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  formOptions: {
+    fieldMappingTime: [['createTime', ['startTime', 'endTime']]],
+    schema: useGridFormSchema(),
+    submitOnChange: true,
+  },
+  gridOptions: {
+    columns: useColumns(onActionClick, onStatusChange),
+    height: 'auto',
+    keepSource: true,
+    proxyConfig: {
+      ajax: {
+        query: async ({ page }, formValues) => {
+          return await getRoleList({
+            page: page.currentPage,
+            pageSize: page.pageSize,
+            ...formValues,
+          });
+        },
+      },
+    },
+    rowConfig: {
+      keyField: 'id',
+    },
+
+    toolbarConfig: {
+      custom: true,
+      export: false,
+      refresh: { code: 'query' },
+      search: true,
+      zoom: true,
+    },
+  } as VxeTableGridOptions<SystemRoleApi.SystemRole>,
+});
+
+function onActionClick(e: OnActionClickParams<SystemRoleApi.SystemRole>) {
+  switch (e.code) {
+    case 'delete': {
+      onDelete(e.row);
+      break;
+    }
+    case 'edit': {
+      onEdit(e.row);
+      break;
+    }
+  }
+}
+
+/**
+ * 将Antd的Modal.confirm封装为promise,方便在异步函数中调用。
+ * @param content 提示内容
+ * @param title 提示标题
+ */
+function confirm(content: string, title: string) {
+  return new Promise((reslove, reject) => {
+    Modal.confirm({
+      content,
+      onCancel() {
+        reject(new Error('已取消'));
+      },
+      onOk() {
+        reslove(true);
+      },
+      title,
+    });
+  });
+}
+
+/**
+ * 状态开关即将改变
+ * @param newStatus 期望改变的状态值
+ * @param row 行数据
+ * @returns 返回false则中止改变,返回其他值(undefined、true)则允许改变
+ */
+async function onStatusChange(
+  newStatus: number,
+  row: SystemRoleApi.SystemRole,
+) {
+  const status: Recordable<string> = {
+    0: '禁用',
+    1: '启用',
+  };
+  try {
+    await confirm(
+      `你要将${row.name}的状态切换为 【${status[newStatus.toString()]}】 吗?`,
+      `切换状态`,
+    );
+    await updateRole(row.id, { status: newStatus });
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+function onEdit(row: SystemRoleApi.SystemRole) {
+  formDrawerApi.setData(row).open();
+}
+
+function onDelete(row: SystemRoleApi.SystemRole) {
+  const hideLoading = message.loading({
+    content: $t('ui.actionMessage.deleting', [row.name]),
+    duration: 0,
+    key: 'action_process_msg',
+  });
+  deleteRole(row.id)
+    .then(() => {
+      message.success({
+        content: $t('ui.actionMessage.deleteSuccess', [row.name]),
+        key: 'action_process_msg',
+      });
+      onRefresh();
+    })
+    .catch(() => {
+      hideLoading();
+    });
+}
+
+function onRefresh() {
+  gridApi.query();
+}
+
+function onCreate() {
+  formDrawerApi.setData({}).open();
+}
+</script>
+<template>
+  <Page auto-content-height>
+    <FormDrawer />
+    <Grid :table-title="$t('system.role.list')">
+      <template #toolbar-tools>
+        <Button type="primary" @click="onCreate">
+          <Plus class="size-5" />
+          {{ $t('ui.actionTitle.create', [$t('system.role.name')]) }}
+        </Button>
+      </template>
+    </Grid>
+  </Page>
+</template>

+ 139 - 0
playground/src/views/system/role/modules/form.vue

@@ -0,0 +1,139 @@
+<script lang="ts" setup>
+import type { DataNode } from 'ant-design-vue/es/tree';
+
+import type { Recordable } from '@vben/types';
+
+import type { SystemRoleApi } from '#/api/system/role';
+
+import { computed, ref } from 'vue';
+
+import { useVbenDrawer, VbenTree } from '@vben/common-ui';
+import { IconifyIcon } from '@vben/icons';
+
+import { Spin } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import { getMenuList } from '#/api/system/menu';
+import { createRole, updateRole } from '#/api/system/role';
+import { $t } from '#/locales';
+
+import { useFormSchema } from '../data';
+
+const emits = defineEmits(['success']);
+
+const formData = ref<SystemRoleApi.SystemRole>();
+
+const [Form, formApi] = useVbenForm({
+  schema: useFormSchema(),
+  showDefaultActions: false,
+});
+
+const permissions = ref<DataNode[]>([]);
+const loadingPermissions = ref(false);
+
+const id = ref();
+const [Drawer, drawerApi] = useVbenDrawer({
+  async onConfirm() {
+    const { valid } = await formApi.validate();
+    if (!valid) return;
+    const values = await formApi.getValues();
+    drawerApi.lock();
+    (id.value ? updateRole(id.value, values) : createRole(values))
+      .then(() => {
+        emits('success');
+        drawerApi.close();
+      })
+      .catch(() => {
+        drawerApi.unlock();
+      });
+  },
+  onOpenChange(isOpen) {
+    if (isOpen) {
+      const data = drawerApi.getData<SystemRoleApi.SystemRole>();
+      formApi.resetForm();
+      if (data) {
+        formData.value = data;
+        id.value = data.id;
+        formApi.setValues(data);
+      } else {
+        id.value = undefined;
+      }
+
+      if (permissions.value.length === 0) {
+        loadPermissions();
+      }
+    }
+  },
+});
+
+async function loadPermissions() {
+  loadingPermissions.value = true;
+  try {
+    const res = await getMenuList();
+    permissions.value = res as unknown as DataNode[];
+  } finally {
+    loadingPermissions.value = false;
+  }
+}
+
+const getDrawerTitle = computed(() => {
+  return formData.value?.id
+    ? $t('common.edit', $t('system.role.name'))
+    : $t('common.create', $t('system.role.name'));
+});
+
+function getNodeClass(node: Recordable<any>) {
+  const classes: string[] = [];
+  if (node.value?.type === 'button') {
+    classes.push('inline-flex');
+    if (node.index % 3 >= 1) {
+      classes.push('!pl-0');
+    }
+  }
+
+  return classes.join(' ');
+}
+</script>
+<template>
+  <Drawer :title="getDrawerTitle">
+    <Form>
+      <template #permissions="slotProps">
+        <Spin :spinning="loadingPermissions">
+          <VbenTree
+            :tree-data="permissions"
+            multiple
+            bordered
+            :default-expanded-level="2"
+            :get-node-class="getNodeClass"
+            v-bind="slotProps"
+            value-field="id"
+            label-field="meta.title"
+            icon-field="meta.icon"
+          >
+            <template #node="{ value }">
+              <IconifyIcon v-if="value.meta.icon" :icon="value.meta.icon" />
+              {{ $t(value.meta.title) }}
+            </template>
+          </VbenTree>
+        </Spin>
+      </template>
+    </Form>
+  </Drawer>
+</template>
+<style lang="css" scoped>
+:deep(.ant-tree-title) {
+  .tree-actions {
+    display: none;
+    margin-left: 20px;
+  }
+}
+
+:deep(.ant-tree-title:hover) {
+  .tree-actions {
+    display: flex;
+    flex: auto;
+    justify-content: flex-end;
+    margin-left: 20px;
+  }
+}
+</style>