From d33261d0c243e053762917ebd025b15eec40d094 Mon Sep 17 00:00:00 2001 From: Netfan Date: Tue, 25 Feb 2025 19:47:45 +0800 Subject: [PATCH] chore: demo page for system/department (#5611) * feat: department management demo * perf: department page improve * feat: demo api middleware * fix: add losing import --- apps/backend-mock/api/system/dept/.post.ts | 15 ++ .../api/system/dept/[id].delete.ts | 15 ++ apps/backend-mock/api/system/dept/[id].put.ts | 15 ++ apps/backend-mock/api/system/dept/list.ts | 67 +++++++ apps/backend-mock/middleware/1.api.ts | 10 +- packages/@core/base/icons/src/lucide.ts | 1 + packages/locales/src/index.ts | 2 + packages/locales/src/langs/en-US/common.json | 8 +- packages/locales/src/langs/en-US/ui.json | 18 +- packages/locales/src/langs/zh-CN/common.json | 8 +- packages/locales/src/langs/zh-CN/ui.json | 18 +- playground/src/adapter/vxe-table.ts | 178 +++++++++++++++++- playground/src/api/system/dept.ts | 54 ++++++ .../src/locales/langs/en-US/system.json | 13 ++ .../src/locales/langs/zh-CN/system.json | 13 ++ .../src/router/routes/modules/system.ts | 28 +++ playground/src/views/system/dept/data.ts | 135 +++++++++++++ playground/src/views/system/dept/list.vue | 143 ++++++++++++++ .../src/views/system/dept/modules/form.vue | 78 ++++++++ 19 files changed, 811 insertions(+), 8 deletions(-) create mode 100644 apps/backend-mock/api/system/dept/.post.ts create mode 100644 apps/backend-mock/api/system/dept/[id].delete.ts create mode 100644 apps/backend-mock/api/system/dept/[id].put.ts create mode 100644 apps/backend-mock/api/system/dept/list.ts create mode 100644 playground/src/api/system/dept.ts create mode 100644 playground/src/locales/langs/en-US/system.json create mode 100644 playground/src/locales/langs/zh-CN/system.json create mode 100644 playground/src/router/routes/modules/system.ts create mode 100644 playground/src/views/system/dept/data.ts create mode 100644 playground/src/views/system/dept/list.vue create mode 100644 playground/src/views/system/dept/modules/form.vue diff --git a/apps/backend-mock/api/system/dept/.post.ts b/apps/backend-mock/api/system/dept/.post.ts new file mode 100644 index 00000000..c529ea1b --- /dev/null +++ b/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); +}); diff --git a/apps/backend-mock/api/system/dept/[id].delete.ts b/apps/backend-mock/api/system/dept/[id].delete.ts new file mode 100644 index 00000000..e48f051c --- /dev/null +++ b/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); +}); diff --git a/apps/backend-mock/api/system/dept/[id].put.ts b/apps/backend-mock/api/system/dept/[id].put.ts new file mode 100644 index 00000000..aa55c085 --- /dev/null +++ b/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); +}); diff --git a/apps/backend-mock/api/system/dept/list.ts b/apps/backend-mock/api/system/dept/list.ts new file mode 100644 index 00000000..304f07f4 --- /dev/null +++ b/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 = { + 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); +}); diff --git a/apps/backend-mock/middleware/1.api.ts b/apps/backend-mock/middleware/1.api.ts index a2e5a09b..f3e5212e 100644 --- a/apps/backend-mock/middleware/1.api.ts +++ b/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, '演示环境,禁止修改'); } }); diff --git a/packages/@core/base/icons/src/lucide.ts b/packages/@core/base/icons/src/lucide.ts index fe0e08fd..751a80ad 100644 --- a/packages/@core/base/icons/src/lucide.ts +++ b/packages/@core/base/icons/src/lucide.ts @@ -49,6 +49,7 @@ export { PanelRight, Pin, PinOff, + Plus, RotateCw, Search, SearchX, diff --git a/packages/locales/src/index.ts b/packages/locales/src/index.ts index eb2251ad..d4bfd819 100644 --- a/packages/locales/src/index.ts +++ b/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, diff --git a/packages/locales/src/langs/en-US/common.json b/packages/locales/src/langs/en-US/common.json index 01fc9ce7..304ee3e7 100644 --- a/packages/locales/src/langs/en-US/common.json +++ b/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" } diff --git a/packages/locales/src/langs/en-US/ui.json b/packages/locales/src/langs/en-US/ui.json index 163fc0c0..fcf3cc12 100644 --- a/packages/locales/src/langs/en-US/ui.json +++ b/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", diff --git a/packages/locales/src/langs/zh-CN/common.json b/packages/locales/src/langs/zh-CN/common.json index d0b5ffc0..86591f73 100644 --- a/packages/locales/src/langs/zh-CN/common.json +++ b/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": "新增" } diff --git a/packages/locales/src/langs/zh-CN/ui.json b/packages/locales/src/langs/zh-CN/ui.json index 2dda2f93..23cd2f20 100644 --- a/packages/locales/src/langs/zh-CN/ui.json +++ b/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": "请输入", diff --git a/playground/src/adapter/vxe-table.ts b/playground/src/adapter/vxe-table.ts index 8f68b15d..1f329881 100644 --- a/playground/src/adapter/vxe-table.ts +++ b/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 { Button, Image } from 'ant-design-vue'; +import { objectOmit } from '@vueuse/core'; +import { Button, Image, Popconfirm, Tag } 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> = { + delete: { + danger: true, + text: $t('common.delete'), + }, + edit: { + text: $t('common.edit'), + }, + }; + const operations: Array> = ( + 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 = {}; + 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, 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) { + 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> = { + code: string; + row: T; +}; +export type OnActionClickFn> = ( + params: OnActionClickParams, +) => void; export type * from '@vben/plugins/vxe-table'; diff --git a/playground/src/api/system/dept.ts b/playground/src/api/system/dept.ts new file mode 100644 index 00000000..ce2b0de8 --- /dev/null +++ b/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>( + '/system/dept/list', + ); +} + +/** + * 创建部门 + * @param data 部门数据 + */ +async function createDept( + data: Omit, +) { + return requestClient.post('/system/dept', data); +} + +/** + * 更新部门 + * + * @param id 部门 ID + * @param data 部门数据 + */ +async function updateDept( + id: string, + data: Omit, +) { + 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 }; diff --git a/playground/src/locales/langs/en-US/system.json b/playground/src/locales/langs/en-US/system.json new file mode 100644 index 00000000..20f9fd11 --- /dev/null +++ b/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" + } +} diff --git a/playground/src/locales/langs/zh-CN/system.json b/playground/src/locales/langs/zh-CN/system.json new file mode 100644 index 00000000..7fa1f1b1 --- /dev/null +++ b/playground/src/locales/langs/zh-CN/system.json @@ -0,0 +1,13 @@ +{ + "dept": { + "createTime": "创建时间", + "deptName": "部门名称", + "name": "部门", + "operation": "操作", + "parentDept": "上级部门", + "remark": "备注", + "status": "状态", + "title": "部门管理" + }, + "title": "系统管理" +} diff --git a/playground/src/router/routes/modules/system.ts b/playground/src/router/routes/modules/system.ts new file mode 100644 index 00000000..0de34651 --- /dev/null +++ b/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; diff --git a/playground/src/views/system/dept/data.ts b/playground/src/views/system/dept/data.ts new file mode 100644 index 00000000..48773625 --- /dev/null +++ b/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, +): VxeTableGridOptions['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, + }, + ]; +} diff --git a/playground/src/views/system/dept/list.vue b/playground/src/views/system/dept/list.vue new file mode 100644 index 00000000..1b4154d4 --- /dev/null +++ b/playground/src/views/system/dept/list.vue @@ -0,0 +1,143 @@ + + diff --git a/playground/src/views/system/dept/modules/form.vue b/playground/src/views/system/dept/modules/form.vue new file mode 100644 index 00000000..e57f115c --- /dev/null +++ b/playground/src/views/system/dept/modules/form.vue @@ -0,0 +1,78 @@ + + +