Parcourir la source

feat: 添加培训人员导入功能,支持文件上传和模板下载

xieyong il y a 1 semaine
Parent
commit
eeb3fd5d7e

+ 60 - 18
src/views/safetyTraining/onlineTrainingPlans/deliver.vue

@@ -127,13 +127,21 @@
 
           <div class="person-header">
             <span class="sub-title">培训人员</span>
-            <el-button
-              type="primary"
-              size="small"
-              icon="el-icon-plus"
-              @click="handleSelectPerson(index)"
-              >选择人员</el-button
-            >
+            <div class="person-actions">
+              <el-button
+                type="primary"
+                size="small"
+                icon="el-icon-plus"
+                @click="handleSelectPerson(index)"
+                >选择人员</el-button
+              >
+              <el-button
+                size="small"
+                icon="el-icon-upload2"
+                @click="handleImportPerson(index)"
+                >导入</el-button
+              >
+            </div>
           </div>
           <el-table
             :data="task.trainingPersons"
@@ -286,11 +294,13 @@
       @changeParent="handlePersonSelected"
       :disabled-list="currentDisabledList"
     />
+    <person-import ref="personImportRef" @confirm="handlePersonImported" />
   </ele-modal>
 </template>
 
 <script>
   import userSelect from '@/components/user-select/user-select.vue';
+  import personImport from './person-import-dialog.vue';
   import { getById, deliver } from '@/api/onlineTrainingPlan/index.js';
   import { getById as getPaperById } from '@/api/examPaper/examPaper.js';
   import { getAll as getAllTrainingMaterials } from '@/api/trainingMaterials/index.js';
@@ -332,7 +342,8 @@
 
   export default {
     components: {
-      userSelect
+      userSelect,
+      personImport
     },
     data() {
       return {
@@ -484,23 +495,45 @@
         this.currentTaskIndex = index;
         this.$refs.userSelectRef.open(null, index, true);
       },
+      handleImportPerson(index) {
+        this.currentTaskIndex = index;
+        const task = this.tasks[index];
+        this.$refs.personImportRef.open(
+          task ? task.trainingPersons.map((item) => item.id) : []
+        );
+      },
       handlePersonSelected(rows, index) {
         const taskIndex =
           index === undefined || index === null ? this.currentTaskIndex : index;
+        this.appendPersons(taskIndex, Array.isArray(rows) ? rows : [rows]);
+      },
+      handlePersonImported(persons, ignoredCount) {
+        const added = this.appendPersons(this.currentTaskIndex, persons);
+        if (added) {
+          this.$message.success(`成功导入 ${added} 名培训人员`);
+        }
+        if (ignoredCount) {
+          this.$message.warning(`已忽略 ${ignoredCount} 行未导入的数据`);
+        }
+      },
+      // 追加人员并按 id 去重,返回实际新增条数
+      appendPersons(taskIndex, list) {
         const task = this.tasks[taskIndex];
-        if (!task) return;
-        const list = Array.isArray(rows) ? rows : [rows];
+        if (!task || !list.length) return 0;
         const exists = new Set(task.trainingPersons.map((item) => item.id));
+        let added = 0;
         list.forEach((item) => {
-          if (!exists.has(item.id)) {
-            task.trainingPersons.push({
-              id: item.id,
-              name: item.name,
-              jobNumber: item.jobNumber,
-              groupName: item.groupName
-            });
-          }
+          if (exists.has(item.id)) return;
+          exists.add(item.id);
+          added++;
+          task.trainingPersons.push({
+            id: item.id,
+            name: item.name,
+            jobNumber: item.jobNumber,
+            groupName: item.groupName
+          });
         });
+        return added;
       },
       handleRemovePerson(taskIndex, personId) {
         const task = this.tasks[taskIndex];
@@ -663,6 +696,15 @@
     }
   }
 
+  .person-actions {
+    display: flex;
+    align-items: center;
+
+    ::v-deep .el-button + .el-button {
+      margin-left: 8px;
+    }
+  }
+
   .danger-text {
     color: #f56c6c;
   }

+ 310 - 0
src/views/safetyTraining/onlineTrainingPlans/person-import-dialog.vue

@@ -0,0 +1,310 @@
+<template>
+  <el-dialog
+    title="导入培训人员"
+    :visible.sync="visible"
+    width="760px"
+    append-to-body
+    :close-on-click-modal="false"
+  >
+    <el-alert
+      type="info"
+      :closable="false"
+      show-icon
+      title="按「工号」列导入,姓名可留空"
+      description="导入时以系统人员信息为准;模板第二行为示例行,填写前请先删除;工号建议设置为文本格式,避免前导 0 丢失。"
+    />
+
+    <div class="op-bar">
+      <el-button
+        size="small"
+        type="primary"
+        icon="el-icon-download"
+        @click="downloadTemplate"
+        >下载模板</el-button
+      >
+      <el-upload
+        action="#"
+        :show-file-list="false"
+        :http-request="handleFile"
+        :before-upload="beforeUpload"
+        accept=".xls,.xlsx"
+      >
+        <el-button size="small" icon="el-icon-folder-opened" :loading="parsing"
+          >选择文件</el-button
+        >
+      </el-upload>
+      <span class="file-name" v-if="fileName">{{ fileName }}</span>
+    </div>
+
+    <el-table
+      :data="rows"
+      v-loading="parsing"
+      border
+      size="small"
+      max-height="320"
+      empty-text="请选择要导入的 Excel 文件"
+      style="width: 100%"
+    >
+      <el-table-column type="index" label="序号" width="60" align="center" />
+      <el-table-column
+        prop="jobNumber"
+        label="工号"
+        align="center"
+        min-width="120"
+        show-overflow-tooltip
+      />
+      <el-table-column
+        prop="fileName"
+        label="文件姓名"
+        align="center"
+        min-width="100"
+        show-overflow-tooltip
+      />
+      <el-table-column
+        prop="userName"
+        label="导入姓名"
+        align="center"
+        min-width="100"
+        show-overflow-tooltip
+      />
+      <el-table-column
+        prop="groupName"
+        label="所属部门"
+        align="center"
+        min-width="150"
+        show-overflow-tooltip
+      />
+      <el-table-column label="结果" align="center" min-width="180">
+        <template slot-scope="scope">
+          <el-tag size="mini" :type="statusTagType(scope.row.status)">{{
+            statusText(scope.row.status)
+          }}</el-tag>
+          <span v-if="scope.row.reason" class="reason-text">
+            {{ scope.row.reason }}
+          </span>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <div slot="footer">
+      <span class="summary-text">
+        共 {{ rows.length }} 行,可导入 {{ matchedRows.length }} 人<template
+          v-if="ignoredRows.length"
+        >
+          ,已忽略 {{ ignoredRows.length }} 行</template
+        >
+      </span>
+      <el-button size="small" @click="visible = false">取 消</el-button>
+      <el-button
+        type="primary"
+        size="small"
+        :disabled="!matchedRows.length"
+        @click="confirm"
+        >确定导入</el-button
+      >
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+  import * as XLSX from 'xlsx';
+  import { getUserPage } from '@/api/system/organization';
+
+  // 单元格取值:统一转成去空格的字符串,数字型工号直接转字符串
+  function cellText(value) {
+    if (value === undefined || value === null) return '';
+    return String(value).trim();
+  }
+
+  export default {
+    data() {
+      return {
+        visible: false,
+        parsing: false,
+        fileName: '',
+        rows: [],
+        // 当前任务已选人员 id,用于标记重复
+        existIds: []
+      };
+    },
+    computed: {
+      matchedRows() {
+        return this.rows.filter((item) => item.status === 'success');
+      },
+      ignoredRows() {
+        return this.rows.filter((item) => item.status !== 'success');
+      }
+    },
+    methods: {
+      open(existIds = []) {
+        this.fileName = '';
+        this.rows = [];
+        this.existIds = (existIds || []).map((item) => String(item));
+        this.visible = true;
+      },
+      statusText(status) {
+        return { success: '可导入', warn: '已忽略', error: '失败' }[status];
+      },
+      statusTagType(status) {
+        return { success: 'success', warn: 'info', error: 'danger' }[status];
+      },
+      // 上传前校验:只允许 xls/xlsx,且不超过 5MB
+      beforeUpload(file) {
+        const ext = file.name
+          .slice(file.name.lastIndexOf('.') + 1)
+          .toLowerCase();
+        if (!['xls', 'xlsx'].includes(ext)) {
+          this.$message.error('只能导入 Excel 文件(.xls/.xlsx)');
+          return false;
+        }
+        if (file.size / 1024 / 1024 > 5) {
+          this.$message.error('导入文件大小不能超过 5MB');
+          return false;
+        }
+        return true;
+      },
+      // 生成并下载导入模板
+      downloadTemplate() {
+        const sheet = XLSX.utils.aoa_to_sheet([
+          ['工号', '姓名'],
+          ['A1001', '张三']
+        ]);
+        sheet['!cols'] = [{ wch: 18 }, { wch: 14 }];
+        const book = XLSX.utils.book_new();
+        XLSX.utils.book_append_sheet(book, sheet, '培训人员');
+        XLSX.writeFile(book, '培训人员导入模板.xlsx');
+      },
+      // 解析文件并用工号反查系统人员
+      async handleFile(param) {
+        this.parsing = true;
+        this.fileName = param.file.name;
+        this.rows = [];
+        try {
+          const buffer = await param.file.arrayBuffer();
+          const book = XLSX.read(buffer, { type: 'array' });
+          const sheet = book.Sheets[book.SheetNames[0]];
+          const aoa = XLSX.utils.sheet_to_json(sheet, {
+            header: 1,
+            defval: '',
+            blankrows: false
+          });
+          if (!aoa.length) {
+            this.$message.error('文件内容为空');
+            return;
+          }
+          const header = aoa[0].map((item) => cellText(item));
+          const jobCol = header.findIndex((item) => item.includes('工号'));
+          const nameCol = header.findIndex((item) => item.includes('姓名'));
+          if (jobCol < 0) {
+            this.$message.error('未找到「工号」列,请使用模板格式');
+            return;
+          }
+          const list = [];
+          aoa.slice(1).forEach((row) => {
+            const jobNumber = cellText(row[jobCol]);
+            const fileName = nameCol < 0 ? '' : cellText(row[nameCol]);
+            if (!jobNumber && !fileName) return;
+            list.push({ jobNumber, fileName });
+          });
+          if (!list.length) {
+            this.$message.warning('文件内没有可导入的数据');
+            return;
+          }
+          const userList = await getUserPage({ pageNum: 1, size: -1 });
+          this.rows = this.buildRows(list, userList.list || []);
+        } catch (error) {
+          this.$message.error(error.message || '文件解析失败');
+        } finally {
+          this.parsing = false;
+        }
+      },
+      // 按工号匹配系统人员,逐行标记可导入/忽略/失败
+      buildRows(list, users) {
+        const userMap = new Map();
+        users.forEach((item) => {
+          const key = cellText(item.jobNumber);
+          if (key) userMap.set(key, item);
+        });
+        const usedJobNumbers = new Set();
+        return list.map((item) => {
+          const base = {
+            jobNumber: item.jobNumber,
+            fileName: item.fileName,
+            userName: '',
+            groupName: ''
+          };
+          if (!item.jobNumber) {
+            return { ...base, status: 'error', reason: '工号为空' };
+          }
+          if (usedJobNumbers.has(item.jobNumber)) {
+            return { ...base, status: 'warn', reason: '文件内工号重复' };
+          }
+          usedJobNumbers.add(item.jobNumber);
+          const user = userMap.get(item.jobNumber);
+          if (!user) {
+            return { ...base, status: 'error', reason: '系统中不存在该工号' };
+          }
+          if (this.existIds.includes(String(user.id))) {
+            return {
+              ...base,
+              userName: user.name,
+              groupName: user.groupName || '',
+              status: 'warn',
+              reason: '已在当前任务人员列表中'
+            };
+          }
+          // 文件里的姓名只作参考,最终以系统人员信息为准
+          const reason =
+            item.fileName && item.fileName !== user.name
+              ? '姓名与系统不一致,已按系统为准'
+              : '';
+          return {
+            ...base,
+            id: user.id,
+            userName: user.name,
+            groupName: user.groupName || '',
+            status: 'success',
+            reason
+          };
+        });
+      },
+      confirm() {
+        const persons = this.matchedRows.map((item) => ({
+          id: item.id,
+          name: item.userName,
+          jobNumber: item.jobNumber,
+          groupName: item.groupName
+        }));
+        this.visible = false;
+        this.$emit('confirm', persons, this.ignoredRows.length);
+      }
+    }
+  };
+</script>
+
+<style lang="scss" scoped>
+  .op-bar {
+    display: flex;
+    align-items: center;
+    margin: 12px 0;
+
+    .file-name {
+      margin-left: 10px;
+      color: #909399;
+      font-size: 12px;
+    }
+  }
+
+  .reason-text {
+    margin-left: 6px;
+    color: #909399;
+    font-size: 12px;
+  }
+
+  .summary-text {
+    float: left;
+    line-height: 32px;
+    color: #606266;
+    font-size: 12px;
+  }
+</style>