Browse Source

员工档案导入导出改为模板同款 xlsx,新增班组字典接口

1. 新增 src/utils/employee-template.js:员工档案模板列唯一来源(28 列含微信号,保留模板自身重复表头),「下载导入模板 / 导出当前结果 / 导出已选」都从它生成真实 .xlsx,导出文件可直接回导;

2. 员工档案页移除本地 CSV 列清单(escapeCsv/downloadCsv/exportRowsLocally),导出前按需加载工厂、班组字典(工厂/班组整列取不到名称时报错中止,不产出空白列);

3. 新增 api listTeams(GET /main/team/page)并把 adaptEmployee 的 teamId 逗号串解析为 teamIds 数组;删除已无引用的 downloadUserTemplate(后端模板为旧 22 列,与页面口径不一致)。
lihaiting 6 days ago
parent
commit
332af3bb0d

+ 19 - 7
src/api/organization/index.js

@@ -126,6 +126,19 @@ export async function listOrganizationsWithUserCount(params) {
   );
 }
 
+/**
+ * 班组字典:GET /main/team/page
+ * 用于把人员的 teamId(逗号串)翻译成班组名称(导出、详情展示)。
+ * 班组是主数据,数量少,这里一次拉满。
+ */
+export async function listTeams(params = {}) {
+  return unwrap(
+    await request.get("/main/team/page", {
+      params: { pageNum: 1, size: 500, ...params },
+    }),
+  );
+}
+
 /** 工厂区域分页:type=1 工厂 */
 export async function getFactoryAreaPage(params = {}) {
   return unwrap(
@@ -469,13 +482,6 @@ export async function removeEmployee(ids) {
   return unwrap(await request.post("/main/user/removeById", ids));
 }
 
-export async function downloadUserTemplate() {
-  await downloadBlob(
-    await request.post("/main/user/downLoadTemplate", {}, { responseType: "blob" }),
-    "人员信息导入模板.xlsx",
-  );
-}
-
 export async function importUsers(file) {
   const formData = new FormData();
   formData.append("file", file);
@@ -937,6 +943,12 @@ export function adaptEmployee(raw = {}, extras = {}) {
     legalEntity: raw.groupName || "",
     factory: raw.factoryName || "",
     factoryId: raw.factoryId || raw.factoriesId || null,
+    // 班组:后端 teamId 是逗号串(可多个),teams 明细常为空数组,
+    // 班组名称需另查班组字典(api listTeams)翻译,这里只保留主键
+    teamIds: String(raw.teamId || "")
+      .split(/[,,]/)
+      .map((item) => item.trim())
+      .filter(Boolean),
     deptIds,
     deptId: deptId ? String(deptId) : "",
     deptName: raw.deptName || raw.deptNames || "",

+ 220 - 0
src/utils/employee-template.js

@@ -0,0 +1,220 @@
+import * as XLSX from "xlsx";
+
+/**
+ * 员工档案「导入/导出模板」唯一来源。
+ *
+ * 表头文案、顺序、工作表名、样例行 100% 对齐用户拍板的《员工档案导入模板.xlsx》(28 列,含「微信号」),
+ * 「导出当前结果 / 导出已选」与「下载导入模板」都从这里生成,两处口径不允许再各写一份。
+ * 两处重复表头(第 3 列与第 14 列都叫「*工号」、第 11 列与第 16 列都叫「岗位」)是模板
+ * 自身的写法,这里原样保留,不做「优化」,否则回导对不上列。
+ *
+ * 取值来源说明(均为 GET /main/user/getUserPage 列表行实测字段,无需逐人查详情):
+ * - 所属机构名称  ← 组织列表 groupId 对应节点名,回落 groupName
+ * - 所属工厂名称  ← 人员 factoryId 经「工厂区域」字典翻译
+ * - 所属班组      ← 人员 teamId(逗号串)经「班组」字典翻译
+ * - 负责部门编码  ← 人员 deptIds 经组织列表节点 groupCode 翻译
+ * - 岗位序列/层级/性质 ← 人员 positionList 主岗位(positionSequenceName / positionLevelName / positionTypeName)
+ * - 员工状态      ← status 数字码中文(1全职 2兼职 3实习 4正式 5试用 6离职)
+ */
+
+/** 文本归一:null/undefined → 空串,其余 trim */
+function textOf(value) {
+  if (value == null) return "";
+  return String(value).trim();
+}
+
+/** 逗号串 / 数组 → 去空白后的字符串数组 */
+function toIdList(value) {
+  if (Array.isArray(value)) return value.map(textOf).filter(Boolean);
+  return textOf(value)
+    .split(/[,,]/)
+    .map(textOf)
+    .filter(Boolean);
+}
+
+/** 主岗位绑定项:primaryFlag=1 优先,其次第一条 */
+function primaryBinding(row) {
+  const list = (row && row.positionBindings) || [];
+  return list.find((item) => Number(item.primaryFlag) === 1) || list[0] || null;
+}
+
+/** id 列表 → 名称列表;字典里查不到时保留原 id,保证信息不丢(不静默丢列) */
+function mapNames(ids, dict) {
+  return ids.map((id) => textOf(dict && dict[id]) || id);
+}
+
+/**
+ * 「YYYY-MM-DD」→ Excel 日期单元格。
+ * 模板里 入职时间 / 生日 是真日期(序列号 40987),不是文本,这里同样写日期单元格,
+ * 保证导出的文件后端能按日期解析。无值写空串,格式异常原样输出。
+ */
+function toExcelDate(value) {
+  const text = textOf(value);
+  if (!text) return "";
+  // 后端个别接口把日期回成纯数字毫秒时间戳(如 558115200000),先按时间戳还原
+  if (/^\d{13}$/.test(text)) {
+    const stamp = new Date(Number(text));
+    return Number.isNaN(stamp.getTime()) ? text : stamp;
+  }
+  const matched = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(text);
+  if (!matched) return text;
+  const date = new Date(Number(matched[1]), Number(matched[2]) - 1, Number(matched[3]));
+  return Number.isNaN(date.getTime()) ? text : date;
+}
+
+/** 列定义:header 为模板表头,value(row, index, ctx) 为该列取值 */
+export const EMPLOYEE_TEMPLATE_COLUMNS = [
+  { header: "序号", value: (row, index) => index + 1 },
+  {
+    header: "*所属机构名称",
+    value: (row, index, ctx) =>
+      textOf(ctx.orgById?.[String(row.groupId)]?.name) || textOf(row.company),
+  },
+  { header: "*工号", value: (row) => textOf(row.employeeNo) },
+  { header: "*姓名", value: (row) => textOf(row.name) },
+  { header: "*性别", value: (row) => textOf(row.gender) },
+  { header: "*手机号", value: (row) => textOf(row.phone) },
+  { header: "*员工状态", value: (row) => textOf(row.formStatus) },
+  {
+    header: "所属工厂名称",
+    value: (row, index, ctx) => textOf(ctx.factoryById?.[String(row.factoryId)]) || "",
+  },
+  {
+    header: "所属班组",
+    value: (row, index, ctx) =>
+      mapNames(toIdList(row.teamIds), ctx.teamById).join(","),
+  },
+  { header: "*隶属部门", value: (row) => textOf(row.deptName) },
+  { header: "岗位", value: (row) => textOf(row.position) },
+  {
+    header: "负责部门编码(多个逗号分隔)",
+    value: (row, index, ctx) =>
+      toIdList(row.deptIds)
+        .map((id) => textOf(ctx.orgById?.[id]?.groupCode) || id)
+        .join(","),
+  },
+  { header: "*身份证号", value: (row) => textOf(row.idCard) },
+  /* 与第 3 列同名的模板列,原样保留 */
+  { header: "*工号", value: (row) => textOf(row.employeeNo) },
+  { header: "*岗位序列", value: (row) => textOf(primaryBinding(row)?.positionSequenceName) },
+  { header: "*岗位", value: (row) => textOf(row.position) },
+  {
+    header: "*岗位性质",
+    value: (row) => textOf(row.employmentTypeName) || textOf(row.employmentType),
+  },
+  { header: "*岗位层级", value: (row) => textOf(row.level) },
+  { header: "邮箱", value: (row) => textOf(row.email) },
+  {
+    header: "年龄",
+    value: (row) => (row.age == null || row.age === "" ? "" : Number(row.age)),
+  },
+  { header: "*入职时间", value: (row) => toExcelDate(row.hireDate) },
+  { header: "籍贯", value: (row) => textOf(row.nativePlace) },
+  {
+    header: "工种",
+    value: (row) =>
+      ((row.workTypes || []).map((item) => textOf(item.name) || textOf(item.code)).filter(Boolean)).join(","),
+  },
+  { header: "专业", value: (row) => textOf(row.major) },
+  { header: "毕业院校", value: (row) => textOf(row.school) },
+  { header: "生日", value: (row) => toExcelDate(row.birthDate) },
+  { header: "微信号", value: (row) => textOf(row.wechat) },
+  { header: "备注:标*号为必填项", value: () => "" },
+];
+
+/** 模板表头(顺序即列顺序,供比对/复用) */
+export const EMPLOYEE_TEMPLATE_HEADERS = EMPLOYEE_TEMPLATE_COLUMNS.map(
+  (column) => column.header,
+);
+
+/** 模板工作表名:与《员工档案导入模板.xlsx》一致(Sheet1),保证导出文件与模板结构完全相同 */
+export const EMPLOYEE_TEMPLATE_SHEET_NAME = "Sheet1";
+
+/**
+ * 模板自带的样例行(原文件的第 2 行,逐格原样复刻,含数字/日期单元格类型)。
+ * 只用于「下载导入模板」,导出真实数据时不带这一行。
+ * 空列写 null 而不是空串:SheetJS 会跳过 null,生成的空单元格与原模板一致。
+ */
+export function buildEmployeeTemplateSampleRow() {
+  return [
+    1,
+    "公司",
+    1212333,
+    "曹孟德",
+    "男",
+    18895956063,
+    "全职",
+    "万隆本部",
+    "质检班组",
+    null,
+    "总经理",
+    "G0018,G0019",
+    "430904198506061566",
+    null,
+    null,
+    null,
+    null,
+    null,
+    "aa@qq.com",
+    37,
+    /* 入职时间:原文件为日期序列号 40987 = 2012-03-19 */
+    new Date(2012, 2, 19),
+    "湖南",
+    null,
+    111,
+    "湖南测试学院",
+    /* 生日:原文件为日期序列号 25569 = 1970-01-01 */
+    new Date(1970, 0, 1),
+    "test001",
+    null,
+  ];
+}
+
+/**
+ * 员工档案行 → 模板二维数组(首行为表头)
+ * @param {Array} rows adaptEmployee 产出的员工行
+ * @param {object} ctx { orgById, factoryById, teamById } 字典(缺失时对应列退化为 id / 空)
+ */
+export function buildEmployeeTemplateSheet(rows, ctx = {}) {
+  const header = EMPLOYEE_TEMPLATE_HEADERS.slice();
+  const body = (rows || []).map((row, index) =>
+    EMPLOYEE_TEMPLATE_COLUMNS.map((column) => column.value(row, index, ctx)),
+  );
+  return { header, body };
+}
+
+/**
+ * 员工档案 → 模板格式 .xlsx
+ * @param {Array} rows 员工行(模板下载传 [])
+ * @param {object} ctx 字典
+ * @param {string} fileName 目标文件名
+ * @param {object} [options]
+ * @param {boolean} [options.withSample=false] 是否带模板自带的样例行(第 2 行)
+ * @returns {number} 写入的数据行数(不含表头/样例行)
+ */
+export function writeEmployeeTemplateXlsx(rows, ctx, fileName, options = {}) {
+  const { header, body } = buildEmployeeTemplateSheet(rows, ctx);
+  const aoa = [header];
+  if (options.withSample) aoa.push(buildEmployeeTemplateSampleRow());
+  aoa.push(...body);
+  const sheet = XLSX.utils.aoa_to_sheet(aoa, { cellDates: true });
+  sheet["!cols"] = EMPLOYEE_TEMPLATE_COLUMNS.map((column) => ({
+    // 中文按 2 字宽估算,保证表头不被截断
+    wch: Math.max(10, column.header.length * 2),
+  }));
+  const book = XLSX.utils.book_new();
+  XLSX.utils.book_append_sheet(book, sheet, EMPLOYEE_TEMPLATE_SHEET_NAME);
+  XLSX.writeFile(book, fileName);
+  return body.length;
+}
+
+/**
+ * 「下载导入模板」:本地生成与《员工档案导入模板.xlsx》同款空白模板(表头 + 样例行)。
+ * 不再调后端 `POST /main/user/downLoadTemplate`(后端下发的是旧 22 列,与页面回导口径不一致)。
+ * @returns {string} 生成的文件名
+ */
+export function downloadEmployeeTemplate(fileName = "员工档案导入模板.xlsx") {
+  writeEmployeeTemplateXlsx([], {}, fileName, { withSample: true });
+  return fileName;
+}
+

+ 74 - 43
src/views/hrManagement/employeeArchive/index.vue

@@ -175,14 +175,17 @@ import {
   adaptEmployeeProfile,
   syncWorkExperiences,
   listWorkExperiences,
-  downloadUserTemplate,
   importUsers,
   fetchAllUsers,
-  searchUsersByKeyword
+  searchUsersByKeyword,
+  getFactoryAreaPage,
+  listTeams
 } from '@/api/organization';
 import {
   buildOrganizationTree
 } from '@/utils/organization-tree';
+/* 导入/导出列与《员工档案导入模板.xlsx》完全一致(含 2 处重复表头与样例行),导出文件可直接回导 */
+import { writeEmployeeTemplateXlsx, downloadEmployeeTemplate } from '@/utils/employee-template';
 /* 员工档案可见机构范围:只取登录缓存所属机构(sessionStorage.loginChangeGroupVOList) */
 import { getOrgOptions, getCurrentOrgId } from '@/utils/current-org';
 
@@ -201,24 +204,8 @@ const STATUS_OPTIONS = [
   { value: 'disabled', label: '待岗', type: 'danger' }
 ];
 
-function escapeCsv(value) {
-  const text = String(value == null ? '' : value);
-  return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
-}
-
-function downloadCsv(rows, columns, fileName) {
-  const lines = [columns.map((item) => item[0]).join(',')];
-  rows.forEach((row) => {
-    lines.push(columns.map((item) => escapeCsv(typeof item[1] === 'function' ? item[1](row) : row[item[1]])).join(','));
-  });
-  const blob = new Blob([`\uFEFF${lines.join('\r\n')}`], { type: 'text/csv;charset=utf-8' });
-  const url = URL.createObjectURL(blob);
-  const link = document.createElement('a');
-  link.href = url;
-  link.download = fileName;
-  link.click();
-  URL.revokeObjectURL(url);
-}
+/* 员工档案导出/模板列已抽到 utils/employee-template.js —— 表头与《员工档案导入模板.xlsx》逐列对齐,
+   本文件不再维护 CSV 列清单,避免两处口径漂移 */
 
 export default {
   name: 'EmployeeArchive',
@@ -248,7 +235,11 @@ export default {
       tableTotal: 0,
       /* 工号池:只在打开「新增/编辑员工」弹窗时按需拉取,页面加载不发这个全量请求 */
       existingNumbers: [],
-      employeeNumbersLoaded: false
+      employeeNumbersLoaded: false,
+      /* 导出用字典(工厂 / 班组):人员行只有 factoryId / teamId,名称要查字典;导出时按需加载一次并缓存 */
+      factoryById: {},
+      teamById: {},
+      exportDictLoaded: false
     };
   },
   computed: {
@@ -699,9 +690,11 @@ export default {
     },
     async handleImportCommand(command) {
       if (command === 'template') {
+        // 「下载导入模板」= 本地按《员工档案导入模板.xlsx》生成(28 列 + 样例行),
+        // 不再调后端 downLoadTemplate(后端下发的是旧 22 列,和页面导出/回导口径对不上)
         try {
-          await downloadUserTemplate();
-          this.$message.success('模板已开始下载');
+          const fileName = downloadEmployeeTemplate(`员工档案导入模板_${new Date().toISOString().slice(0, 10)}.xlsx`);
+          this.$message.success(`模板已开始下载:${fileName}`);
         } catch (error) {
           this.$message.error(error.message || '模板下载失败');
         }
@@ -725,34 +718,69 @@ export default {
         this.loading = false;
       }
     },
-    exportRowsLocally(rows, fileName) {
-      const columns = [
-        ['员工编号', 'employeeNo'],
-        ['姓名', 'name'],
-        ['性别', 'gender'],
-        ['部门', 'deptName'],
-        ['公司', 'company'],
-        ['岗位', 'position'],
-        ['职级', 'level'],
-        ['入职日期', 'hireDate'],
-        ['手机号', 'phone'],
-        ['邮箱', 'email'],
-        ['状态', (row) => this.statusLabel(row.status)]
-      ];
-      downloadCsv(rows, columns, fileName);
+    /**
+     * 导出用字典:人员行只带 factoryId / teamId,工厂名与班组名必须查字典。
+     * 只在导出时按需拉一次并缓存(页面加载不发)。
+     * 任一字典失败 → 直接报错中止导出:宁可不导,也不产出「工厂/班组整列空白」的错文件。
+     */
+    async ensureExportDictionaries() {
+      if (this.exportDictLoaded) return;
+      const [factoryData, teamData] = await Promise.all([
+        getFactoryAreaPage().catch((error) => {
+          throw new Error(`工厂字典加载失败:${error?.message || '未知错误'}`);
+        }),
+        listTeams().catch((error) => {
+          throw new Error(`班组字典加载失败:${error?.message || '未知错误'}`);
+        })
+      ]);
+      const factoryList = Array.isArray(factoryData) ? factoryData : factoryData?.list || [];
+      const teamList = Array.isArray(teamData) ? teamData : teamData?.list || [];
+      const factoryById = {};
+      factoryList.forEach((item) => {
+        if (item?.id == null) return;
+        factoryById[String(item.id)] = item.name || '';
+      });
+      const teamById = {};
+      teamList.forEach((item) => {
+        if (item?.id == null) return;
+        teamById[String(item.id)] = item.name || '';
+      });
+      this.factoryById = factoryById;
+      this.teamById = teamById;
+      this.exportDictLoaded = true;
+    },
+    /** 导出员工:列 = 《员工档案导入模板.xlsx》列(真实 .xlsx,可直接回导) */
+    async exportRowsAsTemplate(rows, fileName) {
+      await this.ensureExportDictionaries();
+      return writeEmployeeTemplateXlsx(
+        rows,
+        { orgById: this.orgById, factoryById: this.factoryById, teamById: this.teamById },
+        fileName
+      );
     },
     async handleExport(type) {
+      const today = new Date().toISOString().slice(0, 10);
       if (type === 'selected') {
         if (!this.selectedRows.length) {
           this.$message.warning('请先选择要导出的员工');
           return;
         }
-        this.exportRowsLocally(this.selectedRows, `员工档案_已选_${new Date().toISOString().slice(0, 10)}.csv`);
-        this.$message.success(`已导出 ${this.selectedRows.length} 条(本地 CSV,因服务端不支持按勾选 ID 导出)`);
+        try {
+          this.loading = true;
+          const count = await this.exportRowsAsTemplate(
+            this.selectedRows,
+            `员工档案_已选_${today}.xlsx`
+          );
+          this.$message.success(`已导出 ${count} 条`);
+        } catch (error) {
+          this.$message.error(error.message || '导出失败');
+        } finally {
+          this.loading = false;
+        }
         return;
       }
       // 「导出当前结果」必须与页面所见完全一致:关键词/性别/入职日期等筛选只有前端口径,
-      // 服务端导出不认这些条件,直接调会把没筛选过的数据导出去(表格信息对不上)→ 统一本地导出
+      // 服务端导出不认这些条件,直接调会把没筛选过的数据导出去(表格信息对不上)→ 本地按模板列导出
       try {
         this.loading = true;
         const total = Math.max(Number(this.tableTotal) || 0, 200);
@@ -762,8 +790,11 @@ export default {
           this.$message.warning('当前没有可导出的员工数据');
           return;
         }
-        this.exportRowsLocally(rows, `员工档案_当前结果_${new Date().toISOString().slice(0, 10)}.csv`);
-        this.$message.success(`已导出 ${rows.length} 条`);
+        const count = await this.exportRowsAsTemplate(
+          rows,
+          `员工档案_当前结果_${today}.xlsx`
+        );
+        this.$message.success(`已导出 ${count} 条`);
       } catch (error) {
         this.$message.error(error.message || '导出失败');
       } finally {