feat(hrm): 添加员工个人信息字段及导入导出功能
- 在员工表单中添加民族、婚姻状态、年龄、学历、政治面貌字段
- 在员工详情页面展示新增的个人信息字段
- 在员工表格中显示民族、年龄、学历列
- 扩展员工API接口定义,添加新的个人信息字段
- 实现员工导入导出功能,包括导入模板下载
- 添加员工导入表单组件,支持Excel文件导入
- 实现导出进度条显示功能
- 添加导入结果统计和错误信息展示
| | |
| | | phone?: string; |
| | | email?: string; |
| | | idCard?: string; |
| | | nation?: string; |
| | | marriageStatus?: string; |
| | | age?: number; |
| | | education?: string; |
| | | politicalStatus?: string; |
| | | hireDate?: string; |
| | | regularDate?: string; |
| | | leaveDate?: string; |
| | |
| | | deptName?: string; |
| | | postName?: string; |
| | | } |
| | | |
| | | /** å工导å
¥ç»æ */ |
| | | export interface ImportResult { |
| | | /** å建æåçåå·¥åæ°ç» */ |
| | | createNames?: string[]; |
| | | /** æ´æ°æåçåå·¥åæ°ç» */ |
| | | updateNames?: string[]; |
| | | /** 导å
¥å¤±è´¥éåï¼key 为åå·¥åï¼value 为失败åå */ |
| | | failureNames?: Record<string, string>; |
| | | } |
| | | } |
| | | |
| | | /** æ¥è¯¢åå·¥å页å表 */ |
| | |
| | | return requestClient.download('/hrm/employee/export-excel', { params }); |
| | | } |
| | | |
| | | /** ä¸è½½å工导å
¥æ¨¡æ¿ */ |
| | | export function importEmployeeTemplate() { |
| | | return requestClient.download('/hrm/employee/import-template'); |
| | | } |
| | | |
| | | /** 导å
¥åå·¥ */ |
| | | export function importEmployee(file: File, updateSupport: boolean) { |
| | | return requestClient.upload<HrmEmployeeApi.ImportResult>( |
| | | '/hrm/employee/import', |
| | | { file, updateSupport }, |
| | | ); |
| | | } |
| | | |
| | | /** å 餿è²ç»å */ |
| | | export function deleteEducation(id: number) { |
| | | return requestClient.delete(`/hrm/employee/education/delete?id=${id}`); |
| | |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'nation', |
| | | label: 'æ°æ', |
| | | component: 'Input', |
| | | componentProps: { |
| | | placeholder: '请è¾å
¥æ°æ', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'marriageStatus', |
| | | label: 'å©å§»ç¶æ', |
| | | component: 'Input', |
| | | componentProps: { |
| | | placeholder: '请è¾å
¥å©å§»ç¶æ', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'age', |
| | | label: 'å¹´é¾', |
| | | component: 'InputNumber', |
| | | componentProps: { |
| | | placeholder: '请è¾å
¥å¹´é¾', |
| | | min: 0, |
| | | max: 150, |
| | | style: { width: '100%' }, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'education', |
| | | label: 'å¦å', |
| | | component: 'Input', |
| | | componentProps: { |
| | | placeholder: '请è¾å
¥å¦å', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'politicalStatus', |
| | | label: 'æ¿æ²»é¢è²', |
| | | component: 'Input', |
| | | componentProps: { |
| | | placeholder: '请è¾å
¥æ¿æ²»é¢è²', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'annualLeaveBalance', |
| | | label: 'å¹´åä½é¢', |
| | | component: 'InputNumber', |
| | |
| | | { field: 'phone', title: 'ææºå·ç ', minWidth: 120 }, |
| | | { field: 'deptName', title: 'é¨é¨', minWidth: 120 }, |
| | | { field: 'postName', title: 'å²ä½', minWidth: 120 }, |
| | | { field: 'nation', title: 'æ°æ', width: 90 }, |
| | | { field: 'age', title: 'å¹´é¾', width: 70 }, |
| | | { field: 'education', title: 'å¦å', width: 90 }, |
| | | { |
| | | field: 'employeeStatus', |
| | | title: 'åå·¥ç¶æ', |
| | |
| | | import { Page, useVbenModal } from '#/packages/effects/common-ui/src'; |
| | | import { downloadFileFromBlobPart } from '#/packages/utils/src'; |
| | | |
| | | import { Button, message, Tag } from 'ant-design-vue'; |
| | | import { Button, message, Progress, Tag } from 'ant-design-vue'; |
| | | |
| | | import { ref } from 'vue'; |
| | | |
| | | import { DICT_TYPE } from '#/packages/constants/src'; |
| | | import { getDictLabel } from '#/packages/effects/hooks/src'; |
| | |
| | | |
| | | import { useGridColumns, useGridFormSchema } from './data'; |
| | | import Form from './modules/form.vue'; |
| | | import ImportForm from './modules/import-form.vue'; |
| | | |
| | | const [FormModal, formModalApi] = useVbenModal({ |
| | | connectedComponent: Form, |
| | | destroyOnClose: true, |
| | | }); |
| | | |
| | | const [ImportModal, importModalApi] = useVbenModal({ |
| | | connectedComponent: ImportForm, |
| | | destroyOnClose: true, |
| | | }); |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | const exporting = ref(false); |
| | | const exportPercent = ref(0); |
| | | let exportTimer: ReturnType<typeof setInterval> | undefined; |
| | | |
| | | /** 模æè¿åº¦æ¡ï¼è¯·æ±æé´ä» 0 èµ°å° 90% */ |
| | | function startExportProgress() { |
| | | exportPercent.value = 0; |
| | | exportTimer = setInterval(() => { |
| | | if (exportPercent.value < 90) { |
| | | exportPercent.value = Math.min( |
| | | 90, |
| | | exportPercent.value + Math.random() * 12, |
| | | ); |
| | | } |
| | | }, 300); |
| | | } |
| | | |
| | | function stopExportProgress() { |
| | | if (exportTimer) { |
| | | clearInterval(exportTimer); |
| | | exportTimer = undefined; |
| | | } |
| | | } |
| | | |
| | | function delay(ms: number) { |
| | | return new Promise((resolve) => setTimeout(resolve, ms)); |
| | | } |
| | | |
| | | /** 导åºåå·¥ */ |
| | | async function handleExport() { |
| | | const data = await exportEmployee(await gridApi.formApi.getValues()); |
| | | downloadFileFromBlobPart({ fileName: 'åå·¥å表.xls', source: data }); |
| | | if (exporting.value) return; |
| | | exporting.value = true; |
| | | startExportProgress(); |
| | | try { |
| | | const data = await exportEmployee(await gridApi.formApi.getValues()); |
| | | // 宿ï¼è¿åº¦æ¡èµ°æ»¡å触åä¸è½½ |
| | | stopExportProgress(); |
| | | exportPercent.value = 100; |
| | | await delay(500); |
| | | downloadFileFromBlobPart({ fileName: 'åå·¥å表.xls', source: data }); |
| | | } finally { |
| | | stopExportProgress(); |
| | | exporting.value = false; |
| | | } |
| | | } |
| | | |
| | | /** 导å
¥åå·¥ */ |
| | | function handleImport() { |
| | | importModalApi.open(); |
| | | } |
| | | |
| | | const [Grid, gridApi] = useVbenVxeGrid({ |
| | |
| | | |
| | | <template> |
| | | <Page auto-content-height> |
| | | <ImportModal @success="handleRefresh" /> |
| | | <FormModal @success="handleRefresh" /> |
| | | <Grid table-title="åå·¥å表"> |
| | | <template #toolbar-tools> |
| | |
| | | icon: ACTION_ICON.ADD, |
| | | auth: ['hrm:employee:create'], |
| | | onClick: handleCreate, |
| | | }, |
| | | { |
| | | label: '导å
¥', |
| | | type: 'primary', |
| | | icon: ACTION_ICON.UPLOAD, |
| | | auth: ['hrm:employee:import'], |
| | | onClick: handleImport, |
| | | }, |
| | | { |
| | | label: $t('ui.actionTitle.export'), |
| | |
| | | </template> |
| | | </Grid> |
| | | </Page> |
| | | <!-- 导åºè¿åº¦æ¡é®ç½© --> |
| | | <div |
| | | v-if="exporting" |
| | | class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40" |
| | | > |
| | | <div class="w-80 rounded-lg bg-white p-6 shadow-lg"> |
| | | <div class="mb-3 text-center text-sm text-gray-600"> |
| | | æ£å¨å¯¼åºåå·¥å表... |
| | | </div> |
| | | <Progress :percent="exportPercent" status="active" /> |
| | | </div> |
| | | </div> |
| | | </template> |
| | |
| | | <DescriptionsItem label="åå·¥ç¶æ"> |
| | | <Tag :color="employeeStatusColor">{{ employeeStatusLabel }}</Tag> |
| | | </DescriptionsItem> |
| | | <DescriptionsItem label="æ°æ">{{ employee.nation || '-' }}</DescriptionsItem> |
| | | <DescriptionsItem label="å©å§»ç¶æ">{{ employee.marriageStatus || '-' }}</DescriptionsItem> |
| | | <DescriptionsItem label="å¹´é¾">{{ employee.age ?? '-' }}</DescriptionsItem> |
| | | <DescriptionsItem label="å¦å">{{ employee.education || '-' }}</DescriptionsItem> |
| | | <DescriptionsItem label="æ¿æ²»é¢è²">{{ employee.politicalStatus || '-' }}</DescriptionsItem> |
| | | </Descriptions> |
| | | |
| | | <!-- å·¥ä½ä¿¡æ¯ --> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { FileType } from 'ant-design-vue/es/upload/interface'; |
| | | import type { HrmEmployeeApi } from '#/api/hrm/employee'; |
| | | |
| | | import { computed, ref } from 'vue'; |
| | | |
| | | import { useVbenModal } from '#/packages/effects/common-ui/src'; |
| | | import { downloadFileFromBlobPart } from '#/packages/utils/src'; |
| | | |
| | | import { Button, message, Progress, Switch, Upload } from 'ant-design-vue'; |
| | | |
| | | import { importEmployee, importEmployeeTemplate } from '#/api/hrm/employee'; |
| | | |
| | | const emit = defineEmits(['success']); |
| | | |
| | | const fileList = ref<FileType[]>([]); |
| | | const updateSupport = ref(false); |
| | | const importResult = ref<HrmEmployeeApi.ImportResult>(); |
| | | const importing = ref(false); |
| | | const progressPercent = ref(0); |
| | | let progressTimer: ReturnType<typeof setInterval> | undefined; |
| | | |
| | | const [Modal, modalApi] = useVbenModal({ |
| | | async onConfirm() { |
| | | if (fileList.value.length === 0) { |
| | | message.warning('è¯·éæ©è¦å¯¼å
¥çæä»¶'); |
| | | return; |
| | | } |
| | | if (importing.value) return; |
| | | modalApi.lock(); |
| | | importing.value = true; |
| | | startMockProgress(); |
| | | try { |
| | | importResult.value = await importEmployee( |
| | | fileList.value[0] as File, |
| | | updateSupport.value, |
| | | ); |
| | | // 宿ï¼è¿åº¦æ¡èµ°æ»¡ |
| | | stopMockProgress(); |
| | | progressPercent.value = 100; |
| | | await delay(500); |
| | | message.success('导å
¥å®æ'); |
| | | emit('success'); |
| | | } finally { |
| | | stopMockProgress(); |
| | | importing.value = false; |
| | | modalApi.unlock(); |
| | | } |
| | | }, |
| | | }); |
| | | |
| | | /** 模æè¿åº¦æ¡ï¼è¯·æ±æé´ä» 0 èµ°å° 90% */ |
| | | function startMockProgress() { |
| | | progressPercent.value = 0; |
| | | progressTimer = setInterval(() => { |
| | | if (progressPercent.value < 90) { |
| | | progressPercent.value = Math.min( |
| | | 90, |
| | | progressPercent.value + Math.random() * 12, |
| | | ); |
| | | } |
| | | }, 300); |
| | | } |
| | | |
| | | function stopMockProgress() { |
| | | if (progressTimer) { |
| | | clearInterval(progressTimer); |
| | | progressTimer = undefined; |
| | | } |
| | | } |
| | | |
| | | function delay(ms: number) { |
| | | return new Promise((resolve) => setTimeout(resolve, ms)); |
| | | } |
| | | |
| | | function beforeUpload(file: FileType) { |
| | | fileList.value = [file]; |
| | | importResult.value = undefined; |
| | | return false; |
| | | } |
| | | |
| | | function handleRemove() { |
| | | fileList.value = []; |
| | | importResult.value = undefined; |
| | | } |
| | | |
| | | async function handleDownloadTemplate() { |
| | | const data = await importEmployeeTemplate(); |
| | | downloadFileFromBlobPart({ fileName: 'å工导å
¥æ¨¡æ¿.xls', source: data }); |
| | | } |
| | | |
| | | const createCount = computed(() => importResult.value?.createNames?.length ?? 0); |
| | | const updateCount = computed(() => importResult.value?.updateNames?.length ?? 0); |
| | | const failureCount = computed( |
| | | () => Object.keys(importResult.value?.failureNames ?? {}).length, |
| | | ); |
| | | </script> |
| | | |
| | | <template> |
| | | <Modal title="导å
¥åå·¥" class="w-[640px]"> |
| | | <div class="mx-4"> |
| | | <Upload |
| | | :max-count="1" |
| | | accept=".xls,.xlsx" |
| | | :file-list="fileList" |
| | | :before-upload="beforeUpload" |
| | | @remove="handleRemove" |
| | | > |
| | | <Button type="primary">éæ© Excel æä»¶</Button> |
| | | </Upload> |
| | | <div class="mt-4"> |
| | | <span class="mr-2">ææºå·å·²å卿¶æ´æ°å工信æ¯ï¼</span> |
| | | <Switch v-model:checked="updateSupport" /> |
| | | </div> |
| | | <div class="mt-2 text-gray-500"> |
| | | ä»
å
许导å
¥ xlsãxlsx æ ¼å¼æä»¶ï¼æ¨¡æ¿ä¸"é¨é¨åç§°/å²ä½åç§°/ç¨æ·è§è²"å¡«ååç§°ï¼è§è²å¤ä¸ªç¨éå·åé |
| | | </div> |
| | | <!-- 导å
¥è¿åº¦æ¡ --> |
| | | <div v-if="importing" class="mt-4"> |
| | | <div class="mb-2 text-sm text-gray-600">æ£å¨å¯¼å
¥åå·¥æ°æ®ï¼è¯·ç¨å...</div> |
| | | <Progress :percent="progressPercent" status="active" /> |
| | | </div> |
| | | <!-- 导å
¥ç»æ --> |
| | | <div |
| | | v-if="importResult" |
| | | class="mt-4 rounded-md border border-gray-200 bg-gray-50 p-3" |
| | | > |
| | | <div class="mb-2 font-medium">导å
¥ç»æ</div> |
| | | <div class="mb-2 flex gap-4"> |
| | | <span class="text-green-600">æåå建ï¼{{ createCount }} 人</span> |
| | | <span class="text-blue-600">æåæ´æ°ï¼{{ updateCount }} 人</span> |
| | | <span class="text-red-600">失败ï¼{{ failureCount }} 人</span> |
| | | </div> |
| | | <div v-if="failureCount > 0" class="max-h-40 overflow-y-auto"> |
| | | <div |
| | | v-for="(reason, name) in importResult.failureNames" |
| | | :key="name" |
| | | class="text-sm leading-6 text-red-500" |
| | | > |
| | | {{ name }}ï¼{{ reason }} |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <template #prepend-footer> |
| | | <Button @click="handleDownloadTemplate">ä¸è½½å¯¼å
¥æ¨¡æ¿</Button> |
| | | </template> |
| | | </Modal> |
| | | </template> |