Browse Source

chore: demo page for system/department (#5611)

* feat: department management demo

* perf: department page improve

* feat: demo api middleware

* fix: add losing import
Netfan 4 weeks ago
parent
commit
d33261d0c2

+ 15 - 0
apps/backend-mock/api/system/dept/.post.ts

@@ -0,0 +1,15 @@
+import { verifyAccessToken } from '~/utils/jwt-utils';
+import {
+  sleep,
+  unAuthorizedResponse,
+  useResponseSuccess,
+} from '~/utils/response';
+
+export default eventHandler(async (event) => {
+  const userinfo = verifyAccessToken(event);
+  if (!userinfo) {
+    return unAuthorizedResponse(event);
+  }
+  await sleep(600);
+  return useResponseSuccess(null);
+});

+ 15 - 0
apps/backend-mock/api/system/dept/[id].delete.ts

@@ -0,0 +1,15 @@
+import { verifyAccessToken } from '~/utils/jwt-utils';
+import {
+  sleep,
+  unAuthorizedResponse,
+  useResponseSuccess,
+} from '~/utils/response';
+
+export default eventHandler(async (event) => {
+  const userinfo = verifyAccessToken(event);
+  if (!userinfo) {
+    return unAuthorizedResponse(event);
+  }
+  await sleep(1000);
+  return useResponseSuccess(null);
+});

+ 15 - 0
apps/backend-mock/api/system/dept/[id].put.ts

@@ -0,0 +1,15 @@
+import { verifyAccessToken } from '~/utils/jwt-utils';
+import {
+  sleep,
+  unAuthorizedResponse,
+  useResponseSuccess,
+} from '~/utils/response';
+
+export default eventHandler(async (event) => {
+  const userinfo = verifyAccessToken(event);
+  if (!userinfo) {
+    return unAuthorizedResponse(event);
+  }
+  await sleep(2000);
+  return useResponseSuccess(null);
+});

+ 67 - 0
apps/backend-mock/api/system/dept/list.ts

@@ -0,0 +1,67 @@
+import { faker } from '@faker-js/faker';
+import { verifyAccessToken } from '~/utils/jwt-utils';
+import {
+  sleep,
+  unAuthorizedResponse,
+  useResponseSuccess,
+} 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',
+});
+
+function generateMockDataList(count: number) {
+  const dataList = [];
+
+  for (let i = 0; i < count; i++) {
+    const dataItem: Record<string, any> = {
+      id: faker.string.uuid(),
+      pid: 0,
+      name: faker.commerce.department(),
+      status: faker.helpers.arrayElement([0, 1]),
+      createTime: formatterCN.format(
+        faker.date.between({ from: '2021-01-01', to: '2022-12-31' }),
+      ),
+      remark: faker.lorem.sentence(),
+    };
+    if (faker.datatype.boolean()) {
+      dataItem.children = Array.from(
+        { length: faker.number.int({ min: 1, max: 5 }) },
+        () => ({
+          id: faker.string.uuid(),
+          pid: dataItem.id,
+          name: faker.commerce.department(),
+          status: faker.helpers.arrayElement([0, 1]),
+          createTime: formatterCN.format(
+            faker.date.between({ from: '2023-01-01', to: '2023-12-31' }),
+          ),
+          remark: faker.lorem.sentence(),
+        }),
+      );
+    }
+    dataList.push(dataItem);
+  }
+
+  return dataList;
+}
+
+const mockData = generateMockDataList(10);
+
+export default eventHandler(async (event) => {
+  const userinfo = verifyAccessToken(event);
+  if (!userinfo) {
+    return unAuthorizedResponse(event);
+  }
+
+  await sleep(600);
+
+  const listData = structuredClone(mockData);
+
+  return useResponseSuccess(listData);
+});

+ 9 - 1
apps/backend-mock/middleware/1.api.ts

@@ -1,4 +1,6 @@
-export default defineEventHandler((event) => {
+import { forbiddenResponse, sleep } from '~/utils/response';
+
+export default defineEventHandler(async (event) => {
   event.node.res.setHeader(
     'Access-Control-Allow-Origin',
     event.headers.get('Origin') ?? '*',
@@ -7,5 +9,11 @@ export default defineEventHandler((event) => {
     event.node.res.statusCode = 204;
     event.node.res.statusMessage = 'No Content.';
     return 'OK';
+  } else if (
+    ['DELETE', 'PATCH', 'POST', 'PUT'].includes(event.method) &&
+    event.path.startsWith('/api/system/')
+  ) {
+    await sleep(Math.floor(Math.random() * 1000));
+    return forbiddenResponse(event, '演示环境,禁止修改');
   }
 });

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

@@ -49,6 +49,7 @@ export {
   PanelRight,
   Pin,
   PinOff,
+  Plus,
   RotateCw,
   Search,
   SearchX,

+ 2 - 0
packages/locales/src/index.ts

@@ -7,9 +7,11 @@ import {
 } from './i18n';
 
 const $t = i18n.global.t;
+const $te = i18n.global.te;
 
 export {
   $t,
+  $te,
   i18n,
   loadLocaleMessages,
   loadLocalesMap,

+ 7 - 1
packages/locales/src/langs/en-US/common.json

@@ -6,9 +6,15 @@
   "prompt": "Prompt",
   "cancel": "Cancel",
   "confirm": "Confirm",
+  "reset": "Reset",
   "noData": "No Data",
   "refresh": "Refresh",
   "loadingMenu": "Loading Menu",
   "query": "Search",
-  "search": "Search"
+  "search": "Search",
+  "enabled": "Enabled",
+  "disabled": "Disabled",
+  "edit": "Edit",
+  "delete": "Delete",
+  "create": "Create"
 }

+ 17 - 1
packages/locales/src/langs/en-US/ui.json

@@ -1,7 +1,23 @@
 {
   "formRules": {
     "required": "Please enter {0}",
-    "selectRequired": "Please select {0}"
+    "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"
+  },
+  "actionTitle": {
+    "edit": "Modify {0}",
+    "create": "Create {0}",
+    "delete": "Delete {0}",
+    "view": "View {0}"
+  },
+  "actionMessage": {
+    "deleteConfirm": "Are you sure to delete {0}?",
+    "deleting": "Deleting {0} ...",
+    "deleteSuccess": "{0} deleted successfully",
+    "operationSuccess": "Operation succeeded",
+    "operationFailed": "Operation failed"
   },
   "placeholder": {
     "input": "Please enter",

+ 7 - 1
packages/locales/src/langs/zh-CN/common.json

@@ -6,9 +6,15 @@
   "prompt": "提示",
   "cancel": "取消",
   "confirm": "确认",
+  "reset": "重置",
   "noData": "暂无数据",
   "refresh": "刷新",
   "loadingMenu": "加载菜单中",
   "query": "查询",
-  "search": "搜索"
+  "search": "搜索",
+  "enabled": "已启用",
+  "disabled": "已禁用",
+  "edit": "修改",
+  "delete": "删除",
+  "create": "新增"
 }

+ 17 - 1
packages/locales/src/langs/zh-CN/ui.json

@@ -1,7 +1,23 @@
 {
   "formRules": {
     "required": "请输入{0}",
-    "selectRequired": "请选择{0}"
+    "selectRequired": "请选择{0}",
+    "minLength": "{0}至少{1}个字符",
+    "maxLength": "{0}最多{1}个字符",
+    "length": "{0}长度必须为{1}个字符"
+  },
+  "actionTitle": {
+    "edit": "修改{0}",
+    "create": "新增{0}",
+    "delete": "删除{0}",
+    "view": "查看{0}"
+  },
+  "actionMessage": {
+    "deleteConfirm": "确定删除 {0} 吗?",
+    "deleting": "正在删除 {0} ...",
+    "deleteSuccess": "{0} 删除成功",
+    "operationSuccess": "操作成功",
+    "operationFailed": "操作失败"
   },
   "placeholder": {
     "input": "请输入",

+ 175 - 3
playground/src/adapter/vxe-table.ts

@@ -1,8 +1,16 @@
+import type { Recordable } from '@vben/types';
+
 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 { objectOmit } from '@vueuse/core';
+import { Button, Image, Popconfirm, Tag } from 'ant-design-vue';
 
-import { Button, Image } from 'ant-design-vue';
+import { $t } from '#/locales';
 
 import { useVbenForm } from './form';
 
@@ -26,7 +34,7 @@ setupVbenVxeTable({
           response: {
             result: 'items',
             total: 'total',
-            list: 'items',
+            list: '',
           },
           showActiveMsg: true,
           showResponseMsg: false,
@@ -37,6 +45,15 @@ setupVbenVxeTable({
       },
     });
 
+    /**
+     * 解决vxeTable在热更新时可能会出错的问题
+     */
+    vxeUI.renderer.forEach((_item, key) => {
+      if (key.startsWith('Cell')) {
+        vxeUI.renderer.delete(key);
+      }
+    });
+
     // 表格配置项可以用 cellRender: { name: 'CellImage' },
     vxeUI.renderer.add('CellImage', {
       renderTableDefault(_renderOpts, params) {
@@ -57,6 +74,155 @@ setupVbenVxeTable({
       },
     });
 
+    // 单元格渲染: Tag
+    vxeUI.renderer.add('CellTag', {
+      renderTableDefault({ options, props }, { column, row }) {
+        const value = row[column.field];
+        const tagOptions = options || [
+          { color: 'success', label: $t('common.enabled'), value: 1 },
+          { color: 'error', label: $t('common.disabled'), value: 0 },
+        ];
+        const tagItem = tagOptions.find((item) => item.value === value);
+        return h(
+          Tag,
+          {
+            ...props,
+            ...objectOmit(tagItem, ['label']),
+          },
+          { default: () => tagItem?.label ?? value },
+        );
+      },
+    });
+
+    /**
+     * 注册表格的操作按钮渲染器
+     */
+    vxeUI.renderer.add('CellOperation', {
+      renderTableDefault({ attrs, options, props }, { column, row }) {
+        const defaultProps = { size: 'small', type: 'link', ...props };
+        let align = 'end';
+        switch (column.align) {
+          case 'center': {
+            align = 'center';
+            break;
+          }
+          case 'left': {
+            align = 'start';
+            break;
+          }
+          default: {
+            align = 'end';
+            break;
+          }
+        }
+        const presets: Recordable<Recordable<any>> = {
+          delete: {
+            danger: true,
+            text: $t('common.delete'),
+          },
+          edit: {
+            text: $t('common.edit'),
+          },
+        };
+        const operations: Array<Recordable<any>> = (
+          options || ['edit', 'delete']
+        )
+          .map((opt) => {
+            if (isString(opt)) {
+              return presets[opt]
+                ? { code: opt, ...presets[opt], ...defaultProps }
+                : {
+                    code: opt,
+                    text: $te(`common.${opt}`) ? $t(`common.${opt}`) : opt,
+                    ...defaultProps,
+                  };
+            } else {
+              return { ...defaultProps, ...presets[opt.code], ...opt };
+            }
+          })
+          .map((opt) => {
+            const optBtn: Recordable<any> = {};
+            Object.keys(opt).forEach((key) => {
+              optBtn[key] = isFunction(opt[key]) ? opt[key](row) : opt[key];
+            });
+            return optBtn;
+          })
+          .filter((opt) => opt.show !== false);
+
+        function renderBtn(opt: Recordable<any>, listen = true) {
+          return h(
+            Button,
+            {
+              ...props,
+              ...opt,
+              icon: undefined,
+              onClick: listen
+                ? () =>
+                    attrs?.onClick?.({
+                      code: opt.code,
+                      row,
+                    })
+                : undefined,
+            },
+            {
+              default: () => {
+                const content = [];
+                if (opt.icon) {
+                  content.push(
+                    h(IconifyIcon, { class: 'size-5', icon: opt.icon }),
+                  );
+                }
+                content.push(opt.text);
+                return content;
+              },
+            },
+          );
+        }
+
+        function renderConfirm(opt: Recordable<any>) {
+          return h(
+            Popconfirm,
+            {
+              placement: 'topLeft',
+              title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']),
+              ...props,
+              ...opt,
+              icon: undefined,
+              onConfirm: () => {
+                attrs?.onClick?.({
+                  code: opt.code,
+                  row,
+                });
+              },
+            },
+            {
+              default: () => renderBtn({ ...opt }, false),
+              description: () =>
+                h(
+                  'div',
+                  { class: 'truncate' },
+                  $t('ui.actionMessage.deleteConfirm', [
+                    row[attrs?.nameField || 'name'],
+                  ]),
+                ),
+            },
+          );
+        }
+
+        const btns = operations.map((opt) =>
+          opt.code === 'delete' ? renderConfirm(opt) : renderBtn(opt),
+        );
+        return h(
+          'div',
+          {
+            class: 'flex table-operations',
+            style: { justifyContent: align },
+          },
+          btns,
+        );
+      },
+    });
+
     // 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
     // vxeUI.formats.add
   },
@@ -64,5 +230,11 @@ setupVbenVxeTable({
 });
 
 export { useVbenVxeGrid };
-
+export type OnActionClickParams<T = Recordable<any>> = {
+  code: string;
+  row: T;
+};
+export type OnActionClickFn<T = Recordable<any>> = (
+  params: OnActionClickParams<T>,
+) => void;
 export type * from '@vben/plugins/vxe-table';

+ 54 - 0
playground/src/api/system/dept.ts

@@ -0,0 +1,54 @@
+import { requestClient } from '#/api/request';
+
+export namespace SystemDeptApi {
+  export interface SystemDept {
+    [key: string]: any;
+    children?: SystemDept[];
+    id: string;
+    name: string;
+    remark?: string;
+    status: 0 | 1;
+  }
+}
+
+/**
+ * 获取部门列表数据
+ */
+async function getDeptList() {
+  return requestClient.get<Array<SystemDeptApi.SystemDept>>(
+    '/system/dept/list',
+  );
+}
+
+/**
+ * 创建部门
+ * @param data 部门数据
+ */
+async function createDept(
+  data: Omit<SystemDeptApi.SystemDept, 'children' | 'id'>,
+) {
+  return requestClient.post('/system/dept', data);
+}
+
+/**
+ * 更新部门
+ *
+ * @param id 部门 ID
+ * @param data 部门数据
+ */
+async function updateDept(
+  id: string,
+  data: Omit<SystemDeptApi.SystemDept, 'children' | 'id'>,
+) {
+  return requestClient.put(`/system/dept/${id}`, data);
+}
+
+/**
+ * 删除部门
+ * @param id 部门 ID
+ */
+async function deleteDept(id: string) {
+  return requestClient.delete(`/system/dept/${id}`);
+}
+
+export { createDept, deleteDept, getDeptList, updateDept };

+ 13 - 0
playground/src/locales/langs/en-US/system.json

@@ -0,0 +1,13 @@
+{
+  "title": "System Management",
+  "dept": {
+    "name": "Department",
+    "title": "Department Management",
+    "deptName": "Department Name",
+    "status": "Status",
+    "createTime": "Create Time",
+    "remark": "Remark",
+    "operation": "Operation",
+    "parentDept": "Parent Department"
+  }
+}

+ 13 - 0
playground/src/locales/langs/zh-CN/system.json

@@ -0,0 +1,13 @@
+{
+  "dept": {
+    "createTime": "创建时间",
+    "deptName": "部门名称",
+    "name": "部门",
+    "operation": "操作",
+    "parentDept": "上级部门",
+    "remark": "备注",
+    "status": "状态",
+    "title": "部门管理"
+  },
+  "title": "系统管理"
+}

+ 28 - 0
playground/src/router/routes/modules/system.ts

@@ -0,0 +1,28 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+import { $t } from '#/locales';
+
+const routes: RouteRecordRaw[] = [
+  {
+    meta: {
+      icon: 'ion:settings-outline',
+      order: 9997,
+      title: $t('system.title'),
+    },
+    name: 'System',
+    path: '/system',
+    children: [
+      {
+        path: '/system/dept',
+        name: 'SystemDept',
+        meta: {
+          icon: 'charm:organisation',
+          title: $t('system.dept.title'),
+        },
+        component: () => import('#/views/system/dept/list.vue'),
+      },
+    ],
+  },
+];
+
+export default routes;

+ 135 - 0
playground/src/views/system/dept/data.ts

@@ -0,0 +1,135 @@
+import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
+
+import type { VbenFormSchema } from '#/adapter/form';
+import type { OnActionClickFn } from '#/adapter/vxe-table';
+import type { SystemDeptApi } from '#/api/system/dept';
+
+import { z } from '#/adapter/form';
+import { getDeptList } from '#/api/system/dept';
+import { $t } from '#/locales';
+
+/**
+ * 获取编辑表单的字段配置。如果没有使用多语言,可以直接export一个数组常量
+ */
+export function useSchema(): VbenFormSchema[] {
+  return [
+    {
+      component: 'Input',
+      fieldName: 'name',
+      label: $t('system.dept.deptName'),
+      rules: z
+        .string()
+        .min(2, $t('ui.formRules.minLength', [$t('system.dept.deptName'), 2]))
+        .max(
+          20,
+          $t('ui.formRules.maxLength', [$t('system.dept.deptName'), 20]),
+        ),
+    },
+    {
+      component: 'ApiTreeSelect',
+      componentProps: {
+        allowClear: true,
+        api: getDeptList,
+        class: 'w-full',
+        labelField: 'name',
+        valueField: 'id',
+        childrenField: 'children',
+      },
+      fieldName: 'pid',
+      label: $t('system.dept.parentDept'),
+    },
+    {
+      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.dept.status'),
+    },
+    {
+      component: 'Textarea',
+      componentProps: {
+        maxLength: 50,
+        rows: 3,
+        showCount: true,
+      },
+      fieldName: 'remark',
+      label: $t('system.dept.remark'),
+      rules: z
+        .string()
+        .max(50, $t('ui.formRules.maxLength', [$t('system.dept.remark'), 50]))
+        .optional(),
+    },
+  ];
+}
+
+/**
+ * 获取表格列配置
+ * @description 使用函数的形式返回列数据而不是直接export一个Array常量,是为了响应语言切换时重新翻译表头
+ * @param onActionClick 表格操作按钮点击事件
+ */
+export function useColumns(
+  onActionClick?: OnActionClickFn<SystemDeptApi.SystemDept>,
+): VxeTableGridOptions<SystemDeptApi.SystemDept>['columns'] {
+  return [
+    {
+      align: 'left',
+      field: 'name',
+      fixed: 'left',
+      title: $t('system.dept.deptName'),
+      treeNode: true,
+      width: 150,
+    },
+    {
+      cellRender: { name: 'CellTag' },
+      field: 'status',
+      title: $t('system.dept.status'),
+      width: 100,
+    },
+    {
+      field: 'createTime',
+      title: $t('system.dept.createTime'),
+      width: 180,
+    },
+    {
+      field: 'remark',
+      title: $t('system.dept.remark'),
+    },
+    {
+      align: 'right',
+      cellRender: {
+        attrs: {
+          nameField: 'name',
+          nameTitle: $t('system.dept.name'),
+          onClick: onActionClick,
+        },
+        name: 'CellOperation',
+        options: [
+          {
+            code: 'append',
+            text: '新增下级',
+          },
+          'edit', // 默认的编辑按钮
+          {
+            code: 'delete', // 默认的删除按钮
+            disabled: (row: SystemDeptApi.SystemDept) => {
+              return !!(row.children && row.children.length > 0);
+            },
+          },
+        ],
+      },
+      field: 'operation',
+      fixed: 'right',
+      headerAlign: 'center',
+      showOverflow: false,
+      title: $t('system.dept.operation'),
+      width: 200,
+    },
+  ];
+}

+ 143 - 0
playground/src/views/system/dept/list.vue

@@ -0,0 +1,143 @@
+<script lang="ts" setup>
+import type {
+  OnActionClickParams,
+  VxeTableGridOptions,
+} from '#/adapter/vxe-table';
+import type { SystemDeptApi } from '#/api/system/dept';
+
+import { Page, useVbenModal } from '@vben/common-ui';
+import { Plus } from '@vben/icons';
+
+import { Button, message } from 'ant-design-vue';
+
+import { useVbenVxeGrid } from '#/adapter/vxe-table';
+import { deleteDept, getDeptList } from '#/api/system/dept';
+import { $t } from '#/locales';
+
+import { useColumns } from './data';
+import Form from './modules/form.vue';
+
+const [FormModal, formModalApi] = useVbenModal({
+  connectedComponent: Form,
+  destroyOnClose: true,
+});
+
+/**
+ * 编辑部门
+ * @param row
+ */
+function onEdit(row: SystemDeptApi.SystemDept) {
+  formModalApi.setData(row).open();
+}
+
+/**
+ * 添加下级部门
+ * @param row
+ */
+function onAppend(row: SystemDeptApi.SystemDept) {
+  formModalApi.setData({ pid: row.id }).open();
+}
+
+/**
+ * 创建新部门
+ */
+function onCreate() {
+  formModalApi.setData(null).open();
+}
+
+/**
+ * 删除部门
+ * @param row
+ */
+function onDelete(row: SystemDeptApi.SystemDept) {
+  const hideLoading = message.loading({
+    content: $t('ui.actionMessage.deleting', [row.name]),
+    duration: 0,
+    key: 'action_process_msg',
+  });
+  deleteDept(row.id)
+    .then(() => {
+      message.success({
+        content: $t('ui.actionMessage.deleteSuccess', [row.name]),
+        key: 'action_process_msg',
+      });
+      refreshGrid();
+    })
+    .catch(() => {
+      hideLoading();
+    });
+}
+
+/**
+ * 表格操作按钮的回调函数
+ */
+function onActionClick({
+  code,
+  row,
+}: OnActionClickParams<SystemDeptApi.SystemDept>) {
+  switch (code) {
+    case 'append': {
+      onAppend(row);
+      break;
+    }
+    case 'delete': {
+      onDelete(row);
+      break;
+    }
+    case 'edit': {
+      onEdit(row);
+      break;
+    }
+  }
+}
+
+const [Grid, gridApi] = useVbenVxeGrid({
+  gridEvents: {},
+  gridOptions: {
+    columns: useColumns(onActionClick),
+    height: 'auto',
+    keepSource: true,
+    pagerConfig: {
+      enabled: false,
+    },
+    proxyConfig: {
+      ajax: {
+        query: async (_params) => {
+          return await getDeptList();
+        },
+      },
+    },
+    toolbarConfig: {
+      custom: true,
+      export: false,
+      refresh: { code: 'query' },
+      zoom: true,
+    },
+    treeConfig: {
+      parentField: 'pid',
+      rowField: 'id',
+      transform: false,
+    },
+  } as VxeTableGridOptions,
+});
+
+/**
+ * 刷新表格
+ */
+function refreshGrid() {
+  gridApi.query();
+}
+</script>
+<template>
+  <Page auto-content-height>
+    <FormModal @success="refreshGrid" />
+    <Grid table-title="部门列表">
+      <template #toolbar-tools>
+        <Button type="primary" @click="onCreate">
+          <Plus class="size-5" />
+          {{ $t('ui.actionTitle.create', [$t('system.dept.name')]) }}
+        </Button>
+      </template>
+    </Grid>
+  </Page>
+</template>

+ 78 - 0
playground/src/views/system/dept/modules/form.vue

@@ -0,0 +1,78 @@
+<script lang="ts" setup>
+import type { SystemDeptApi } from '#/api/system/dept';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+
+import { Button } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import { createDept, updateDept } from '#/api/system/dept';
+import { $t } from '#/locales';
+
+import { useSchema } from '../data';
+
+const emit = defineEmits(['success']);
+const formData = ref<SystemDeptApi.SystemDept>();
+const getTitle = computed(() => {
+  return formData.value?.id
+    ? $t('ui.actionTitle.edit', [$t('system.dept.name')])
+    : $t('ui.actionTitle.create', [$t('system.dept.name')]);
+});
+
+const [Form, formApi] = useVbenForm({
+  layout: 'vertical',
+  schema: useSchema(),
+  showDefaultActions: false,
+});
+
+function resetForm() {
+  formApi.resetForm();
+  formApi.setValues(formData.value || {});
+}
+
+const [Modal, modalApi] = useVbenModal({
+  async onConfirm() {
+    const { valid } = await formApi.validate();
+    if (valid) {
+      modalApi.lock();
+      const data = formApi.getValues();
+      try {
+        await (formData.value?.id
+          ? updateDept(formData.value.id, data)
+          : createDept(data));
+        modalApi.close();
+        emit('success');
+      } finally {
+        modalApi.lock(false);
+      }
+    }
+  },
+  onOpenChange(isOpen) {
+    if (isOpen) {
+      const data = modalApi.getData<SystemDeptApi.SystemDept>();
+      if (data) {
+        if (data.pid === 0) {
+          data.pid = undefined;
+        }
+        formData.value = data;
+        formApi.setValues(formData.value);
+      }
+    }
+  },
+});
+</script>
+
+<template>
+  <Modal :title="getTitle">
+    <Form class="mx-4" />
+    <template #prepend-footer>
+      <div class="flex-auto">
+        <Button type="primary" danger @click="resetForm">
+          {{ $t('common.reset') }}
+        </Button>
+      </div>
+    </template>
+  </Modal>
+</template>