Browse Source

岗位档案推荐培训恢复回写;修复工种列表改完只剩一条、岗位申请证书输入失焦、物品领用保存不关窗;员工档案与录用审批性别/状态统一接口码并改走字典与接口 statusName;录用审批支持删除草稿;导出下载与 chunk 加载异常抽公共 utils

lihaiting 1 week ago
parent
commit
fce28ce109

+ 20 - 1
src/api/hr/hiringApproval.js

@@ -1,4 +1,6 @@
 import request from "@/utils/request";
+/* 性别:接口口径 1 男 / 2 女(下拉 value 即码);未选 / 没有值 → 不提交该字段 */
+import { toApiGender } from "@/utils/gender";
 
 function unwrap(response) {
   const result = response?.data || {};
@@ -279,7 +281,8 @@ function serializeHiringApproval(form = {}) {
     positionType: form.positionType || undefined,
     positionTypeName: form.positionTypeName || undefined,
     expectedEntryDate: dateOnly(form.expectedEntryDate || form.hireDate),
-    gender: form.gender || undefined,
+    // 性别:提交接口码 '1'/'2';没选(空)→ undefined(compact 会去掉,即不提交该字段)
+    gender: toApiGender(form.gender),
     name: name || undefined,
     positionId: keepBigIntId(form.positionId),
     hiringDeptId: keepBigIntId(
@@ -400,3 +403,19 @@ export const createHiringApproval = async (data) => {
 
 /** 与 create 同一接口(文档无独立修改接口) */
 
+/**
+ * 删除录用审批草稿 DELETE /hr/recruit/hiring-approvals/{id}
+ * ★ 按文档:**仅尚未发起审批流程的 DRAFT 草稿**允许逻辑删除;
+ *   审批中 / 已通过 / 已驳回 / 已取消单据必须保留流程与业务审计记录(后端会拒绝)。
+ *   id 为录用审批主键(int64,保持字符串避免精度丢失)。
+ */
+export const deleteHiringApproval = async (id) => {
+  const hiringId = keepBigIntId(id);
+  if (hiringId == null || hiringId === "") {
+    return Promise.reject(new Error("缺少录用审批主键"));
+  }
+  return unwrap(
+    await request.delete(`/hr/recruit/hiring-approvals/${hiringId}`),
+  );
+};
+

+ 53 - 28
src/api/hr/index.js

@@ -1,4 +1,5 @@
 import request from "@/utils/request";
+import { downloadBlobResponse } from "@/utils/download";
 import {
   adaptContractFlowSignNodes,
   contractFlowSignStatusLabel,
@@ -34,29 +35,8 @@ function keepBigIntId(value) {
   return Number(text);
 }
 
-function downloadBlob(response, fallbackName) {
-  const disposition = response.headers?.["content-disposition"] || "";
-  const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
-  const plain = disposition.match(/filename="?([^";]+)"?/i)?.[1];
-  const fileName = encoded
-    ? decodeURIComponent(encoded)
-    : plain || fallbackName;
-  const blob =
-    response.data instanceof Blob
-      ? response.data
-      : new Blob([response.data], {
-          type:
-            response.headers?.["content-type"] || "application/octet-stream",
-        });
-  const url = URL.createObjectURL(blob);
-  const link = document.createElement("a");
-  link.href = url;
-  link.download = fileName;
-  document.body.appendChild(link);
-  link.click();
-  document.body.removeChild(link);
-  URL.revokeObjectURL(url);
-}
+/* 文件流下载统一走 utils/download 的 downloadBlobResponse:
+   文件名解码(filename= 里的 percent-encoding 不 decode 会乱码)+ JSON 业务错误拦截 */
 
 const STATUS_TO_API = { 1: 1, 2: 0, 3: 2 };
 const STATUS_FROM_API = { 0: 2, 1: 1, 2: 3 };
@@ -239,7 +219,7 @@ function adaptLevelConfig(cfg = {}) {
       category: item.certificateType || "",
       certificateId: item.certificateId,
     })),
-    // 培训要求由 EHS 维护,详情若带回仅展示、不随岗位档案回写
+    // 推荐培训:字典项 value 作为课程标识,回写/回显统一走 trainingName 字段
     trainings: (cfg.trainings || [])
       .map((item) => item.trainingName)
       .filter(Boolean),
@@ -408,7 +388,7 @@ function resolveDutyContent(level = {}, fallback = "") {
 }
 
 // 弹窗每层级字段 → 接口「岗位序列层级配置参数」
-// 文档:positionLevel、positionSequence、salary(含 dutyContent) 必填;培训由 EHS 维护,不随本接口回写
+// 文档:positionLevel、positionSequence、salary(含 dutyContent) 必填
 function buildLevelConfig(level = {}, positionList = [], positionSequence = "") {
   // ===== 晋升路径暂时下线(2026-09-29):不再向接口提交 promotions 参数,恢复时取消本段注释 =====
   // const targetId = (name) =>
@@ -500,6 +480,16 @@ function buildLevelConfig(level = {}, positionList = [], positionSequence = "")
         sortNo: index + 1,
       }),
     );
+  // 推荐培训:表单值是字典项 value(字符串),接口按 trainingName 回写;
+  // 兼容对象形态(含 trainingName 的历史数据),否则重进编辑会丢失已选课程
+  const trainings = (level.trainings || [])
+    .map((item) =>
+      typeof item === "string"
+        ? item.trim()
+        : String(item?.trainingName ?? item?.name ?? "").trim(),
+    )
+    .filter(Boolean)
+    .map((trainingName) => ({ trainingName }));
 
   const sequenceText = String(
     positionSequence || level.positionSequence || "",
@@ -515,6 +505,7 @@ function buildLevelConfig(level = {}, positionList = [], positionSequence = "")
   if (sequenceText) config.positionSequence = sequenceText;
   if (skills.length) config.skills = skills;
   if (certificates.length) config.certificates = certificates;
+  if (trainings.length) config.trainings = trainings;
   // 晋升路径暂时下线,不再提交 promotions;恢复时取消下一行注释
   // if (promotions.length) config.promotions = promotions;
   return config;
@@ -539,6 +530,7 @@ function fallbackLevelFromForm(form = {}) {
     workShift: form.workShift,
     skills: form.skills,
     certificates: form.certificates,
+    trainings: form.trainings,
     promote: form.promote,
     duties: form.duties,
     dutyText:
@@ -757,7 +749,7 @@ export const generatePositionDocument = async (id) =>
   unwrap(await request.post(`/hr/position/${id}/document`));
 
 export async function downloadPositionDocument(id) {
-  downloadBlob(
+  downloadBlobResponse(
     await request.get(`/hr/position/document/${id}/download`, {
       responseType: "blob",
     }),
@@ -766,7 +758,7 @@ export async function downloadPositionDocument(id) {
 }
 
 export async function downloadPositionImportTemplate() {
-  downloadBlob(
+  downloadBlobResponse(
     await request.get("/hr/position/import/template", { responseType: "blob" }),
     "岗位导入模板.xlsx",
   );
@@ -784,7 +776,7 @@ export async function importPositions(file) {
 // 晋升(岗位晋升通道:地图 / 导出 / 申请分页;审批流由 BPM 负责,评审类接口已下线)
 
 export async function exportPromotionChannels() {
-  downloadBlob(
+  downloadBlobResponse(
     await request.post(
       "/hr/promotion/channels/export",
       {},
@@ -881,6 +873,39 @@ export {
 // 劳动合同管理
 export const getContractPage = async (data = {}) =>
   unwrap(await request.get("/hr/contracts/page", { params: compact(data) }));
+
+/**
+ * 导出劳动合同台账 POST /hr/contracts/export
+ * ★ 复用分页查询的组织范围与**全部业务筛选条件**;pageNum / size 不传(文档:不限制导出范围)。
+ *   接口直接返回 .xlsx 文件流,故 responseType=blob + 放宽 timeout;
+ *   downloadBlob 会拦截「HTTP 200 + JSON 业务错误」,避免把错误 JSON 存成 xlsx。
+ * @param {{keyword?:string, contractType?:string, contractStatus?:string, departmentId?:*, legalEntityId?:*,
+ *          positionId?:*, signingMethod?:string, signingStatus?:string, startDate?:string, endDate?:string,
+ *          expiringOnly?:boolean}} params
+ */
+export const exportContractLedger = async (params = {}) => {
+  const payload = compact({
+    keyword: params.keyword || undefined,
+    contractType: params.contractType || undefined,
+    contractStatus: params.contractStatus || undefined,
+    departmentId: keepBigIntId(params.departmentId),
+    legalEntityId: keepBigIntId(params.legalEntityId),
+    positionId: keepBigIntId(params.positionId),
+    signingMethod: params.signingMethod || undefined,
+    signingStatus: params.signingStatus || undefined,
+    startDate: params.startDate ? String(params.startDate).slice(0, 10) : undefined,
+    endDate: params.endDate ? String(params.endDate).slice(0, 10) : undefined,
+    // 仅勾选「即将到期」时才传 true(不传 = 不限制)
+    expiringOnly: params.expiringOnly === true ? true : undefined,
+  });
+  await downloadBlobResponse(
+    await request.post("/hr/contracts/export", payload, {
+      responseType: "blob",
+      timeout: 120000,
+    }),
+    `劳动合同台账_${new Date().toISOString().slice(0, 10)}.xlsx`,
+  );
+};
 /** 新建劳动合同 POST /hr/contracts/save */
 export const CONTRACT_APPROVAL_BUSINESS_KEY = "hr_contract_approval";
 export const REGULARIZATION_APPROVAL_BUSINESS_KEY = "main_regularization_approval";

+ 4 - 24
src/api/hr/personnelStructure.js

@@ -1,4 +1,5 @@
 import request from "@/utils/request";
+import { downloadBlobResponse } from "@/utils/download";
 
 /**
  * 人员结构报表(接口文档 人力资源-人员结构报表 / Hr Personnel Structure Report Controller)
@@ -48,29 +49,8 @@ function toInteger(value) {
   return num === null ? undefined : Math.trunc(num);
 }
 
-function downloadBlob(response, fallbackName) {
-  const disposition = response.headers?.["content-disposition"] || "";
-  const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
-  const plain = disposition.match(/filename="?([^";]+)"?/i)?.[1];
-  const fileName = encoded
-    ? decodeURIComponent(encoded)
-    : plain || fallbackName;
-  const blob =
-    response.data instanceof Blob
-      ? response.data
-      : new Blob([response.data], {
-          type:
-            response.headers?.["content-type"] || "application/octet-stream",
-        });
-  const url = URL.createObjectURL(blob);
-  const link = document.createElement("a");
-  link.href = url;
-  link.download = fileName;
-  document.body.appendChild(link);
-  link.click();
-  document.body.removeChild(link);
-  URL.revokeObjectURL(url);
-}
+/* 文件流下载统一走 utils/download 的 downloadBlobResponse:
+   文件名解码(filename= 里的 percent-encoding 不 decode 会乱码)+ JSON 业务错误拦截 */
 
 /* ────────────────────────── 数据状态契约 ────────────────────────── */
 
@@ -447,7 +427,7 @@ export async function exportStructureDetails(params = {}, filters = {}) {
     dimensionValueCode: params.dimensionValueCode || undefined,
     keyword: params.keyword || undefined,
   });
-  await downloadBlob(
+  await downloadBlobResponse(
     await request.get(`${BASE}/details/export`, {
       params: query,
       responseType: "blob",

+ 4 - 68
src/api/hr/skillMatrix.js

@@ -1,4 +1,5 @@
 import request from '@/utils/request';
+import { downloadBlobResponse } from '@/utils/download';
 
 function unwrap(response) {
   const result = response?.data || {};
@@ -24,43 +25,8 @@ function keepBigIntId(value) {
   return Number(text);
 }
 
-async function downloadBlob(response, fallbackName) {
-  const disposition = response.headers?.['content-disposition'] || '';
-  const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
-  const plain = disposition.match(/filename="?([^";]+)"?/i)?.[1];
-  const fileName = encoded
-    ? decodeURIComponent(encoded)
-    : plain || fallbackName;
-  const blob =
-    response.data instanceof Blob
-      ? response.data
-      : new Blob([response.data], {
-          type:
-            response.headers?.['content-type'] || 'application/octet-stream',
-        });
-  const mime = (blob.type || '').toLowerCase();
-  let fileBlob = blob;
-  if (mime.includes('application/json')) {
-    const text = await blob.text();
-    try {
-      const json = JSON.parse(text);
-      if (json && Number(json.code) !== 0) {
-        return Promise.reject(new Error(json.message || '导出失败'));
-      }
-    } catch (error) {
-      /* 非业务 JSON,按文件继续下载 */
-    }
-    fileBlob = new Blob([text], { type: blob.type || 'application/json' });
-  }
-  const url = URL.createObjectURL(fileBlob);
-  const link = document.createElement('a');
-  link.href = url;
-  link.download = fileName;
-  document.body.appendChild(link);
-  link.click();
-  document.body.removeChild(link);
-  URL.revokeObjectURL(url);
-}
+/* 文件流下载统一走 utils/download 的 downloadBlobResponse:
+   文件名解码(filename= 里的 percent-encoding 不 decode 会乱码)+ JSON 业务错误拦截 */
 
 const LEVEL_LABEL = {
   初级: '1级',
@@ -151,7 +117,7 @@ export async function analyzeSkillMatrix(data = {}) {
 
 /** 导出技能矩阵 Excel */
 export async function exportSkillMatrix(data = {}) {
-  await downloadBlob(
+  await downloadBlobResponse(
     await request.post(
       '/hr/skill-matrix/export',
       compact({
@@ -195,36 +161,6 @@ export async function getSkillCategoryTree() {
   return unwrap(await request.get('/hr/skill/category-tree'));
 }
 
-/** 技能库分页 */
-export async function getSkillPage(data = {}) {
-  return unwrap(await request.post('/hr/skill/page', compact(data)));
-}
-
-/** 技能详情 */
-
-
-/** 新增技能 */
-export async function saveSkill(data = {}) {
-  return unwrap(await request.post('/hr/skill', compact(data)));
-}
-
-/** 修改技能 */
-export async function updateSkill(data = {}) {
-  return unwrap(
-    await request.put('/hr/skill', compact({
-      ...data,
-      id: keepBigIntId(data.id),
-    })),
-  );
-}
-
-/** 删除技能 */
-export async function deleteSkill(id) {
-  return unwrap(
-    await request.delete(`/hr/skill/${encodeURIComponent(String(keepBigIntId(id) ?? id))}`),
-  );
-}
-
 /** 员工证书到期预警列表 */
 
 

+ 41 - 59
src/api/organization/index.js

@@ -4,6 +4,8 @@ import {
   loadOrganizationList,
   invalidateOrganizationList,
 } from "@/utils/org-list-cache";
+import { toApiSex } from "@/utils/gender";
+import { downloadBlobResponse } from "@/utils/download";
 
 function unwrap(response) {
   const result = response.data;
@@ -55,48 +57,8 @@ function formatDate(value) {
   return `${y}-${m}-${d}`;
 }
 
-/** FileReader 读 blob 文本(业务错误体是 JSON,需要转成可读信息) */
-function readBlobText(blob) {
-  return new Promise((resolve, reject) => {
-    const reader = new FileReader();
-    reader.onload = () => resolve(String(reader.result || ""));
-    reader.onerror = () => reject(new Error("读取下载内容失败"));
-    reader.readAsText(blob, "utf-8");
-  });
-}
-
-async function downloadBlob(response, fallbackName) {
-  const contentType = String(response.headers?.["content-type"] || "");
-  const payload = response.data;
-  // 后端业务错误常以 HTTP 200 + JSON 返回:不拦截会把 JSON 存成 .xlsx,打开全是乱码
-  if (payload instanceof Blob && /application\/json/i.test(contentType)) {
-    const text = await readBlobText(payload);
-    let message = "";
-    try {
-      const data = JSON.parse(text);
-      message = data?.message || data?.msg || "";
-    } catch (error) {
-      /* 非 JSON 错误体,走默认文案 */
-    }
-    throw new Error(message || "下载失败:接口返回了错误信息而不是文件");
-  }
-  const blob = new Blob([payload], {
-    type: contentType || "application/octet-stream",
-  });
-  const disposition = response.headers["content-disposition"] || "";
-  const matched = disposition.match(/filename\*?=(?:UTF-8''|")?([^";]+)/i);
-  const fileName = matched
-    ? decodeURIComponent(matched[1].replace(/"/g, ""))
-    : fallbackName;
-  const url = URL.createObjectURL(blob);
-  const link = document.createElement("a");
-  link.href = url;
-  link.download = fileName;
-  document.body.appendChild(link);
-  link.click();
-  document.body.removeChild(link);
-  URL.revokeObjectURL(url);
-}
+/* 文件流下载统一走 utils/download 的 downloadBlobResponse:
+   文件名解码(filename= 里的 percent-encoding 不 decode 会乱码)+ JSON 业务错误拦截 */
 
 /**
  * 查询机构列表(扁平全量)
@@ -503,36 +465,45 @@ async function removeWorkExperience(userId, workExperienceId) {
 }
 
 export async function exportOrganizations() {
-  await downloadBlob(
+  await downloadBlobResponse(
     await request.post("/main/group/exportGroups", {}, { responseType: "blob" }),
     "组织机构列表.xlsx",
   );
 }
 
-/** 后端 status → 列表展示态 */
-const EMPLOYEE_STATUS_CODE = {
-  1: "全职",
+/** 后端数字状态码 → 中文(展示与按文案兜底匹配用)。
+ *  ★ 2026-10-14 按字典 hr_user_status 实际抓包修正:
+ *    1=正式在职、6=试用期、7=待岗、8=已离职(swagger 注释「1全职…6离职」与实际数据不符,
+ *    以字典/实际数据为准;2/3/4/5 字典未暴露,按老代码与移动端口径保留)。
+ *  下拉、回显、提交的权威选项来自 hr_user_status 字典(utils/employee-status-dict)。 */
+export const EMPLOYEE_STATUS_CODE = {
+  1: "正式在职",
   2: "兼职",
   3: "实习",
   4: "正式",
   5: "试用",
-  6: "离职",
+  6: "试用期",
+  7: "待岗",
+  8: "已离职",
 };
 
-/** 员工状态「中文 → 后端数字状态码」唯一映射(提交时使用)。
- *  与 EMPLOYEE_STATUS_CODE 方向相反;下拉、回显、提交三处统一以本表为准,
- *  避免各模块用不同来源(字典 / 硬编码)导致对不齐。 */
+/** 员工状态「中文 → 后端数字状态码」唯一映射(提交兜底时使用)。
+ *  与 EMPLOYEE_STATUS_CODE 方向完全相反;同义文案都归到同一码(正式在职/全职→1,
+ *  试用期/试用→6,待岗/劳务→7,已离职/离职→8)。 */
 export const EMPLOYEE_STATUS = {
   全职: 1,
+  正式在职: 1,
   兼职: 2,
   实习: 3,
   正式: 4,
   试用: 5,
   试用期: 6,
   劳务: 7,
+  待岗: 7,
   离职: 8,
+  已离职: 8,
 };
-const EMPLOYEE_SEX = { 男: 1, 女: 2, 未知: 0 };
+/** 后端 sex 码 → 中文(列表、详情展示用);表单侧的下拉/提交统一走 utils/gender 的 GENDER_OPTIONS */
 const SEX_LABEL = { 0: "未知", 1: "男", 2: "女" };
 const REWARD_STATUS = { 生效: 2, 已撤销: 4, 已过期: 3, 未生效: 0, 审核中: 1 };
 /** 证照类型编码(名称 → 编码)。下拉选项、提交、回显三处统一以本表为准 */
@@ -550,18 +521,19 @@ export const CERT_TYPE = {
 const CERT_LEVEL = { 初级: "1", 中级: "2", 高级: "3" };
 const CERT_LEVEL_LABEL = { 1: "初级", 2: "中级", 3: "高级", 初级: "初级", 中级: "中级", 高级: "高级" };
 
-/** 列表/详情统一展示状态:active | probation | inactive | disabled */
+/** 列表/详情统一展示状态:active | probation | inactive | disabled
+ *  ★ 码表以字典 hr_user_status 实测为准:8=已离职、6=试用期、7=待岗(isEnabled=0 也是待岗)。 */
 function toListStatus(raw = {}) {
   if (Number(raw.isEnabled) === 0) return "disabled";
   const code = Number(raw.status);
-  if (code === 6) return "inactive";
-  if (code === 5) return "probation";
+  if (code === 8) return "inactive";
+  if (code === 6) return "probation";
+  if (code === 7) return "disabled";
   return "active";
 }
 
 function toFormStatus(raw = {}) {
-  if (Number(raw.isEnabled) === 0) return "离职";
-  return EMPLOYEE_STATUS_CODE[Number(raw.status)] || "全职";
+  return EMPLOYEE_STATUS_CODE[Number(raw.status)] || "";
 }
 
 function emptyLeaveInfo() {
@@ -965,6 +937,8 @@ export function adaptEmployee(raw = {}, extras = {}) {
     formStatus: toFormStatus(raw),
     // 后端原始状态码:员工档案表单用 hr_user_status 字典编码回显匹配
     statusCode: raw.status,
+    // ★ 新增字段:后端直接给的状态中文名(列表「状态」列直接展示它,不再本地派生文案)
+    statusName: raw.statusName || "",
     hireDate: formatDate(raw.joinDate || raw.hireDate || raw.entryDate),
     regularDate: formatDate(raw.positiveDate),
     enabled: Number(raw.isEnabled) !== 0,
@@ -1193,12 +1167,19 @@ function serializePositionList(form = {}, positionIds = []) {
  *   (后端「未传保持」),避免表单里没有值却把已有数据清空;字段本身仍在员工详情里只读展示。
  */
 export function serializeEmployee(form = {}) {
-  // 员工状态:表单 value 为 hr_user_status 字典编码(数字字符串);旧数据/兜底可能是中文
+  /* 员工状态:表单 value 就是字典里的数字状态码(实测 1=正式在职、6=试用期、7=待岗、8=已离职);
+     离职页签自动带出的旧写法可能是中文,再按 EMPLOYEE_STATUS 反查一次。 */
   const statusAsNumber = Number(form.status);
   const statusCode =
     Number.isFinite(statusAsNumber) && statusAsNumber > 0
       ? statusAsNumber
-      : EMPLOYEE_STATUS[form.status] ?? EMPLOYEE_STATUS[form.formStatus] ?? 1;
+      : EMPLOYEE_STATUS[form.status] ?? EMPLOYEE_STATUS[form.formStatus] ?? null;
+  if (statusCode == null) {
+    // 不静默改写状态:命中不了就报出来(表单默认值与下拉都保证有值,正常不会走到这里)
+    console.error(
+      `[员工档案] 员工状态无法识别(status=${JSON.stringify(form.status)},formStatus=${JSON.stringify(form.formStatus)}),未提交该字段`,
+    );
+  }
 
   const education = pickEducation(form);
   const bankCardList = serializeBankCardList(form);
@@ -1235,7 +1216,8 @@ export function serializeEmployee(form = {}) {
     accountId: toApiId(form.accountId || form.raw?.accountId),
     name: form.name || "",
     jobNumber: form.employeeNo || "",
-    sex: EMPLOYEE_SEX[form.gender] ?? form.gender,
+    // 性别:表单下拉 value 即接口码(1 男 / 2 女);未选 / 没有值 → 不提交该字段(compact 会去掉 undefined)
+    sex: toApiSex(form.gender),
     birthday: form.birthDate || "",
     age: Number(form.age) || undefined,
     idCard: form.idCard || "",

+ 3 - 0
src/main.js

@@ -15,6 +15,7 @@ import DictSelection from '@/components/Dict/DictSelection';
 import fileMain from '@/components/addDoc/index.vue';
 import clickOnce from '@/utils/clickOnce';
 import { tableCacheKey } from '@/utils/table-cache-key';
+import { installChunkErrorRecovery } from '@/utils/chunk-error';
 import MyPD from '@/components/bpmnProcessDesigner/package/index.js';
 import '@/components/bpmnProcessDesigner/package/theme/viewer.scss';
 import 'bpmn-js/dist/assets/diagram-js.css';
@@ -32,6 +33,8 @@ if (!window.__POWERED_BY_QIANKUN__) {
 Vue.config.productionTip = false;
 /* 表格列设置缓存 key(拼当前用户 id,见 utils/table-cache-key.js) */
 Vue.prototype.$tableCacheKey = tableCacheKey;
+/* 部署新版本后旧页面拉旧 chunk 会白屏(ChunkLoadError):命中即自动刷新一次 */
+installChunkErrorRecovery(router);
 Vue.directive('click-once', clickOnce);
 Vue.component('HeaderTitle', HeaderTitle);
 Vue.component('DictSelection', DictSelection);

+ 91 - 0
src/utils/chunk-error.js

@@ -0,0 +1,91 @@
+/**
+ * 静态资源版本错配(ChunkLoadError)自愈。
+ *
+ * 场景:部署了新版本后,仍停留在旧页面 / 旧入口的应用去加载旧 chunk,
+ * 而服务器上旧 chunk 已被新构建替换 → nginx 404 回退成 index.html →
+ * 浏览器把 HTML 当 JS 解析,先报
+ *   ChunkLoadError: Loading chunk xxxx failed
+ * 再报
+ *   SyntaxError: Unexpected token '<'
+ * 页面直接白屏,用户只能自己强制刷新(还常常刷不掉)。
+ *
+ * 这里做统一兜底:命中即整页刷新一次,让用户直接落到新版本。
+ * 用 sessionStorage 做冷却,避免「服务端资源真的缺失」时无限刷新。
+ */
+
+const RELOAD_AT_KEY = 'hr-chunk-reload-at';
+/** 冷却时间:这段时间内已刷新过就不再刷,防止死循环 */
+const RELOAD_COOLDOWN = 60 * 1000;
+
+/** webpack 的 chunk 加载失败(jsonp / css chunk) */
+const CHUNK_ERROR_RULES = [
+  /Loading chunk [^\s]+ failed/i,
+  /Loading CSS chunk/i,
+  /ChunkLoadError/i,
+];
+/** chunk 拿到 HTML(404 回退)后被当成脚本执行/求值时的报错 */
+const SCRIPT_ERROR_RULES = [
+  /Unexpected token '</,
+  /error occurs while executing normal script/i,
+];
+
+/** 是否属于「静态资源版本错配」类错误 */
+export function isChunkLoadError(reason) {
+  const text = `${reason?.name || ''} ${reason?.message || reason || ''}`.trim();
+  if (!text) return false;
+  // 接口返回 HTML 时的 JSON 解析错(…is not valid JSON)不归这里,别误刷新
+  if (/is not valid JSON/i.test(text)) return false;
+  return [...CHUNK_ERROR_RULES, ...SCRIPT_ERROR_RULES].some((rule) =>
+    rule.test(text),
+  );
+}
+
+/**
+ * 命中 chunk 加载失败 → 刷新页面。
+ * 返回 true 表示「已消化该错误」(调用方不必再弹错误提示)。
+ */
+export function reloadForChunkError(reason) {
+  if (!isChunkLoadError(reason)) return false;
+
+  let last = 0;
+  try {
+    last = Number(window.sessionStorage.getItem(RELOAD_AT_KEY) || 0) || 0;
+  } catch (error) {
+    last = 0;
+  }
+  if (last && Date.now() - last < RELOAD_COOLDOWN) {
+    /* 刚刷新过又失败:多半是服务端资源确实缺失(部署不完整),别再刷 */
+    console.error(
+      '[page-hr] 页面资源加载失败,且刚刚已刷新过仍未恢复,请检查服务器上的静态资源是否与 index.html 同版本',
+      reason,
+    );
+    return true;
+  }
+  try {
+    window.sessionStorage.setItem(RELOAD_AT_KEY, String(Date.now()));
+  } catch (error) {
+    /* 存储不可用也不能阻断刷新 */
+  }
+  console.warn('[page-hr] 检测到静态资源版本已更新,正在刷新页面…', reason);
+  window.location.reload();
+  return true;
+}
+
+/**
+ * 安装兜底:路由懒加载失败 + 全局未捕获错误 / 未处理的 Promise 拒绝。
+ * 在 main.js 里创建完 router 后调用一次。
+ */
+export function installChunkErrorRecovery(router) {
+  if (router && typeof router.onError === 'function') {
+    router.onError((error) => {
+      reloadForChunkError(error);
+    });
+  }
+  window.addEventListener('unhandledrejection', (event) => {
+    if (reloadForChunkError(event.reason)) event.preventDefault();
+  });
+  window.addEventListener('error', (event) => {
+    /* 资源加载失败的 error 事件没有 event.error(是元素上的事件),这里只处理真正的异常 */
+    if (event.error) reloadForChunkError(event.error);
+  });
+}

+ 82 - 0
src/utils/download.js

@@ -0,0 +1,82 @@
+/**
+ * 统一的「文件流下载」工具:解析 Content-Disposition 文件名 + 触发浏览器下载。
+ *
+ * ★ 文件名解码(2026-10-14 修复「导出文件名乱码」):
+ *   后端给中文文件名有两种姿势 ——
+ *   ① filename*=UTF-8''%E5%90%88%E5%90%8C...  (RFC 5987 标准形式)
+ *   ② filename="%E5%90%88%E5%90%8C..."        (直接把 UTF-8 百分号编码塞进 filename,本项目后端在用)
+ *   两种都必须 decodeURIComponent,否则浏览器保存的文件名就是一串 %E5%90%88 乱码。
+ *   解码失败(非法编码序列)时原样返回,不让下载挂掉。
+ *
+ * ★ 业务错误拦截:导出接口出错时常以「HTTP 200 + JSON」返回(如 {"code":"-1","message":"..."}),
+ *   不拦截就会把错误 JSON 存成 .xlsx,打开全是乱码 —— 命中即抛出 message。
+ */
+
+/** 安全解码:非法百分号序列(如文件名本身含 %)原样返回 */
+function decodeFileName(value) {
+  if (!value) return value;
+  try {
+    return decodeURIComponent(value);
+  } catch (error) {
+    return value;
+  }
+}
+
+/** 从 Content-Disposition 里解析文件名(filename* 优先于 filename,均做解码) */
+function parseDispositionFileName(disposition) {
+  const text = String(disposition || '');
+  const extended = text.match(/filename\*=(?:UTF-8'')?([^;]+)/i);
+  if (extended) {
+    return decodeFileName(extended[1].replace(/^"|"$/g, ''));
+  }
+  const plain = text.match(/filename="?([^";]+)"?/i);
+  return plain ? decodeFileName(plain[1]) : '';
+}
+
+function readBlobText(blob) {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader();
+    reader.onload = () => resolve(String(reader.result || ''));
+    reader.onerror = () => reject(new Error('读取下载内容失败'));
+    reader.readAsText(blob, 'utf-8');
+  });
+}
+
+/**
+ * 下载接口返回的文件流。
+ * @param {Object} response axios response(要求调用时 responseType: "blob")
+ * @param {string} fallbackName 接口没给文件名时的兜底文件名
+ */
+export async function downloadBlobResponse(response, fallbackName) {
+  const contentType = String(response.headers?.['content-type'] || '');
+  const payload = response.data;
+  if (payload instanceof Blob && /json/i.test(contentType)) {
+    // 后端业务错误常以「HTTP 200 + JSON」返回:不拦截就会把错误 JSON 存成 .xlsx(乱码)
+    const text = await readBlobText(payload);
+    let message = '';
+    try {
+      const data = JSON.parse(text);
+      message = data?.message || data?.msg || '';
+    } catch (error) {
+      /* 非 JSON 错误体,走默认文案 */
+    }
+    throw new Error(message || '下载失败:接口返回了错误信息而不是文件');
+  }
+  const blob =
+    payload instanceof Blob
+      ? payload
+      : new Blob([payload], {
+          type: contentType || 'application/octet-stream',
+        });
+  const fileName =
+    parseDispositionFileName(response.headers?.['content-disposition']) ||
+    fallbackName;
+  const url = URL.createObjectURL(blob);
+  const link = document.createElement('a');
+  link.href = url;
+  link.download = fileName;
+  document.body.appendChild(link);
+  link.click();
+  document.body.removeChild(link);
+  URL.revokeObjectURL(url);
+}

+ 69 - 0
src/utils/employee-status-dict.js

@@ -0,0 +1,69 @@
+import { getDictByCode } from '@/api/sys';
+
+/**
+ * 员工状态字典(hr_user_status)—— 新增/编辑员工档案的下拉、列表「状态」筛选共用同一份。
+ *
+ * 约定(2026-10-14 与用户确认,字典抓包修正):
+ * - 选项完全由字典决定(label = 字典文案,value = 字典里的数字状态码);
+ * - **下拉 value / 提交的 status / 筛选参数一律用该数字码**,前端不另维护一套码表;
+ * - 实测码空间:1=正式在职、6=试用期、7=待岗、8=已离职(与 swagger 注释「1-6」不符,
+ *   以字典/实际数据为准 —— 老代码 {试用期:6, 劳务:7, 离职:8} 才是对的)。
+ *
+ * ★ 不再做「字典失败就用内置表兜底」:失败会明确抛错,由调用方提示,
+ *   避免出现「看起来能选、其实和后端不是一个码」的静默降级。
+ */
+let cachedOptions = null;
+let inflightRequest = null;
+
+/**
+ * ★ 字典值的真实结构(2026-10-14 抓包确认)是单键对象数组:
+ *   data: [{6:"试用期"}, {1:"正式在职"}, {7:"待岗"}, {8:"已离职"}]
+ *   即「数字状态码: 中文文案」。getDictByCode 对这种结构归一成
+ *   {label: 中文, value: 中文, key: 数字码} —— value 被放成了文案,
+ *   而员工状态的 value 必须是数字状态码(要和接口 status 对上),
+ *   所以这里在 value 不是数字、key 是数字时改取 key。
+ */
+function toOptions(items) {
+  return (Array.isArray(items) ? items : [])
+    .map((item) => {
+      const label = String(item?.label ?? item?.name ?? '').trim();
+      let value = item?.value ?? item?.id ?? item?.key;
+      if (!/^\d+$/.test(String(value)) && /^\d+$/.test(String(item?.key ?? ''))) {
+        value = item.key;
+      }
+      if (!label || value == null || value === '') return null;
+      return { label, value: String(value) };
+    })
+    .filter(Boolean);
+}
+
+/**
+ * 取员工状态选项(带缓存 + 并发合并,表单与筛选各调一次只发一个请求)
+ * @param {{ force?: boolean }} options force=true 强制重新拉取
+ * @returns {Promise<Array<{label: string, value: string}>>}
+ */
+export function loadEmployeeStatusOptions({ force = false } = {}) {
+  if (!force && cachedOptions?.length) return Promise.resolve(cachedOptions);
+  if (!force && inflightRequest) return inflightRequest;
+  inflightRequest = (async () => {
+    try {
+      const items = await getDictByCode('hr_user_status');
+      const options = toOptions(items);
+      if (!options.length) {
+        throw new Error('员工状态字典(hr_user_status)未返回任何选项,请在字典管理中配置');
+      }
+      cachedOptions = options;
+      return options;
+    } finally {
+      inflightRequest = null;
+    }
+  })();
+  return inflightRequest;
+}
+
+/** 按字典值取文案(取不到返回空串,调用方自行决定回退) */
+export function employeeStatusLabel(value) {
+  if (!cachedOptions || value == null || value === '') return '';
+  const hit = cachedOptions.find((item) => String(item.value) === String(value));
+  return hit ? hit.label : '';
+}

+ 58 - 0
src/utils/gender.js

@@ -0,0 +1,58 @@
+/**
+ * 性别(全项目唯一口径)
+ *
+ * 后端 `sex`(员工档案 / 用户)与 `gender`(录用审批、候选人)是同一套码:
+ *   **1 = 男,2 = 女**。
+ *
+ * ★ 下拉选项只有「男 / 女」两项,value 就是这套码,label 才是中文 ——
+ *   提交给接口的是 1 / 2,不再是「男」「女」文本(旧实现直接提交中文,与后端码表对不上)。
+ * ★ 没有值就是没有值:**不设「未知」选项**,回显时也留空(下拉显示 placeholder),
+ *   既不编造「男」,也不提交该字段(见 toApiGender / toApiSex 的 undefined)。
+ * ★ 回填统一走 normalizeGender():后端码、字符串码、历史中文都能落到同一个 value。
+ */
+
+export const GENDER_MALE = '1';
+export const GENDER_FEMALE = '2';
+
+/** 性别下拉选项(value = 接口码;没有「未知」项,未填即空) */
+export const GENDER_OPTIONS = [
+  { label: '男', value: GENDER_MALE },
+  { label: '女', value: GENDER_FEMALE },
+];
+
+const GENDER_CODES = [GENDER_MALE, GENDER_FEMALE];
+/** 后端把「没有值」写成 0 时,同样按留空处理(不当作异常值,不打印告警) */
+const GENDER_EMPTY_CODES = ['0'];
+/** 兼容历史数据:旧表单/旧接口里存的是中文性别 */
+const GENDER_TEXT_TO_CODE = { 男: GENDER_MALE, 女: GENDER_FEMALE };
+
+/**
+ * 任意来源的性别值 → 下拉 value('1' | '2' | '')
+ * @param {*} value 后端 sex(1 / '1' / 0)、接口已转好的中文('男')
+ * @returns {'1'|'2'|''} 取不到值 / 无法识别 → ''(留空,不显示任何选项)
+ */
+export function normalizeGender(value) {
+  if (value === null || value === undefined) return '';
+  const text = String(value).trim();
+  if (!text || GENDER_EMPTY_CODES.includes(text)) return '';
+  if (GENDER_CODES.includes(text)) return text;
+  const mapped = GENDER_TEXT_TO_CODE[text];
+  if (mapped) return mapped;
+  console.warn(`[性别] 无法识别的性别值「${text}」,下拉将留空`);
+  return '';
+}
+
+/**
+ * 下拉 value → 提交给接口的性别码('1' | '2')。
+ * 没有值 → undefined,调用方(compact / 序列化)会把它从请求体里去掉,即不提交该字段。
+ */
+export function toApiGender(value) {
+  const code = normalizeGender(value);
+  return code || undefined;
+}
+
+/** 下拉 value → 提交给接口的性别码(数字 1 / 2);没有值同样不提交(undefined) */
+export function toApiSex(value) {
+  const code = toApiGender(value);
+  return code === undefined ? undefined : Number(code);
+}

+ 12 - 99
src/views/contractManagement/index.vue

@@ -483,6 +483,7 @@ import {
   voidContract,
   getContractTemplatePage,
   getContractTemplateVersions,
+  exportContractLedger,
   CONTRACT_APPROVAL_BUSINESS_KEY,
   CONTRACT_STATUS_OPTIONS,
   contractStatusLabel,
@@ -502,8 +503,6 @@ import { getCurrentUser } from '@/utils/token-util';
 import { getByCode } from '@/api/system/dictionary-data';
 // 岗位性质字典与岗位档案同源:api/sys 的 getDictByCode 已兼容结构化项与「{ 1: "全职" }」老格式
 import { getDictByCode } from '@/api/sys';
-// 合同列表导出:后端无导出接口,本地生成 .xlsx(与员工档案导出口径一致)
-import * as XLSX from 'xlsx';
 
 const CONTRACT_TYPES = [
   { value: 'FIXED', label: '固定期限劳动合同' },
@@ -617,69 +616,10 @@ function joinAttachmentIds(value) {
   return ids.length ? ids.join(',') : undefined;
 }
 
-/** 合同附件文件主键:优先 attachmentIds,其次 files / offlineFileId */
-function collectAttachmentFileIds(item = {}) {
-  const fromIds = parseAttachmentIds(item.attachmentIds);
-  if (fromIds.length) return fromIds;
-  const files = Array.isArray(item.files) ? item.files : [];
-  const attach = files.filter((file) => ['ATTACHMENT', 'SCAN', 'ARCHIVE'].includes(file.filePurpose));
-  const source = attach.length ? attach : files;
-  const fromFiles = source
-    .map((file) => file.fileId || file.id)
-    .filter((id) => id != null && id !== '')
-    .map((id) => String(id));
-  if (fromFiles.length) return fromFiles;
-  return parseAttachmentIds([item.offlineFileId, item.fileId]);
-}
-
 function isArchived(row = {}) {
   return Boolean(row.archived || row.statusCode === 'ARCHIVED' || row.status === '已归档');
 }
 
-/**
- * 合同列表导出列(与页面表格所见列一致,去掉「序号 / 操作」)。
- * 员工列在表格里合并显示为「姓名 / 工号」,导出拆成两列便于 Excel 筛选。
- */
-const CONTRACT_EXPORT_COLUMNS = [
-  { header: '合同编号', value: (r) => r.contractNo || '' },
-  { header: '员工姓名', value: (r) => r.employeeName || '' },
-  { header: '工号', value: (r) => r.employeeNo || r.employeeCode || '' },
-  { header: '所属机构', value: (r) => r.legalEntity || '' },
-  { header: '所属部门', value: (r) => r.department || '' },
-  { header: '岗位名称', value: (r) => r.positionName || '' },
-  { header: '合同类型', value: (r) => r.contractType || '' },
-  { header: '签署方式', value: (r) => r.signMethod || '' },
-  { header: '合同状态', value: (r) => r.status || '' },
-  { header: '是否已归档', value: (r) => (isArchived(r) ? '已归档' : '未归档') },
-  { header: '签订日期', value: (r) => r.signDate || '' },
-  { header: '创建时间', value: (r) => r.createTime || '' },
-  { header: '创建人', value: (r) => r.createUserName || '' },
-  { header: '修改时间', value: (r) => r.updateTime || '' },
-  { header: '修改人', value: (r) => r.updateUserName || '' },
-];
-
-/**
- * 本地生成合同列表 .xlsx。
- * ★ 后端 `/hr/contracts/**` 未提供导出接口(swagger 全量核对过),因此不走 downloadBlob,
- *   直接按页面列在前端生成真实 .xlsx。
- * @returns {number} 写入的数据行数(不含表头)
- */
-function writeContractExportXlsx(rows, fileName) {
-  const header = CONTRACT_EXPORT_COLUMNS.map((column) => column.header);
-  const body = (rows || []).map((row) =>
-    CONTRACT_EXPORT_COLUMNS.map((column) => column.value(row)),
-  );
-  const sheet = XLSX.utils.aoa_to_sheet([header, ...body]);
-  // 中文按 2 字宽估算,保证表头不被截断
-  sheet['!cols'] = CONTRACT_EXPORT_COLUMNS.map((column) => ({
-    wch: Math.max(10, column.header.length * 2 + 4),
-  }));
-  const book = XLSX.utils.book_new();
-  XLSX.utils.book_append_sheet(book, sheet, '劳动合同');
-  XLSX.writeFile(book, fileName);
-  return body.length;
-}
-
 function isVoided(row = {}) {
   const code = String(row.statusCode || '').toUpperCase();
   return code === 'VOIDED' || code === 'CANCELLED' || row.status === '已作废';
@@ -1583,55 +1523,28 @@ export default {
       }
     },
     /**
-     * 导出合同列表。
-     * ★ 后端 `/hr/contracts/**` 没有导出接口(已核对 swagger 全量路径),旧实现只弹
-     *   「文档未提供对应接口」的提示。现改为:按当前筛选条件拉全量 → 前端生成 .xlsx。
+     * 导出劳动合同台账。
+     * ★ 走后端 POST /hr/contracts/export:复用分页查询的组织范围 + 当前页面全部筛选条件,
+     *   pageNum / size 不传(文档:不限制导出范围),由后端生成 Excel 文件流。
+     *   失败(含「HTTP 200 + JSON 业务错误」)由 downloadBlob 抛出 → 这里统一提示,不做兜底。
      */
     async exportList() {
       if (this.exporting) return;
       this.exporting = true;
       try {
-        const rows = await this.fetchAllContractsForExport();
-        if (!rows.length) {
-          this.$message.warning('当前筛选条件下没有可导出的合同');
-          return;
-        }
-        const fileName = `劳动合同_${new Date().toISOString().slice(0, 10)}.xlsx`;
-        const count = writeContractExportXlsx(rows, fileName);
-        this.$message.success(`已导出 ${count} 条`);
+        await exportContractLedger({
+          keyword: this.filters.keyword || undefined,
+          contractType: this.filters.contractType || undefined,
+          contractStatus: this.filters.contractStatus || undefined,
+          departmentId: this.filters.departmentId || undefined,
+        });
+        this.$message.success('导出成功');
       } catch (error) {
         this.$message.error(error.message || '导出失败');
       } finally {
         this.exporting = false;
       }
     },
-    /**
-     * 导出取数:页面是服务端分页(fetchPage 只拿当页),导出需按同一筛选条件翻页拉全量。
-     * 按 id 去重兜底:若后端忽略 pageNum 反复返回同一页,也不会导出重复数据。
-     */
-    async fetchAllContractsForExport() {
-      const size = 200;
-      const maxPages = 100; // 安全上限:最多 2 万条
-      const params = {
-        keyword: this.filters.keyword || undefined,
-        contractType: this.filters.contractType || undefined,
-        contractStatus: this.filters.contractStatus || undefined,
-        departmentId: this.filters.departmentId || undefined,
-      };
-      const map = new Map();
-      for (let page = 1; page <= maxPages; page += 1) {
-        const data = await getContractPage({ ...params, pageNum: page, size });
-        const list = (data?.list || data?.records || []).map(adaptContract);
-        if (!list.length) break;
-        list.forEach((row) => {
-          const key = row.id != null ? String(row.id) : `no-${row.contractNo}-${map.size}`;
-          map.set(key, row);
-        });
-        const total = Number(data?.count ?? data?.total ?? 0);
-        if (total && map.size >= total) break;
-      }
-      return [...map.values()];
-    },
     async openCreate() {
       this.createMode = 'create';
       this.changeSnapshot = blankChangeSnapshot();

+ 13 - 14
src/views/hiring/hiringApproval/components/ApprovalFormDrawer.vue

@@ -38,7 +38,8 @@
               <el-col :span="12">
                 <el-form-item label="性别">
                   <el-select v-model="form.gender" clearable placeholder="请选择性别" class="full-width">
-                    <el-option v-for="item in genderOptions" :key="item" :label="item" :value="item" />
+                    <el-option v-for="item in genderOptions" :key="item.value" :label="item.label"
+                      :value="item.value" />
                   </el-select>
                 </el-form-item>
               </el-col>
@@ -197,6 +198,8 @@ import { listOrganizations } from "@/api/organization";
 /* 机构选项取登录缓存(loginChangeGroupVOList);组织列表接口仅用于部门级联 */
 import { getOrgOptions } from "@/utils/current-org";
 import { getDictByCode } from "@/api/sys";
+/* 性别:接口口径 1 男 / 2 女(下拉 value 即接口码);未填就是空,不提交该字段 */
+import { GENDER_OPTIONS, normalizeGender } from "@/utils/gender";
 import { showRequestError } from "@/utils/message";
 
 const emptyForm = () => ({
@@ -205,6 +208,7 @@ const emptyForm = () => ({
   applicationId: null,
   candidateId: null,
   candidateName: "",
+  // 性别:接口码(1 男 / 2 女);默认留空,不默认成「男」
   gender: "",
   contactMobile: "",
   education: "",
@@ -247,7 +251,6 @@ const probationMonthOptions = [
   { value: 6, label: "6个月" },
 ];
 
-const DEFAULT_GENDER_OPTIONS = ["男", "女"];
 const DEFAULT_EDUCATION_OPTIONS = [
   { label: "初中", value: "初中", key: "初中" },
   { label: "高中", value: "高中", key: "高中" },
@@ -284,7 +287,7 @@ export default {
       departmentLoading: false,
       form: emptyForm(),
       probationMonthOptions,
-      genderOptions: [...DEFAULT_GENDER_OPTIONS],
+      genderOptions: [...GENDER_OPTIONS],
       educationOptions: [...DEFAULT_EDUCATION_OPTIONS],
       positionOptions: [],
       positionSequenceOptions: [],
@@ -450,7 +453,8 @@ export default {
           detail.personId ??
           null,
         candidateName: detail.candidateName || detail.name || "",
-        gender: detail.gender || "",
+        // 性别:接口码、历史中文都归一到下拉 value;没有值就留空(不显示任何选项)
+        gender: normalizeGender(detail.gender),
         contactMobile:
           detail.contactMobile || detail.phone || detail.mobile || "",
         education: this.resolveEducation(detail.education || ""),
@@ -493,15 +497,11 @@ export default {
     },
     async loadDictOptions() {
       try {
-        const [genderItems, educationItems] = await Promise.all([
-          getDictByCode("gender").catch(() => []),
-          getDictByCode("education").catch(() =>
-            getDictByCode("education_background").catch(() => []),
-          ),
-        ]);
-        const genders = (genderItems || [])
-          .map((item) => String(item.value ?? item.label ?? "").trim())
-          .filter(Boolean);
+        /* 性别不走字典:接口口径固定 1 男 / 2 女(见 utils/gender),
+           字典项与接口码不同源,取字典会把 value 改成中文,提交就对不上了 */
+        const educationItems = await getDictByCode("education").catch(() =>
+          getDictByCode("education_background").catch(() => []),
+        );
         const educations = (educationItems || [])
           .map((item) => {
             const label = String(item.label ?? item.value ?? "").trim();
@@ -513,7 +513,6 @@ export default {
             return { label, value: key, key };
           })
           .filter(Boolean);
-        if (genders.length) this.genderOptions = genders;
         if (educations.length) this.educationOptions = educations;
         this.syncEducation();
       } catch (e) {

+ 40 - 0
src/views/hiring/hiringApproval/index.vue

@@ -59,6 +59,11 @@
           <el-link v-if="canEdit(row)" type="primary" :underline="false" icon="el-icon-edit" @click="openEdit(row)">
             编辑
           </el-link>
+          <!-- 仅草稿(尚未发起审批流程)可删除 → el-link 不支持 loading,故用 disabled 防连点 -->
+          <el-link v-if="canDelete(row)" type="danger" :underline="false" icon="el-icon-delete"
+            :disabled="submitting" @click="handleDelete(row)">
+            删除
+          </el-link>
           <el-link v-if="canShowApprovalRecord(row)" type="primary" :underline="false" icon="el-icon-tickets"
             @click="openApprovalRecord(row)">
             审批记录
@@ -93,6 +98,7 @@
 <script>
 import {
   adaptHiringApproval,
+  deleteHiringApproval,
   getHiringApprovalPage,
   HIRING_APPROVAL_BUSINESS_KEY,
   HIRING_APPROVED_STATUS,
@@ -103,6 +109,7 @@ import processSubmitDialog from "@/BIZComponents/processSubmitDialog/processSubm
 import BpmProcessDetailDialog from "@/views/bpm/processInstance/detailDialog.vue";
 import { showRequestError } from "@/utils/message";
 import { getCurrentUser } from "@/utils/token-util";
+import submitGuard from "@/mixins/submit-guard";
 import ApprovalFormDrawer from "./components/ApprovalFormDrawer.vue";
 import OfferLaunchDialog from "../components/OfferLaunchDialog.vue";
 
@@ -119,6 +126,8 @@ export default {
     processSubmitDialog,
     BpmProcessDetailDialog,
   },
+  /* 删除草稿属写操作:共用 submitting 锁防连点(见 mixins/submit-guard) */
+  mixins: [submitGuard],
   data() {
     return {
       loading: false,
@@ -286,6 +295,37 @@ export default {
       // 草稿与已驳回可编辑后重新提交
       return ["DRAFT", "REJECTED"].includes(row.statusCode);
     },
+    /**
+     * 是否可删除:按接口文档**只有尚未发起审批流程的 DRAFT 草稿**可逻辑删除,
+     * 审批中 / 已通过 / 已驳回 / 已取消必须保留流程与业务审计记录 → 不展示删除入口。
+     * 无状态码的历史数据视为草稿(与 adaptHiringApproval 的默认口径一致)。
+     */
+    canDelete(row) {
+      return Boolean(row?.id) && (row.statusCode || "DRAFT") === "DRAFT";
+    },
+    /**
+     * 删除草稿:DELETE /hr/recruit/hiring-approvals/{id}
+     * ★ 确认框必须放在 runSubmit 的 task 内:放在锁外的话确认期间没上锁,
+     *   连点会弹出两个确认框、发出两次删除请求。
+     */
+    handleDelete(row) {
+      if (!this.canDelete(row)) {
+        this.$message.warning("仅草稿状态的录用单可删除");
+        return Promise.resolve();
+      }
+      return this.runSubmit(
+        async () => {
+          await this.$confirm("删除后不可恢复,是否删除该录用审批草稿?", "删除确认", {
+            type: "warning",
+            confirmButtonText: "确定删除",
+            cancelButtonText: "取消",
+          });
+          await deleteHiringApproval(row.id);
+          this.reloadTable();
+        },
+        { success: "删除成功", errorMessage: "删除失败" },
+      );
+    },
     // 审批通过且尚未发送 Offer 才可发起(已成功发送邮件 / 已确认线下通知的不再展示)
     canLaunchOffer(row) {
       return (

+ 85 - 36
src/views/hrManagement/employeeArchive/components/EmployeeArchiveForm.vue

@@ -45,8 +45,8 @@
                 <el-col :xs="24" :sm="12"><el-form-item label="姓名" prop="name"><el-input v-model.trim="form.name"
                       maxlength="20" placeholder="请输入员工真实姓名" /></el-form-item></el-col>
                 <el-col :xs="24" :sm="12"><el-form-item label="性别" prop="gender"><el-select v-model="form.gender"
-                      class="full-width"><el-option v-for="item in genderOptions" :key="item" :label="item"
-                        :value="item" /></el-select></el-form-item></el-col>
+                      class="full-width"><el-option v-for="item in genderOptions" :key="item.value"
+                        :label="item.label" :value="item.value" /></el-select></el-form-item></el-col>
                 <el-col :xs="24" :sm="12"><el-form-item label="身份证" prop="idCard"><el-input v-model.trim="form.idCard"
                       maxlength="18" placeholder="请输入18位身份证号" @input="handleIdCardInput"
                       @blur="parseIdCard" /></el-form-item></el-col>
@@ -639,8 +639,11 @@ import {
   getDepartmentPositionType,
   getDepartmentPositionLevels,
 } from '@/api/hr';
-import { listOrganizations, getFactoryAreaPage, getUserPage, EMPLOYEE_STATUS, CERT_TYPE } from '@/api/organization';
+import { listOrganizations, getFactoryAreaPage, getUserPage, EMPLOYEE_STATUS, EMPLOYEE_STATUS_CODE, CERT_TYPE } from '@/api/organization';
 import { getDictByCode } from '@/api/sys';
+import { loadEmployeeStatusOptions } from '@/utils/employee-status-dict';
+/* 性别:接口口径 1 男 / 2 女(下拉 value 即接口码);没有值就留空,不提交该字段 */
+import { GENDER_OPTIONS, normalizeGender } from '@/utils/gender';
 import { buildOrganizationTree, flattenOrganizationTree } from '@/utils/organization-tree';
 import { toDocIds } from '@/components/addDoc/util';
 import { positionAllowanceUnitOptions } from '@/enum/dict.js';
@@ -651,14 +654,15 @@ import { getCurrentOrgId, getOrgOptions } from '@/utils/current-org';
 
 const emptyContract = () => ({ category: '', name: '', signDate: '', startDate: '', endDate: '', files: [], status: '生效', remark: '' });
 const emptyForm = () => ({
-  id: null, avatar: '', name: '', employeeNo: '', gender: '男', birthDate: '', age: null, idCard: '', idCardExpiry: '', ethnic: '汉族',
+  id: null, avatar: '', name: '', employeeNo: '', gender: '', birthDate: '', age: null, idCard: '', idCardExpiry: '', ethnic: '汉族',
   maritalStatus: '未婚', politicalStatus: '群众', nativePlace: '', householdType: '', healthStatus: '优',
   company: '中盈产业集团有限公司', legalEntity: '', groupId: null, factory: '', factoryId: null, deptId: null, deptIds: [], deptName: '',
   position: '', positionId: null, positionIds: [], positionBindings: [],
   // 岗位序列(部门 → 序列 → 岗位 → 性质 → 层级,与办理入职一致)
   positionSequenceId: '', positionSequence: '', positionSequenceName: '',
   positionType: '', level: '', staffingLevel: '', dutyLevel: '', manager: '', managerId: null,
-  employmentType: '', employmentTypeName: '', status: '全职', hireDate: '', regularDate: '', account: '', enabled: true,
+  // 新建默认「正式在职」:下拉 value 是 hr_user_status 字典的数字状态码,'1' 即「正式在职」
+  employmentType: '', employmentTypeName: '', status: '1', hireDate: '', regularDate: '', account: '', enabled: true,
   phone: '', email: '', wechat: '', workAddress: '', currentAddress: '', registeredAddress: '', emergencyContact: '', emergencyRelation: '', emergencyPhone: '', familyMembers: [],
   education: '本科', school: '', major: '', graduationDate: '',
   educations: [],
@@ -761,7 +765,8 @@ export default {
       managerOptions: [],
       managerLoading: false,
       ethnicOptions: ['汉族', '土家族', '苗族', '侗族', '回族', '瑶族', '其他'],
-      genderOptions: ['男', '女', '未知'],
+      genderOptions: [...GENDER_OPTIONS],
+      // 员工状态下拉:由 hr_user_status 字典填充(见 loadStatusOptions)
       statusOptions: [],
       maritalOptions: ['未婚', '已婚', '离异', '丧偶'],
       educationOptions: ['小学', '初中', '高中', '大专', '本科', '硕士', '博士'],
@@ -998,45 +1003,85 @@ export default {
     visible(value) { if (value) this.loadForm(); }
   },
   methods: {
-    /** 员工状态下拉选项:接口字典 hr_user_status(label=中文名,value=状态编码)。
-     *  下拉 value 用字典编码,回显按状态码匹配、提交直接传编码;
-     *  字典为空或加载失败时,用 EMPLOYEE_STATUS(中文→数字码)兜底,保证一定能选。 */
+    /**
+     * 员工状态下拉选项:直接取 hr_user_status 字典(value = 字典值,即后端 status),
+     * 新增/编辑与列表「状态」筛选共用同一个加载器(utils/employee-status-dict)。
+     * 字典拿不到就明确报错并留空,不再用内置码表静默顶替。
+     */
     async loadStatusOptions() {
-      const fallback = Object.entries(EMPLOYEE_STATUS).map(([label, code]) => ({ label, value: String(code) }));
       try {
-        const items = await getDictByCode('hr_user_status');
-        const options = (Array.isArray(items) ? items : [])
-          .map((item) => {
-            const label = String(item.label ?? item.name ?? '').trim();
-            const value = item.value ?? item.id ?? item.key;
-            if (!label || value == null || value === '') return null;
-            return { label, value: String(value) };
-          })
-          .filter(Boolean);
-        this.statusOptions = options.length ? options : fallback;
+        this.statusOptions = await loadEmployeeStatusOptions();
       } catch (error) {
-        this.statusOptions = fallback;
+        this.statusOptions = [];
+        console.error('[员工档案] 员工状态字典加载失败:', error);
+        this.$message.error(error.message || '员工状态字典加载失败');
       }
-      // 字典晚于表单加载时,把已回填的中文/旧值重新对齐成字典 value
-      if (this.form.status) {
-        const hit = this.statusOptions.find(
-          (opt) => String(opt.value) === String(this.form.status) || opt.label === this.form.status,
-        );
-        if (hit) this.form.status = hit.value;
+      // 字典可能在弹框打开之后才回来:用字典值把已回填的值对齐一次(只对齐字典里存在的值)
+      if (this.visible && this.employee) {
+        const next = this.resolveStatusForForm(this.employee);
+        if (this.statusOptions.some((opt) => String(opt.value) === String(next))) {
+          this.form.status = next;
+        }
       }
     },
-    /** 回显员工状态:优先用后端状态码匹配字典 value,其次按中文 label 匹配 */
-    resolveStatusForForm(source) {
-      const code = source.statusCode ?? source.raw?.status;
-      const byCode = code != null && code !== ''
-        ? this.statusOptions.find((opt) => String(opt.value) === String(code))
-        : null;
-      if (byCode) return byCode.value;
+    /**
+     * 回显员工状态:下拉 value 一律是**字典里的数字状态码**,所以按数字码匹配。
+     * ★ 字典实测码空间:1=正式在职、6=试用期、7=待岗、8=已离职(swagger 注释 1-6 与实际不符)。
+     * ① 字典里有该状态码 → 直接用(正常路径,如 status=1 → 「正式在职」);
+     * ② 字典里没有 → 用 EMPLOYEE_STATUS_CODE 把码转成中文再按字典文案匹配,命中即用字典值,并 warn;
+     * ③ 仍命中不了 → 原码原样回显(会显示成数字,一眼能看出字典缺项),不编造文案。
+     * ④ 历史数据没有状态码时才按中文文案反查。
+     * ⚠ 取码只认 `statusCode`(adaptEmployee 里 = 后端原始 status),其次是 `raw.status`:
+     *   千万别读 `source.status`,它在 adaptEmployee 里是列表态
+     *   (active / probation / inactive / disabled)。
+     * ⚠ 原实现 `return this.statusOptions[0]` 已删除——两个条件都失配时它会直接回落成
+     *   字典第一项,这正是「列表已离职、编辑里变待入职」的来源。
+     */
+    resolveStatusForForm(source = {}) {
+      const rawCode = source.statusCode ?? source.raw?.status;
+      const code = rawCode == null || rawCode === '' ? null : Number(rawCode);
+      if (code != null && Number.isFinite(code) && code > 0) {
+        const value = String(code);
+        if (this.statusOptions.some((opt) => String(opt.value) === value)) return value;
+        const backendLabel = EMPLOYEE_STATUS_CODE[code] || '';
+        const byDictLabel = backendLabel
+          ? this.statusOptions.find(
+            (opt) => opt.label === backendLabel || opt.label.includes(backendLabel),
+          )
+          : null;
+        if (byDictLabel) {
+          console.warn(
+            `[员工档案] 员工状态字典缺 ${value}(后端语义「${backendLabel}」),已按文案回显为「${byDictLabel.label}」`,
+          );
+          return byDictLabel.value;
+        }
+        console.warn(`[员工档案] 员工状态字典缺 ${value}(后端语义「${backendLabel}」),该状态下拉按原码显示`);
+        return value;
+      }
+      // 历史数据没有状态码时才按中文反查;仍匹配不到就保留原值(不编造一个状态出来)
       const byLabel = this.statusOptions.find(
         (opt) => opt.label === source.formStatus || opt.label === source.status,
       );
       if (byLabel) return byLabel.value;
-      return this.statusOptions.length ? this.statusOptions[0].value : (source.formStatus || '');
+      return source.formStatus || source.status || '';
+    },
+    /** 离职页签强制写状态时用的值:优先取字典里的「离职」项,字典缺项才退回后端码 */
+    resolveLeaveStatusValue() {
+      const hit = this.statusOptions.find((opt) => opt.label.includes('离职'));
+      if (hit) return hit.value;
+      console.warn('[员工档案] 员工状态字典里没有「离职」项,离职保存将退回后端码 8');
+      return String(EMPLOYEE_STATUS['离职']);
+    },
+    /**
+     * 回显性别。
+     * ★ 接口码优先:adaptEmployee 里 `raw.sex` 是后端原始码(1 男 / 2 女),
+     *   `gender` 只是它转好的中文文案;两者都过 normalizeGender 落到下拉 value('1'/'2')。
+     *   取不到值 → 留空(下拉显示 placeholder),不编造「男」,也不显示「未知」选项。
+     */
+    resolveGenderForForm(source = {}) {
+      const rawSex = source.raw?.sex ?? source.sex;
+      if (rawSex != null && rawSex !== '') return normalizeGender(rawSex);
+      return normalizeGender(source.gender);
     },
     loadForm() {
       this._skipFormValidate = true;
@@ -1051,6 +1096,9 @@ export default {
         legalEntity: source.legalEntity || source.company || '',
         company: source.company || source.legalEntity || '',
         status: this.resolveStatusForForm(source),
+        /* 性别:下拉 value 就是接口码(1 男 / 2 女),回填统一走 normalizeGender;
+           接口没给值(或值无法识别)就留空,不编造「男」也不显示「未知」 */
+        gender: this.resolveGenderForForm(source),
         hireDate: source.hireDate || source.joinDate || '',
         factoryId: source.factoryId != null && source.factoryId !== '' ? String(source.factoryId) : null,
         factory: source.factory || source.factoryName || '',
@@ -2530,7 +2578,8 @@ export default {
           school: firstEducation.school || this.form.school || '',
           major: firstEducation.major || this.form.major || '',
           graduationDate: firstEducation.graduationDate || this.form.graduationDate || '',
-          status: leaveEnabled ? '离职' : this.form.status,
+          // 离职页签一旦启用,状态固定为「离职」;取字典里的离职项值(与下拉同一套值空间)
+          status: leaveEnabled ? this.resolveLeaveStatusValue() : this.form.status,
           age: this.calculateAge(this.form.birthDate),
         };
         // done 必须被调用;用回调而不是依赖 resolve/reject,避免漏调导致永久 await

+ 89 - 39
src/views/hrManagement/employeeArchive/index.vue

@@ -45,9 +45,10 @@
                 </el-form-item>
               </el-col>
               <el-col v-bind="styleResponsive ? { lg: 6, md: 12 } : { span: 6 }">
-                <el-form-item label="状态" class="status-item">
+                <el-form-item label="员工状态" class="status-item">
+                  <!-- 选项来自 hr_user_status 字典,选中值即字典值(直接作为接口 status 参数) -->
                   <el-select v-model="filters.status" clearable placeholder="请选择" style="width: 100%">
-                    <el-option v-for="item in statusOptions" :key="item.value" :label="item.label"
+                    <el-option v-for="item in filterStatusOptions" :key="item.value" :label="item.label"
                       :value="item.value" />
                   </el-select>
                 </el-form-item>
@@ -55,8 +56,8 @@
               <el-col v-bind="styleResponsive ? { lg: 6, md: 12 } : { span: 6 }">
                 <el-form-item label="性别" class="gender-item">
                   <el-select v-model="filters.gender" clearable placeholder="请选择" style="width: 100%">
-                    <el-option label="男" value="男" />
-                    <el-option label="女" value="女" />
+                    <el-option v-for="item in genderOptions" :key="item.value" :label="item.label"
+                      :value="item.value" />
                   </el-select>
                 </el-form-item>
               </el-col>
@@ -138,9 +139,11 @@
               </div>
             </template>
             <template v-slot:status="{ row }">
-              <el-tag size="mini" :type="statusTagType(row.status)">
-                {{ statusLabel(row.status) }}
+              <!-- 状态:直接用接口返回的 statusName(后端新增字段),不再由本地状态码派生文案 -->
+              <el-tag v-if="row.statusName" size="mini" :type="statusNameTagType(row.statusName)">
+                {{ row.statusName }}
               </el-tag>
+              <span v-else>-</span>
             </template>
             <template v-slot:action="{ row }">
               <!-- 员工列已可点开详情,此处移除原「查看」入口 -->
@@ -188,21 +191,15 @@ import {
 import { writeEmployeeTemplateXlsx, downloadEmployeeTemplate } from '@/utils/employee-template';
 /* 员工档案可见机构范围:只取登录缓存所属机构(sessionStorage.loginChangeGroupVOList) */
 import { getOrgOptions, getCurrentOrgId } from '@/utils/current-org';
+/* 「状态」筛选项:与新增/编辑员工档案共用 hr_user_status 字典(value = 字典值 = 后端 status) */
+import { loadEmployeeStatusOptions } from '@/utils/employee-status-dict';
+/* 性别:接口口径 1 男 / 2 女(筛选项与新增/编辑同一套码,前端按码过滤) */
+import { GENDER_OPTIONS, normalizeGender } from '@/utils/gender';
 
-const LIST_STATUS_TO_API = {
-  active: undefined,
-  probation: '5',
-  inactive: '6',
-  disabled: undefined
-};
-
-/* 列表状态筛选项与指标卡配置(原 ./mock.js 内联,mock 文件已删除) */
-const STATUS_OPTIONS = [
-  { value: 'active', label: '在职', type: 'success' },
-  { value: 'probation', label: '试用期', type: 'warning' },
-  { value: 'inactive', label: '已离职', type: 'info' },
-  { value: 'disabled', label: '待岗', type: 'danger' }
-];
+/* 列表「状态」列的文案已改为**直接用接口返回的 statusName**(后端新增字段),
+   本地不再维护 active/probation/inactive/disabled 四态文案表(原 STATUS_OPTIONS 已删);
+   tag 颜色按 statusName 文案关键词匹配,见 statusNameTagType()。
+   筛选的下拉与参数仍用 hr_user_status 字典的数字码(见 loadFilterStatusOptions)。 */
 
 /* 员工档案导出/模板列已抽到 utils/employee-template.js —— 表头与《员工档案导入模板.xlsx》逐列对齐,
    本文件不再维护 CSV 列清单,避免两处口径漂移 */
@@ -215,7 +212,10 @@ export default {
       loading: false,
       organizationTree: [],
       orgById: {},
-      statusOptions: STATUS_OPTIONS,
+      /* 「状态」筛选的下拉选项:hr_user_status 字典(与新增/编辑员工档案同一份) */
+      filterStatusOptions: [],
+      /* 「性别」筛选项:与新增/编辑同一套接口码(1 男 / 2 女) */
+      genderOptions: [...GENDER_OPTIONS],
       treeProps: { children: 'children', label: 'name' },
       treeKeyword: '',
       treeCollapsed: false,
@@ -286,8 +286,12 @@ export default {
       const tags = [];
       if (this.selectedOrganizationId) tags.push({ key: 'organization', label: this.currentOrganizationName });
       if (this.appliedFilters.keyword) tags.push({ key: 'keyword', label: `关键词:${this.appliedFilters.keyword}` });
-      if (this.appliedFilters.status) tags.push({ key: 'status', label: `状态:${this.statusLabel(this.appliedFilters.status)}` });
-      if (this.appliedFilters.gender) tags.push({ key: 'gender', label: `性别:${this.appliedFilters.gender}` });
+      if (this.appliedFilters.status !== '' && this.appliedFilters.status != null) {
+        tags.push({ key: 'status', label: `员工状态:${this.filterStatusLabel(this.appliedFilters.status)}` });
+      }
+      if (this.appliedFilters.gender) {
+        tags.push({ key: 'gender', label: `性别:${this.genderFilterLabel(this.appliedFilters.gender)}` });
+      }
       if (this.appliedFilters.hireRange?.length) {
         tags.push({ key: 'hireRange', label: `入职:${this.appliedFilters.hireRange.join(' 至 ')}` });
       }
@@ -302,6 +306,8 @@ export default {
   created() {
     this.syncOrgScope();
     this.bootstrap();
+    // 字典不阻塞首屏(首屏仍是「组织列表 + 一页员工」两个请求),异步补齐筛选项
+    this.loadFilterStatusOptions();
   },
   methods: {
     /** 可见机构范围:只取登录缓存所属机构列表,默认定位到当前登录机构 */
@@ -441,12 +447,11 @@ export default {
       }
     },
     needsClientFilter() {
-      const { status, gender, hireRange, keyword } = this.appliedFilters;
+      // 状态筛选已改走接口(字典值当 status 参数),不再需要前端全量拉取后过滤
+      const { gender, hireRange, keyword } = this.appliedFilters;
       return Boolean(
         gender
         || hireRange?.length
-        || status === 'disabled'
-        || status === 'active'
         || (keyword && keyword.trim())
       );
     },
@@ -468,23 +473,43 @@ export default {
       const { status } = this.appliedFilters;
       const params = {
         ...this.scopeParams(),
-        isQueryLZ: status === 'inactive' ? 1 : 0
+        // isQueryLZ=1 才会返回离职员工:选中字典里的「离职」项时必须带上,否则筛出来是空的
+        isQueryLZ: this.isLeaveStatusValue(status) ? 1 : 0
       };
-      if (status && LIST_STATUS_TO_API[status] !== undefined) {
-        params.status = LIST_STATUS_TO_API[status];
+      // 状态直接用字典值当接口参数(后端 status,string)
+      if (status !== '' && status != null) {
+        params.status = status;
       }
       return params;
     },
+    /** 该筛选值是否「离职」状态(字典码 8 或文案含「离职」),用于决定 isQueryLZ。
+     *  ★ 字典实测码空间:1=正式在职、6=试用期、7=待岗、8=已离职 —— 6 不是离职。 */
+    isLeaveStatusValue(value) {
+      if (value === '' || value == null) return false;
+      if (String(value) === '8') return true;
+      const label = this.filterStatusOptions.find((item) => String(item.value) === String(value))?.label || '';
+      return label.includes('离职');
+    },
+    /** 「状态」筛选项:hr_user_status 字典;拉不到就留空并提示(不做内置兜底) */
+    async loadFilterStatusOptions() {
+      try {
+        this.filterStatusOptions = await loadEmployeeStatusOptions();
+      } catch (error) {
+        this.filterStatusOptions = [];
+        console.error('[员工档案] 员工状态字典加载失败:', error);
+        this.$message.error(error.message || '员工状态字典加载失败');
+      }
+    },
     applyClientFilters(rows) {
-      const { status, gender, hireRange, keyword } = this.appliedFilters;
+      // 状态筛选由接口按字典值过滤(见 buildBaseParams),此处只处理性别 / 入职区间 / 关键词
+      const { gender, hireRange, keyword } = this.appliedFilters;
       const normalized = String(keyword || '').trim().toLowerCase();
       return rows.filter((employee) => {
-        if (gender && employee.gender !== gender) return false;
+        // 性别按接口码比较(后端码优先,兼容接口已转好的中文);没有性别值的行不属于任何性别筛选
+        if (gender && this.resolveGenderCode(employee) !== String(gender)) return false;
         if (hireRange?.length) {
           if (!employee.hireDate || employee.hireDate < hireRange[0] || employee.hireDate > hireRange[1]) return false;
         }
-        if (status === 'disabled' && employee.status !== 'disabled') return false;
-        if (status === 'active' && employee.status !== 'active') return false;
         if (normalized) {
           const haystack = [employee.name, employee.employeeNo, employee.phone, employee.position, employee.deptName, employee.email]
             .join(' ')
@@ -545,11 +570,33 @@ export default {
         return { list: [], count: 0 };
       }
     },
-    statusLabel(status) {
-      return this.statusOptions.find((item) => item.value === status)?.label || '未知';
-    },
-    statusTagType(status) {
-      return this.statusOptions.find((item) => item.value === status)?.type || 'info';
+    /**
+     * 「状态」列的 tag 颜色:文案由接口 statusName 给,颜色按文案关键词匹配(沿用原四态配色)。
+     * 未命中任何关键词时用默认灰(info),不猜状态语义。
+     */
+    statusNameTagType(name) {
+      const text = String(name || '');
+      if (text.includes('离职')) return 'info';
+      if (text.includes('试用')) return 'warning';
+      if (text.includes('待岗')) return 'danger';
+      if (text.includes('在职') || text.includes('正式')) return 'success';
+      return 'info';
+    },
+    /** 「员工状态」筛选值的文案:入参是 hr_user_status 字典值 */
+    filterStatusLabel(value) {
+      const hit = this.filterStatusOptions.find((item) => String(item.value) === String(value));
+      return hit ? hit.label : String(value);
+    },
+    /** 行的性别码:后端原始码优先(adaptEmployee 保留了 raw.sex),其次接口转好的中文文案 */
+    resolveGenderCode(employee = {}) {
+      const rawSex = employee.raw?.sex ?? employee.sex;
+      if (rawSex != null && rawSex !== '') return normalizeGender(rawSex);
+      return normalizeGender(employee.gender);
+    },
+    /** 「性别」筛选值的文案:入参是接口码(1 / 2 / 0) */
+    genderFilterLabel(value) {
+      const hit = GENDER_OPTIONS.find((item) => item.value === String(value));
+      return hit ? hit.label : String(value);
     },
     isCompanyNode(data) {
       return [10, 20, '10', '20', 'group', 'company'].includes(data.type);
@@ -606,7 +653,10 @@ export default {
         const detail = await this.loadEmployeeDetail(employee.id);
         this.editingEmployee = {
           ...detail,
-          status: detail.formStatus || detail.status,
+          /* ⚠ 不要在这里把 status 覆写成中文(旧写法 status: detail.formStatus)。
+             表单的员工状态只看后端码 statusCode,下拉 value 是字典里的同一套数字码
+             (实测 1=正式在职、6=试用期、7=待岗、8=已离职),
+             覆写成中文会让值空间对不上;中文文案由字典按码派生。 */
           // 列表已有文案时兜底,避免详情缺 postName / joinDate 导致岗位回显成裸 ID、入职时间为空
           position: detail.position || employee.position || '',
           hireDate: detail.hireDate || employee.hireDate || '',

+ 2 - 0
src/views/hrManagement/itemReceiptManagement/index.vue

@@ -527,6 +527,8 @@ export default {
       const saved = await panel.persist({ silent: false });
       if (saved) {
         this.editing.id = saved.id;
+        // 保存成功即自动关闭弹窗(列表已由 onSavedFromPanel 刷新)
+        this.editVisible = false;
       }
     },
     onEditClosed() {

+ 8 - 3
src/views/hrManagement/jobManagement/index.vue

@@ -50,11 +50,11 @@
       </ele-pro-table>
     </el-card>
     <!-- 编辑弹窗 -->
-    <user-edit :visible.sync="showEdit" :data="current" :controlList="controlList" :isView="isView" @done="reload"
+    <user-edit :visible.sync="showEdit" :data="current" :controlList="controlList" :isView="isView" @done="refreshList"
       ref="userEdit" :typeList="typeOptions" />
 
     <importDialog :defModule="moduleName" ref="importDialogRef" :fileUrl="'/main/profession/downLoadTemplate'"
-      fileName="工种导入模板" @success="reload" />
+      fileName="工种导入模板" @success="refreshList" />
   </div>
 </template>
 
@@ -241,11 +241,16 @@ export default {
       return res;
     },
 
-    /* 刷新表格 */
+    /* 搜索条件变化时刷新(仅搜索组件传入 where) */
     reload(where) {
       this.searchParams = where;
       this.$refs.table.reload({ page: 1, where: where });
     },
+    /* 新增/修改/导入成功后刷新:忽略回传的行数据,沿用当前查询条件,
+       否则行数据会被当成 where 拼进分页参数,导致列表只剩这一条 */
+    refreshList() {
+      this.$refs.table.reload({ page: 1 });
+    },
     /* 打开编辑弹窗 */
     openEdit(row, isView) {
       this.isView = !!isView;

+ 1 - 1
src/views/position/positionApplication/index.vue

@@ -309,7 +309,7 @@
             </div>
             <el-button class="add-line" icon="el-icon-plus" @click="addSkill">新增技能</el-button>
             <div class="form-subtitle">技能证书</div>
-            <div v-for="(cert, index) in form.certificates" :key="cert.name + index" class="dynamic-row cert-row">
+            <div v-for="(cert, index) in form.certificates" :key="index" class="dynamic-row cert-row">
               <el-input v-model.trim="cert.name" placeholder="证书名称" />
               <el-select v-model="cert.category" placeholder="证书分类">
                 <el-option v-for="item in idTypeOptions" :key="item.key || item.value" :label="item.label"

+ 0 - 257
src/views/position/positionManagement/components/SkillLibraryDialog.vue

@@ -1,257 +0,0 @@
-<template>
-  <el-dialog title="管理技能库" :visible.sync="dialogVisible" width="920px" append-to-body
-    custom-class="skill-library-dialog" @open="onOpen">
-    <div class="library-toolbar">
-      <el-input v-model="keyword" clearable prefix-icon="el-icon-search" placeholder="搜索技能编码或名称" class="keyword-input"
-        @keyup.enter.native="loadList" @clear="loadList" />
-      <el-select v-model="category" clearable placeholder="技能分类" class="category-select" @change="loadList">
-        <el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
-      </el-select>
-      <el-button type="primary" icon="el-icon-plus" @click="openForm()">新增技能</el-button>
-      <el-button icon="el-icon-refresh" @click="loadList">刷新</el-button>
-    </div>
-
-    <el-table v-loading="loading" :data="list" border stripe height="420">
-      <el-table-column type="index" label="序号" width="60" align="center" />
-      <el-table-column prop="skillCode" label="技能编码" min-width="120" />
-      <el-table-column prop="skillName" label="技能名称" min-width="140" />
-      <el-table-column prop="skillCategory" label="分类" width="120" />
-      <el-table-column label="认证方式" width="110">
-        <template v-slot="{ row }">{{ certMethodName(row.certMethod) }}</template>
-      </el-table-column>
-      <el-table-column prop="skillDesc" label="说明" min-width="180" show-overflow-tooltip />
-      <el-table-column label="操作" width="140" fixed="right">
-        <template v-slot="{ row }">
-          <el-button type="text" @click="openForm(row)">编辑</el-button>
-          <el-button type="text" :disabled="submitting" class="danger-text" @click="remove(row)">删除</el-button>
-        </template>
-      </el-table-column>
-    </el-table>
-
-    <div class="library-pagination">
-      <el-pagination background layout="total, prev, pager, next" :current-page.sync="pageNum" :page-size="pageSize"
-        :total="total" @current-change="loadList" />
-    </div>
-
-    <el-dialog :title="form.id ? '编辑技能' : '新增技能'" :visible.sync="formVisible" width="520px" append-to-body>
-      <el-form ref="form" :model="form" :rules="rules" label-width="96px">
-        <el-form-item label="技能名称" prop="skillName">
-          <el-input v-model="form.skillName" maxlength="64" placeholder="如:数控车床操作" />
-        </el-form-item>
-        <el-form-item label="技能分类" prop="skillCategory">
-          <el-select v-model="form.skillCategory" allow-create filterable clearable placeholder="选择或输入分类"
-            style="width: 100%">
-            <el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
-          </el-select>
-        </el-form-item>
-        <el-form-item label="认证方式" prop="certMethod">
-          <el-select v-model="form.certMethod" placeholder="请选择" style="width: 100%">
-            <el-option v-for="item in certMethods" :key="item.value" :label="item.label" :value="item.value" />
-          </el-select>
-        </el-form-item>
-        <el-form-item label="技能说明">
-          <el-input v-model="form.skillDesc" type="textarea" :rows="3" maxlength="255" show-word-limit />
-        </el-form-item>
-      </el-form>
-      <div slot="footer">
-        <el-button @click="formVisible = false">取消</el-button>
-        <el-button type="primary" :loading="submitting" @click="submitForm">保存</el-button>
-      </div>
-    </el-dialog>
-  </el-dialog>
-</template>
-
-<script>
-import {
-  deleteSkill,
-  getSkillCategoryTree,
-  getSkillPage,
-  saveSkill,
-  updateSkill,
-} from '@/api/hr/skillMatrix';
-import submitGuard from '@/mixins/submit-guard';
-
-const CERT_METHODS = [
-  { value: 1, label: '内部考核' },
-  { value: 2, label: '外部考证' },
-  { value: 3, label: '实操评定' },
-  { value: 4, label: '综合评定' },
-];
-
-export default {
-  name: 'SkillLibraryDialog',
-  mixins: [submitGuard],
-  props: {
-    visible: { type: Boolean, default: false },
-  },
-  data() {
-    return {
-      loading: false,
-      keyword: '',
-      category: '',
-      categoryOptions: [],
-      list: [],
-      pageNum: 1,
-      pageSize: 20,
-      total: 0,
-      formVisible: false,
-      form: this.emptyForm(),
-      certMethods: CERT_METHODS,
-      rules: {
-        skillName: [{ required: true, message: '请输入技能名称', trigger: 'blur' }],
-        skillCategory: [{ required: true, message: '请选择技能分类', trigger: 'change' }],
-        certMethod: [{ required: true, message: '请选择认证方式', trigger: 'change' }],
-      },
-    };
-  },
-  computed: {
-    dialogVisible: {
-      get() {
-        return this.visible;
-      },
-      set(value) {
-        this.$emit('update:visible', value);
-      },
-    },
-  },
-  methods: {
-    emptyForm() {
-      return {
-        id: null,
-        skillName: '',
-        skillCategory: '',
-        certMethod: 1,
-        skillDesc: '',
-      };
-    },
-    certMethodName(value) {
-      return CERT_METHODS.find((item) => item.value === Number(value))?.label || '-';
-    },
-    async onOpen() {
-      await this.loadCategories();
-      this.pageNum = 1;
-      await this.loadList();
-    },
-    async loadCategories() {
-      try {
-        const tree = await getSkillCategoryTree();
-        const names = new Set();
-        if (tree && typeof tree === 'object') {
-          Object.keys(tree).forEach((key) => {
-            const value = tree[key];
-            if (typeof value === 'string') names.add(value);
-            else if (value && typeof value === 'object') {
-              Object.values(value).forEach((name) => {
-                if (name) names.add(String(name));
-              });
-              if (key) names.add(key);
-            } else if (key) names.add(key);
-          });
-        }
-        this.categoryOptions = [...names].filter(Boolean).sort();
-      } catch (error) {
-        this.categoryOptions = [];
-      }
-    },
-    async loadList() {
-      this.loading = true;
-      try {
-        const page = await getSkillPage({
-          pageNum: this.pageNum,
-          size: this.pageSize,
-          keyword: this.keyword || undefined,
-          skillCategory: this.category || undefined,
-          sortName: 'createTime',
-          orderBy: 'descending',
-        });
-        this.list = page?.list || [];
-        this.total = Number(page?.count || 0);
-        if (!this.categoryOptions.length) {
-          const fromList = [
-            ...new Set(this.list.map((item) => item.skillCategory).filter(Boolean)),
-          ];
-          this.categoryOptions = fromList;
-        }
-      } catch (error) {
-        this.list = [];
-        this.total = 0;
-        this.$message.error(error.message || '技能库加载失败');
-      } finally {
-        this.loading = false;
-      }
-    },
-    openForm(row) {
-      this.form = row
-        ? {
-          id: row.id,
-          skillName: row.skillName || '',
-          skillCategory: row.skillCategory || '',
-          certMethod: Number(row.certMethod || 1),
-          skillDesc: row.skillDesc || '',
-        }
-        : this.emptyForm();
-      this.formVisible = true;
-      this.$nextTick(() => this.$refs.form?.clearValidate());
-    },
-    submitForm() {
-      this.$refs.form.validate((valid) => {
-        if (!valid) return;
-        const isEdit = Boolean(this.form.id);
-        return this.runSubmit(
-          async () => {
-            if (isEdit) {
-              await updateSkill(this.form);
-            } else {
-              await saveSkill(this.form);
-            }
-            this.formVisible = false;
-            await this.loadList();
-            this.$emit('changed');
-          },
-          { success: isEdit ? '技能已更新' : '技能已新增', errorMessage: '保存失败' },
-        );
-      });
-    },
-    remove(row) {
-      return this.runSubmit(
-        async () => {
-          await this.$confirm(`确认删除技能「${row.skillName}」?`, '提示', {
-            type: 'warning',
-          });
-          await deleteSkill(row.id);
-          await this.loadList();
-          this.$emit('changed');
-        },
-        { success: '已删除', errorMessage: '删除失败' },
-      );
-    },
-  },
-};
-</script>
-
-<style scoped>
-.library-toolbar {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 10px;
-  margin-bottom: 14px;
-}
-
-.keyword-input {
-  width: 220px;
-}
-
-.category-select {
-  width: 160px;
-}
-
-.library-pagination {
-  margin-top: 14px;
-  display: flex;
-  justify-content: flex-end;
-}
-
-.danger-text {
-  color: #d45b38;
-}
-</style>

+ 1 - 17
src/views/position/positionManagement/components/SkillMatrixPanel.vue

@@ -103,7 +103,7 @@
     <aside class="side-column">
       <div class="side-card skill-library">
         <div class="side-heading">
-          <h2>技能库</h2>
+          <h2>技能分类</h2>
         </div>
         <div v-if="skillTree.length" class="skill-tree-wrap">
           <el-tree :key="'skill-tree-' + skillTree.length" :data="skillTree"
@@ -116,14 +116,9 @@
           </el-tree>
         </div>
         <div v-else class="side-empty">暂无数据</div>
-        <el-button type="primary" class="manage-skill-btn" icon="el-icon-setting" @click="libraryVisible = true">
-          管理技能库
-        </el-button>
       </div>
     </aside>
 
-    <skill-library-dialog :visible.sync="libraryVisible" @changed="onLibraryChanged" />
-
     <el-dialog title="技能差距分析" :visible.sync="analysisVisible" width="760px" append-to-body>
       <div v-loading="analysisLoading" class="analysis-body">
         <div class="analysis-metrics" v-if="analysis">
@@ -211,7 +206,6 @@
 </template>
 
 <script>
-import SkillLibraryDialog from './SkillLibraryDialog.vue';
 import {
   analyzeSkillMatrix,
   exportSkillMatrix,
@@ -236,7 +230,6 @@ const SKILL_LEVELS = [
 
 export default {
   name: 'SkillMatrixPanel',
-  components: { SkillLibraryDialog },
   props: {
     legalEntities: { type: Array, default: () => [] },
     departments: { type: Array, default: () => [] },
@@ -264,7 +257,6 @@ export default {
       displayRows: [],
       skillTree: [],
       skillCategoryOptions: [],
-      libraryVisible: false,
       analysisVisible: false,
       analysisLoading: false,
       analysis: null,
@@ -592,10 +584,6 @@ export default {
           });
       }
     },
-    onLibraryChanged() {
-      this.loadSkillTree();
-      this.reloadMatrix();
-    },
     async runGapAnalysis() {
       this.analysisVisible = true;
       this.analysisLoading = true;
@@ -979,10 +967,6 @@ export default {
   color: #475569;
 }
 
-.manage-skill-btn {
-  width: 100%;
-}
-
 .side-empty {
   padding: 28px 8px;
   color: #99a4b4;