2026-09-02 5dbd5f73288a1dd739de5d2383678e19ba2aece9
feat(hrm): 添加员工合同导入功能

- 新增合同导入结果接口定义,包含新增、更新、失败统计
- 添加下载导入模板和导入合同的API接口
- 实现合同导入表单组件,支持文件上传和进度显示
- 在合同管理页面添加导入按钮和模态框
- 配置导入时覆盖更新选项和导入结果显示
- 更新开发服务器代理地址配置
已添加1个文件
已修改4个文件
203 ■■■■■ 文件已修改
src/api/hrm/employee/contract/index.ts 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/contract/index.vue 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/contract/modules/import-form.vue 153 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/employee/index.vue 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vite.config.ts 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/employee/contract/index.ts
@@ -85,6 +85,16 @@
    terminateDate: string;
    terminateReason: string;
  }
  /** åˆåŒå¯¼å…¥ç»“æžœ */
  export interface ImportResult {
    /** æ–°å¢žæˆåŠŸçš„å‘˜å·¥åæ•°ç»„ */
    createNames?: string[];
    /** è¦†ç›–更新成功的员工名数组 */
    updateNames?: string[];
    /** å¯¼å…¥å¤±è´¥é›†åˆï¼Œkey ä¸ºå‘˜å·¥åï¼Œvalue ä¸ºå¤±è´¥åŽŸå›  */
    failureNames?: Record<string, string>;
  }
}
/** èŽ·å–å‘˜å·¥åˆåŒåˆ†é¡µåˆ—è¡¨ */
@@ -128,3 +138,19 @@
export function getExpiringContractCount() {
  return requestClient.get<number>('/hrm/employee-contract/expiring-count');
}
/** ä¸‹è½½å‘˜å·¥åˆåŒå¯¼å…¥æ¨¡æ¿ */
export function importEmployeeContractTemplate() {
  return requestClient.download('/hrm/employee-contract/import-template');
}
/** å¯¼å…¥å‘˜å·¥åˆåŒ */
export function importEmployeeContract(
  file: File,
  updateSupport: boolean,
) {
  return requestClient.upload<HrmEmployeeContractApi.ImportResult>(
    '/hrm/employee-contract/import',
    { file, updateSupport },
  );
}
src/views/hrm/employee/contract/index.vue
@@ -15,10 +15,16 @@
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ImportForm from './modules/import-form.vue';
import Terminate from './modules/terminate.vue';
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportForm,
  destroyOnClose: true,
});
@@ -35,6 +41,11 @@
/** æ–°å¢žåˆåŒ */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** å¯¼å…¥åˆåŒ */
function handleImport() {
  importModalApi.open();
}
/** ç¼–辑合同 */
@@ -97,6 +108,7 @@
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ImportModal @success="handleRefresh" />
    <TerminateModal @success="handleRefresh" />
    <Grid table-title="员工合同列表">
      <template #parentNo="{ row }">
@@ -116,6 +128,13 @@
              auth: ['hrm:employee-contract:create'],
              onClick: handleCreate,
            },
            {
              label: '导入',
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['hrm:employee-contract:import'],
              onClick: handleImport,
            },
          ]"
        />
      </template>
src/views/hrm/employee/contract/modules/import-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,153 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import type { HrmEmployeeContractApi } from '#/api/hrm/employee/contract';
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 {
  importEmployeeContract,
  importEmployeeContractTemplate,
} from '#/api/hrm/employee/contract';
const emit = defineEmits(['success']);
const fileList = ref<FileType[]>([]);
const updateSupport = ref(false);
const importResult = ref<HrmEmployeeContractApi.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 importEmployeeContract(
        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 importEmployeeContractTemplate();
  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-[720px]">
    <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 rounded-md bg-gray-50 p-2 text-xs leading-5 text-gray-500">
        <p>仅允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶ï¼›æ¨¡æ¿ä¸­â€œå‘˜å·¥å§“名/合同类型/合同期限类型”按名称或中文填写(合同类型如:劳动合同、劳务合同)。</p>
        <p>导入判定:按“员工姓名 + åˆåŒç±»åž‹ + åˆåŒå¼€å§‹æ—¥æœŸâ€è¯†åˆ«ã€‚未勾选“已存在时更新”时,命中已存在合同的行会导入失败并提示;勾选后则覆盖更新该行(保留原合同编号),未命中的行一律新增。</p>
      </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>
src/views/hrm/employee/index.vue
@@ -216,7 +216,7 @@
        />
      </template>
    </Grid>
  </Page>
  <!-- å¯¼å‡ºè¿›åº¦æ¡é®ç½© -->
  <div
    v-if="exporting"
@@ -229,4 +229,5 @@
      <Progress :percent="exportPercent" status="active" />
    </div>
  </div>
  </Page>
</template>
vite.config.ts
@@ -83,7 +83,7 @@
        proxy: {
          "/admin-api": {
            changeOrigin: true,
            target: "http://36.213.90.123:9500",
            target: "http://192.168.0.10:48080",
            ws: true,
          },
        },