2026-08-10 536c6fbd1f42ea335abdce561451a6ee87d71465
feat(hrm): 添加员工个人信息字段及导入导出功能

- 在员工表单中添加民族、婚姻状态、年龄、学历、政治面貌字段
- 在员工详情页面展示新增的个人信息字段
- 在员工表格中显示民族、年龄、学历列
- 扩展员工API接口定义,添加新的个人信息字段
- 实现员工导入导出功能,包括导入模板下载
- 添加员工导入表单组件,支持Excel文件导入
- 实现导出进度条显示功能
- 添加导入结果统计和错误信息展示
已修改4个文件
已添加1个文件
307 ■■■■■ 文件已修改
src/api/hrm/employee/index.ts 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/data.ts 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/index.vue 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/modules/detail.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/modules/import-form.vue 149 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/employee/index.ts
@@ -18,6 +18,11 @@
    phone?: string;
    email?: string;
    idCard?: string;
    nation?: string;
    marriageStatus?: string;
    age?: number;
    education?: string;
    politicalStatus?: string;
    hireDate?: string;
    regularDate?: string;
    leaveDate?: string;
@@ -82,6 +87,16 @@
    deptName?: string;
    postName?: string;
  }
  /** å‘˜å·¥å¯¼å…¥ç»“æžœ */
  export interface ImportResult {
    /** åˆ›å»ºæˆåŠŸçš„å‘˜å·¥åæ•°ç»„ */
    createNames?: string[];
    /** æ›´æ–°æˆåŠŸçš„å‘˜å·¥åæ•°ç»„ */
    updateNames?: string[];
    /** å¯¼å…¥å¤±è´¥é›†åˆï¼Œkey ä¸ºå‘˜å·¥åï¼Œvalue ä¸ºå¤±è´¥åŽŸå›  */
    failureNames?: Record<string, string>;
  }
}
/** æŸ¥è¯¢å‘˜å·¥åˆ†é¡µåˆ—表 */
@@ -126,6 +141,19 @@
  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}`);
src/views/hrm/employee/data.ts
@@ -177,6 +177,49 @@
      },
    },
    {
      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',
@@ -311,6 +354,9 @@
    { 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: '员工状态',
src/views/hrm/employee/index.vue
@@ -5,7 +5,9 @@
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';
@@ -16,9 +18,15 @@
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,
});
@@ -57,10 +65,55 @@
  }
}
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({
@@ -95,6 +148,7 @@
<template>
  <Page auto-content-height>
    <ImportModal @success="handleRefresh" />
    <FormModal @success="handleRefresh" />
    <Grid table-title="员工列表">
      <template #toolbar-tools>
@@ -106,6 +160,13 @@
              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'),
@@ -156,4 +217,16 @@
      </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>
src/views/hrm/employee/modules/detail.vue
@@ -48,6 +48,11 @@
      <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>
    <!-- å·¥ä½œä¿¡æ¯ -->
src/views/hrm/employee/modules/import-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,149 @@
<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>