Browse Source

首页指标卡改下钻弹窗+人员结构导出,组织接口补机构人数与导出错误提示,HR 各办理页表单与入口调整

1. 首页顶部指标卡点击不再跳路由,改为 MetricDrillDownDialog 弹窗(明细表+导出),新增入职率/离职率、异常部门主副值口径;人员结构明细与导出支持 flowType 下钻;

2. 组织接口:新增 listOrganizationsWithUserCount(/main/group/getGroupListWithUserCount)、导出/模板下载统一 downloadBlob 拦截 JSON 错误体、人员分页抓取按 total 追平判停、导入超时放宽到 2 分钟;

3. HR 办理类页面:员工档案组织树用接口人数、岗位调动表单与列表调整(含去掉撤回操作、已撤回/已取消可编辑)、转正/离职申请/离职交接/入职物品领用/合同/招聘页字段与入口微调。
lihaiting 5 days ago
parent
commit
2d9ec19a6a

+ 1 - 1
src/api/hr/index.js

@@ -1116,7 +1116,7 @@ const CONTRACT_TYPE_LABELS = {
   APPRENTICE: "学徒合同",
   OTHER: "其他",
 };
-function contractTypeLabel(code) {
+export function contractTypeLabel(code) {
   return CONTRACT_TYPE_LABELS[String(code || "").toUpperCase()] || String(code || "");
 }
 /** 签署方式 code → 中文标签 */

+ 6 - 2
src/api/hr/overview.js

@@ -31,8 +31,9 @@ function keepBigIntId(value) {
  * 查询人力资源总览
  * GET /hr/overview/data-center
  *
- * 一次性返回顶部核心指标、人员流动趋势、部门编制占比和紧急待办;
- * 统计年月需同时提供,均不传时按当前自然月统计。
+ * 一次性返回顶部核心指标、最近六个月人员流动、部门编制占比和紧急待办;
+ * 统计年月需同时提供,均不传时按当前自然月月末统计。
+ * 入离职率以月初在职人数为分母;合同到期按统计日起可配置预警天数内且未完成续签的合同去重统计。
  *
  * @param {object} params
  * @param {string|number} [params.organizationId] 统计所属组织,不传表示全部授权组织
@@ -40,6 +41,8 @@ function keepBigIntId(value) {
  * @param {number} [params.year] 四位年份(与 month 同时传)
  * @param {number} [params.month] 统计月份 1-12(与 year 同时传)
  * @param {number} [params.trendMonthCount] 流动趋势展示月份数 1-12,默认 6
+ * @param {number} [params.contractWarningDays] 合同到期预警天数,默认 30;
+ *   统计区间为统计日(含)至统计日加该天数(含)。不传则使用后端默认值
  */
 export async function getHrOverviewDataCenter(params = {}) {
   return unwrap(
@@ -50,6 +53,7 @@ export async function getHrOverviewDataCenter(params = {}) {
         year: params.year,
         month: params.month,
         trendMonthCount: params.trendMonthCount,
+        contractWarningDays: params.contractWarningDays,
       }),
     }),
   );

+ 6 - 3
src/api/hr/personnelStructure.js

@@ -411,7 +411,8 @@ export async function getEffectivenessAnalysis(params = {}, filters = {}) {
 
 /**
  * 分页查询人员结构明细
- * 仅返回统计日在岗人员,支持 dimensionCode + dimensionValueCode 组合下钻
+ * 仅返回统计日在岗人员,支持 dimensionCode + dimensionValueCode 组合下钻;
+ * 顶部指标卡下钻走 flowType:ON_JOB=总员工数、ONBOARD=本月入职
  */
 export async function getStructureDetails(params = {}, filters = {}) {
   const pageNum = Number(params.pageNum) > 0 ? Number(params.pageNum) : 1;
@@ -420,6 +421,7 @@ export async function getStructureDetails(params = {}, filters = {}) {
     await request.get(`${BASE}/details`, {
       params: compact({
         ...buildQuery(filters),
+        flowType: params.flowType || undefined,
         dimensionCode: params.dimensionCode || undefined,
         dimensionValueCode: params.dimensionValueCode || undefined,
         keyword: params.keyword || undefined,
@@ -436,15 +438,16 @@ export async function getStructureDetails(params = {}, filters = {}) {
   };
 }
 
-/** 导出人员结构明细:复用明细接口全部筛选口径 */
+/** 导出人员结构明细:复用明细接口全部筛选口径(含 flowType) */
 export async function exportStructureDetails(params = {}, filters = {}) {
   const query = compact({
     ...buildQuery(filters),
+    flowType: params.flowType || undefined,
     dimensionCode: params.dimensionCode || undefined,
     dimensionValueCode: params.dimensionValueCode || undefined,
     keyword: params.keyword || undefined,
   });
-  downloadBlob(
+  await downloadBlob(
     await request.get(`${BASE}/details/export`, {
       params: query,
       responseType: "blob",

+ 13 - 3
src/api/hr/userChange.js

@@ -139,13 +139,23 @@ export function deriveDocStatus(raw = {}) {
   return "draft";
 }
 
+/**
+ * 可编辑的单据态:草稿、已驳回、已撤回、已取消
+ * (审批中/已通过/已生效的异动单不可编辑)
+ */
+const EDITABLE_DOC_STATUS = ["draft", "rejected", "withdrawn", "cancelled"];
+
 export function canEditUserChange(row = {}) {
-  const status = String(row.approvalStatus || "").toUpperCase();
-  return status === "DRAFT" || status === "REJECTED" || status === "";
+  const status = row.docStatus || deriveDocStatus(row);
+  return EDITABLE_DOC_STATUS.includes(String(status).toLowerCase());
 }
 
+/** 可提交审批:仅草稿 / 已驳回(已撤回、已取消不展示提交入口) */
 export function canSubmitUserChange(row = {}) {
-  return Boolean(row?.id) && canEditUserChange(row);
+  const status = String(row.approvalStatus || "").toUpperCase();
+  return (
+    Boolean(row?.id) && (status === "DRAFT" || status === "REJECTED" || status === "")
+  );
 }
 
 export function canWithdrawUserChange(row = {}) {

+ 56 - 22
src/api/organization/index.js

@@ -59,9 +59,33 @@ function formatDate(value) {
   return `${y}-${m}-${d}`;
 }
 
-function downloadBlob(response, fallbackName) {
-  const blob = new Blob([response.data], {
-    type: response.headers["content-type"] || "application/octet-stream",
+/** 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);
@@ -87,6 +111,21 @@ export async function listOrganizations(params, options) {
   return loadOrganizationList(params, options);
 }
 
+/**
+ * 机构列表(带人数):GET /main/group/getGroupListWithUserCount
+ *
+ * 返回体在 getGroupList 基础上多一个 userCount(后端口径的组织人数)。
+ * 员工档案组织树直接展示该字段,前端不再逐部门请求统计人数。
+ *
+ * 不走 utils/org-list-cache(那条缓存针对 getGroupList):本接口需要按需强刷人数,
+ * 员工数据变更后要立刻拿到新的人数。
+ */
+export async function listOrganizationsWithUserCount(params) {
+  return unwrap(
+    await request.get("/main/group/getGroupListWithUserCount", { params }),
+  );
+}
+
 /** 工厂区域分页:type=1 工厂 */
 export async function getFactoryAreaPage(params = {}) {
   return unwrap(
@@ -181,7 +220,10 @@ export async function fetchAllUsers(params = {}, options = {}) {
     const chunk = Array.isArray(data?.list) ? data.list : [];
     total = Number(data?.count ?? list.length + chunk.length);
     list.push(...chunk);
-    if (!chunk.length || chunk.length < pageSize) break;
+    // ★ 短页不能直接判停:后端可能对 size 封顶(请求 500 实际只回 200),
+    //   那样会静默丢人。以 list.length 是否追平 total 为准,短页仅在已追平时才停。
+    if (!chunk.length) break;
+    if (chunk.length < pageSize && list.length >= total) break;
     pageNum += 1;
   }
   return { list, count: total === Infinity ? list.length : total };
@@ -427,26 +469,20 @@ export async function removeEmployee(ids) {
   return unwrap(await request.post("/main/user/removeById", ids));
 }
 
-export async function exportUsers(payload = {}) {
-  const response = await request.post("/main/user/exportUsers", payload, {
-    responseType: "blob",
-  });
-  downloadBlob(response, `员工档案_${formatDate(new Date())}.xlsx`);
-}
-
 export async function downloadUserTemplate() {
-  const response = await request.post(
-    "/main/user/downLoadTemplate",
-    {},
-    { responseType: "blob" },
+  await downloadBlob(
+    await request.post("/main/user/downLoadTemplate", {}, { responseType: "blob" }),
+    "人员信息导入模板.xlsx",
   );
-  downloadBlob(response, "人员信息导入模板.xlsx");
 }
 
 export async function importUsers(file) {
   const formData = new FormData();
   formData.append("file", file);
-  const response = await request.post("/main/excelUpload/importUser", formData);
+  // 导入是后端逐行落库,行数多时明显超过普通接口耗时,超时放宽到 2 分钟
+  const response = await request.post("/main/excelUpload/importUser", formData, {
+    timeout: 120000,
+  });
   if (response.data?.code == 0 || typeof response.data === "string") {
     return response.data?.data ?? response.data?.message ?? response.data;
   }
@@ -479,12 +515,10 @@ export async function removeWorkExperience(userId, workExperienceId) {
 }
 
 export async function exportOrganizations() {
-  const response = await request.post(
-    "/main/group/exportGroups",
-    {},
-    { responseType: "blob" },
+  await downloadBlob(
+    await request.post("/main/group/exportGroups", {}, { responseType: "blob" }),
+    "组织机构列表.xlsx",
   );
-  downloadBlob(response, "组织机构列表.xlsx");
 }
 
 /** 后端 status → 列表展示态 */

+ 29 - 0
src/styles/views/HomeView.scss

@@ -681,6 +681,35 @@ button {
   text-shadow: none;
 }
 
+/* 顶部卡片副值(如考勤异常卡片的异常部门数) */
+.metric-sub {
+  display: inline-flex;
+  align-items: baseline;
+  margin-top: 3px;
+  color: #9fd0e6;
+  font-size: 13px;
+  line-height: 18px;
+  font-variant-numeric: tabular-nums;
+}
+
+.metric-sub i {
+  margin-right: 5px;
+  padding: 0 5px;
+  border: 1px solid rgba(255, 176, 32, 0.4);
+  border-radius: 3px;
+  color: #ffbd45;
+  font-size: 11px;
+  font-style: normal;
+  line-height: 16px;
+  letter-spacing: 0.04em;
+}
+
+.metric-sub small {
+  margin-left: 3px;
+  color: #7eb2cd;
+  font-size: 12px;
+}
+
 .metric-card--clickable {
   cursor: pointer;
 }

+ 4 - 1
src/styles/views/contractManagement/index.scss

@@ -179,8 +179,11 @@
 .cm-create-grid .el-input-number .el-input__inner{height:32px}
 .cm-create-sign-method{display:flex;gap:8px}
 .cm-create-sign-method .cm-method-btn{flex:1;height:34px;border:1px solid var(--color-primary);border-radius:4px;background:#fff;color:var(--color-primary);font-size:14px;cursor:pointer;transition:all .15s}
-.cm-create-sign-method .cm-method-btn.is-active{background:var(--color-primary);color:#fff;box-shadow:0 2px 6px var(--color-primary-2)}
+/* 未开通的选项(线上签订):弱化展示,点击只提示不切换。放在 is-active 之前,编辑历史线上合同时仍显示选中态 */
+.cm-create-sign-method .cm-method-btn.is-unavailable{border-color:#dfe6ef;color:#9aa5b3;background:#f7f9fc;cursor:not-allowed}
+.cm-create-sign-method .cm-method-btn.is-active{border-color:var(--color-primary);background:var(--color-primary);color:#fff;box-shadow:0 2px 6px var(--color-primary-2)}
 .cm-create-sign-method .cm-method-btn:hover:not(.is-active){background:var(--color-primary-1)}
+.cm-create-sign-method .cm-method-btn.is-unavailable:hover:not(.is-active){background:#f2f5fa}
 .cm-create-tips{font-size:14px;color:#7e8b9d;line-height:1.8;padding:10px 12px;background:#fafbfd;border-radius:4px;margin-top:8px}
 .cm-create-tips p{margin:0}
 .cm-create-tips p+p{margin-top:3px}

+ 3 - 0
src/styles/views/employeeArchive/index.scss

@@ -73,6 +73,9 @@
 .employee-table::before { display: none; }
 .employee-table ::v-deep .ele-table-tool { min-height: 70px; padding: 13px 20px; border-bottom: 1px solid #e8edf3; }
 .employee-table ::v-deep .ele-table-tool-title { min-width: 0; align-items: center; }
+/* 工具栏按钮之间留出间距(导入/导出被 el-dropdown 包裹,默认的 el-button 相邻间距不生效) */
+.employee-table ::v-deep .ele-table-tool-title-content { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
+.employee-table ::v-deep .ele-table-tool-title-content > .el-button { margin: 0; }
 .employee-table ::v-deep .ele-table-tool-title-label { flex: 0 0 auto; }
 .employee-table ::v-deep .ele-table-tool-title-label h6 { margin: 0; color: #26364c; font-size: 17px; font-weight: 600; }
 .employee-table ::v-deep .ele-table-tool-title-label > div { margin-top: 3px; color: #8d99a9; font-size: 14px; }

+ 8 - 0
src/styles/views/positionTransfer/index.scss

@@ -112,6 +112,14 @@
 .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 15px; }
 .transfer-form ::v-deep .el-form-item__label { padding-bottom: 5px; color: #4a586d; font-size: 14px; line-height: 19px; }
 .transfer-form ::v-deep .el-select { width: 100%; }
+/* 签署方式:与合同管理(.cm-create-sign-method/.cm-method-btn)保持一致的分段按钮样式 */
+.pt-sign-method { display: flex; gap: 8px; }
+.pt-method-btn { flex: 1; min-width: 0; height: 34px; padding: 0 8px; border: 1px solid var(--color-primary); border-radius: 4px; color: var(--color-primary); font-size: 14px; white-space: nowrap; background: #fff; cursor: pointer; transition: all .15s; }
+/* 未开通的选项(线上模板):弱化展示,点击只提示不切换。放在 is-active 之前,历史线上单据仍能显示选中态 */
+.pt-method-btn.is-unavailable { border-color: #dfe6ef; color: #9aa5b3; background: #f7f9fc; cursor: not-allowed; }
+.pt-method-btn.is-active { border-color: var(--color-primary); color: #fff; background: var(--color-primary); box-shadow: 0 2px 6px var(--color-primary-2); }
+.pt-method-btn:hover:not(.is-active) { background: var(--color-primary-1); }
+.pt-method-btn.is-unavailable:hover:not(.is-active) { background: #f2f5fa; }
 .transfer-upload ::v-deep .el-upload__tip { margin-top: 6px; color: #8a96a7; font-size: 14px; }
 .drawer-footer { position: absolute; right: 0; bottom: 0; left: 0; z-index: 5; min-height: 68px; padding: 11px 20px; display: flex; align-items: center; justify-content: space-between; gap: 13px; border-top: 1px solid #dfe6ef; background: #fff; box-shadow: 0 -8px 20px rgba(40,67,106,.06); }
 .drawer-footer > span { color: #7e8a9a; font-size: 14px; }

+ 3 - 0
src/styles/views/regularizationHandling/index.scss

@@ -25,6 +25,9 @@
 /* 签署方式切换按钮(与合同管理 cm-method-btn 同风格) */
 .sign-method-switch{display:inline-flex;border:1px solid var(--color-primary);border-radius:4px;overflow:hidden;min-width:240px}
 .sign-method-btn{flex:1;height:34px;padding:0 18px;border:0;background:#fff;color:var(--color-primary);font-size:14px;cursor:pointer;transition:all .15s}
+/* 未开通的选项(线上签署):弱化展示,点击只提示不切换。放在 is-active 之前,历史线上转正单仍能显示选中态 */
+.sign-method-btn.is-unavailable{color:#9aa5b3;background:#f7f9fc;cursor:not-allowed}
 .sign-method-btn.is-active{background:var(--color-primary);color:#fff;box-shadow:0 2px 6px var(--color-primary-2)}
 .sign-method-btn:hover:not(.is-active):not(:disabled){background:var(--color-primary-1)}
+.sign-method-btn.is-unavailable:hover:not(.is-active){background:#f2f5fa;color:#9aa5b3}
 .sign-method-btn:disabled{cursor:not-allowed;opacity:.6}

+ 3 - 3
src/views/attendance/rule/index.vue

@@ -45,7 +45,7 @@
         :page-size="20" :page-sizes="[10, 20, 50]" class="rule-table">
         <template v-slot:toolbar>
           <el-button type="primary" icon="el-icon-plus" class="ele-btn-icon" @click="openCreate">
-            新增打卡规则
+            新建
           </el-button>
         </template>
         <template v-slot:name="{ row }">
@@ -326,7 +326,7 @@ export default {
       viewMode: 'detail',
       deviceLogs: [],
       dialogVisible: false,
-      dialogTitle: '新增打卡规则',
+      dialogTitle: '新建打卡规则',
       dialogReadonly: false,
       activeTab: 'method',
       dialogTabs: [
@@ -770,7 +770,7 @@ export default {
       await this.loadLeavePolicies(form);
       this.form = form;
       this.activeTab = 'method';
-      this.dialogTitle = '新增打卡规则';
+      this.dialogTitle = '新建打卡规则';
       this.dialogReadonly = false;
       this.dialogVisible = true;
     },

+ 28 - 3
src/views/contractManagement/index.vue

@@ -281,18 +281,22 @@
               </el-form-item>
               <el-form-item label="签署方式" prop="signMethod">
                 <div class="cm-create-sign-method">
-                  <button type="button" class="cm-method-btn" :class="{ 'is-active': createForm.signMethod === '线上' }"
+                  <button type="button" class="cm-method-btn is-unavailable"
+                    :class="{ 'is-active': createForm.signMethod === '线上' }" title="暂无开通模板功能,敬请期待"
                     @click="onSignMethodChange('线上')">线上签订</button>
                   <button type="button" class="cm-method-btn" :class="{ 'is-active': createForm.signMethod === '线下' }"
                     @click="onSignMethodChange('线下')">线下签订</button>
                 </div>
               </el-form-item>
               <el-form-item v-if="isOnlineSign" label="合同模版" prop="templateId">
-                <el-select v-model="createForm.templateId" placeholder="请选择合同模版" @change="onTemplateChange">
+                <!-- 模板功能暂未开通:编辑历史线上合同时该字段仍会渲染,点开下拉即提示 -->
+                <el-select v-model="createForm.templateId" placeholder="请选择合同模版" @change="onTemplateChange"
+                  @visible-change="onTemplateDropdown">
                   <el-option v-for="t in contractTemplates" :key="String(t.id)" :label="t.name" :value="t.id" />
                 </el-select>
               </el-form-item>
-              <el-form-item v-else label="合同附件" prop="fileIds" required class="is-span-2">
+              <!-- 合同附件与合同模版同占签署方式右侧的同一单元格,不再整行铺开 -->
+              <el-form-item v-else label="合同附件" prop="fileIds" required>
                 <fileMain v-model="createForm.fileIds" size="small" />
               </el-form-item>
             </div>
@@ -1866,8 +1870,29 @@ export default {
       }
       this.$refs.approvalRecordDialog?.open({ processInstanceId: id });
     },
+    /** 线上模板功能未开通的统一提示:先关掉上一条,避免连点堆叠多条提示 */
+    warnTemplateDisabled() {
+      this.$message.closeAll();
+      this.$message.warning('暂无开通模板功能,敬请期待');
+    },
+    /** 合同模版下拉展开时提示(编辑历史线上合同时会落到这里) */
+    onTemplateDropdown(visible) {
+      if (visible) this.warnTemplateDisabled();
+    },
     onSignMethodChange(method) {
+      // 线上签订依赖模板生成正文,模板未开通时只提示、不切换签署方式
+      if (method === '线上') {
+        this.warnTemplateDisabled();
+        return;
+      }
+      if (this.createForm.signMethod === method) return;
       this.createForm.signMethod = method;
+      // 模板与附件互斥:切换时把另一侧已选/已传的内容清空,避免提交时同时带上模板与附件造成重复
+      this.createForm.templateId = null;
+      this.createForm.templateVersionId = null;
+      this.createForm.templateVersionNo = null;
+      this.createForm.fileIds = [];
+      this.createForm.fileId = null;
       this.$nextTick(() => this.$refs.createFormRef?.clearValidate(['templateId', 'fileIds']));
     },
     // generateAndSubmit 已废弃:/hr/contracts/{id}/submit 接口不再使用,提交审批改由 launchApproval 走公共审批组件

+ 14 - 6
src/views/hiring/hiringApproval/index.vue

@@ -42,7 +42,9 @@
           <span v-else>-</span>
         </template>
         <template v-slot:candidate="{ row }">
-          {{ row.candidateName || "-" }}
+          <el-link v-if="row.candidateName" type="primary" :underline="false" @click="openView(row)">{{ row.candidateName
+            }}</el-link>
+          <span v-else>-</span>
         </template>
         <template v-slot:salary="{ row }">
           {{ row.proposedSalary || "-" }}
@@ -147,6 +149,7 @@ export default {
           formatter: (_r, _c, value) => value || "-",
         },
         {
+          prop: "candidate",
           columnKey: "candidate",
           label: "候选人",
           minWidth: 120,
@@ -171,7 +174,7 @@ export default {
           prop: "proposedSalary",
           columnKey: "proposedSalary",
           label: "拟定薪资(元/月)",
-          width: 150,
+          width: 140,
           align: "left",
           slot: "salary",
         },
@@ -194,7 +197,7 @@ export default {
           prop: "offerSent",
           columnKey: "offerSent",
           label: "是否已发送Offer",
-          width: 220,
+          width: 150,
           align: "left",
           formatter: (_r, _c, value) =>
             value ? "已成功发送邮件或已确认线下通知" : "尚未发送",
@@ -210,7 +213,7 @@ export default {
           prop: "createTime",
           columnKey: "createTime",
           label: "创建时间",
-          width: 110,
+          width: 100,
           align: "left",
         },
         {
@@ -224,7 +227,7 @@ export default {
           prop: "updateTime",
           columnKey: "updateTime",
           label: "修改时间",
-          width: 110,
+          width: 100,
           align: "left",
         },
         {
@@ -283,8 +286,13 @@ export default {
       // 草稿与已驳回可编辑后重新提交
       return ["DRAFT", "REJECTED"].includes(row.statusCode);
     },
+    // 审批通过且尚未发送 Offer 才可发起(已成功发送邮件 / 已确认线下通知的不再展示)
     canLaunchOffer(row) {
-      return Boolean(row?.id) && HIRING_APPROVED_STATUS.has(row.statusCode);
+      return (
+        Boolean(row?.id) &&
+        HIRING_APPROVED_STATUS.has(row.statusCode) &&
+        !row?.offerSent
+      );
     },
     openOffer(row) {
       if (!this.canLaunchOffer(row)) {

+ 2 - 2
src/views/hiring/offerNotify/index.vue

@@ -173,7 +173,7 @@ export default {
           prop: "email",
           columnKey: "email",
           label: "邮箱",
-          minWidth: 160,
+          minWidth: 140,
           align: "left",
           showOverflowTooltip: true,
           formatter: (_r, _c, value) => value || "-",
@@ -195,7 +195,7 @@ export default {
         {
           columnKey: "attachment",
           label: "附件",
-          minWidth: 160,
+          minWidth: 80,
           align: "left",
           slot: "attachment",
         },

+ 760 - 0
src/views/homeView/components/MetricDrillDownDialog.vue

@@ -0,0 +1,760 @@
+<template>
+  <el-dialog
+    :visible.sync="show"
+    :title="config ? config.title : '下钻明细'"
+    custom-class="metric-drill-dialog"
+    append-to-body
+    width="1100px"
+    top="6vh"
+    :close-on-click-modal="false"
+  >
+    <div v-if="config" class="drill-body" v-loading="loading">
+      <!-- 口径行:与卡片同源的统计口径 + 数量 -->
+      <div class="drill-meta">
+        <span>口径:{{ caliberText }}</span>
+        <span class="drill-meta__divider">|</span>
+        <span>共 <b>{{ totalText }}</b> {{ config.countUnit }}</span>
+        <div class="drill-actions">
+          <button type="button" class="drill-btn drill-btn--primary" :disabled="exporting" @click="exportRows(true)">
+            <i class="el-icon-download"></i>导出 Excel
+          </button>
+          <button type="button" class="drill-btn" @click="show = false">
+            <i class="el-icon-close"></i>关闭
+          </button>
+        </div>
+      </div>
+
+      <!-- 明细表 -->
+      <el-table :data="rows" stripe class="drill-table" empty-text="暂无数据">
+        <el-table-column
+          v-for="column in config.columns"
+          :key="column.prop"
+          :prop="column.prop"
+          :label="column.label"
+          :width="column.width"
+          :min-width="column.minWidth"
+          :fixed="column.fixed"
+          align="left"
+          show-overflow-tooltip
+        >
+          <template v-if="column.slot === 'status'" v-slot="{ row }">
+            <el-tag size="mini" :type="statusMeta(row).type">{{ statusMeta(row).label }}</el-tag>
+          </template>
+          <template v-else-if="column.slot === 'days'" v-slot="{ row }">
+            <el-tag size="mini" type="danger">{{ row[column.prop] }} 天</el-tag>
+          </template>
+          <template v-else-if="column.slot === 'action'" v-slot="{ row }">
+            <el-link
+              v-for="action in config.actionTexts"
+              :key="action"
+              type="primary"
+              :underline="false"
+              class="drill-action"
+              @click="handleAction(action, row)"
+            >
+              {{ action }}
+            </el-link>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <!-- 底部:数量 + 分页 + 导出本页 -->
+      <div class="drill-footer">
+        <span class="drill-footer__total">共 <b>{{ totalText }}</b> 条</span>
+        <el-pagination
+          v-if="config.paginated !== false"
+          layout="prev, pager, next"
+          small
+          :total="total"
+          :page-size="size"
+          :current-page="page"
+          :pager-count="5"
+          class="drill-pager"
+          @current-change="handlePageChange"
+        />
+        <button type="button" class="drill-btn drill-btn--primary" :disabled="exporting" @click="exportRows(false)">
+          <i class="el-icon-download"></i>导出本页
+        </button>
+      </div>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+import * as XLSX from "xlsx";
+import {
+  getStructureDetails,
+  exportStructureDetails,
+} from "@/api/hr/personnelStructure";
+import {
+  getAttendanceExceptionReport,
+  exceptionLabel,
+} from "@/api/hr/attendance";
+import {
+  getContractUnrenewedReport,
+  contractTypeLabel,
+} from "@/api/hr/index";
+/* 考勤异常明细只回 userId,姓名/工号/部门走主数据批量接口补齐(带缓存) */
+import { loadEmployeeBriefs } from "@/api/organization";
+
+function pad2(value) {
+  return String(value).padStart(2, "0");
+}
+
+function formatDate(date) {
+  return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
+}
+
+function sliceDate(value) {
+  return String(value || "").slice(0, 10);
+}
+
+/**
+ * 考勤异常处理状态 → 文案 / 颜色。
+ * 实测后端返回 OPEN(未闭环);其余枚举未定稿,认得的翻译、不认识的原样展示。
+ */
+function attendanceStatusMeta(value) {
+  const labelMap = {
+    OPEN: "待处理",
+    PENDING: "待处理",
+    UNHANDLED: "待处理",
+    UNPROCESSED: "待处理",
+    CLOSED: "已处理",
+    HANDLED: "已处理",
+    PROCESSED: "已处理",
+    DONE: "已处理",
+  };
+  const label = labelMap[String(value || "").toUpperCase()] || String(value || "") || "待处理";
+  return { label, type: /已处理/.test(label) ? "success" : "warning" };
+}
+
+/**
+ * 顶部卡片编码 → 下钻弹窗配置。
+ * load(ctx) 返回 { list, total };exportAll(ctx) 有则「导出 Excel」优先走服务端
+ * (返回 rows 数组则继续走本地 xlsx 导出,返回空值表示已由服务端处理)。
+ */
+export const DRILL_DOWN_CONFIGS = {
+  ON_JOB_TOTAL: {
+    title: "总员工数 · 下钻明细",
+    countUnit: "人",
+    caliber({ statisticsDate }) {
+      return `状态=在职且入职日期≤统计日${statisticsDate ? `(${statisticsDate})` : ""}`;
+    },
+    columns: [
+      { prop: "jobNumber", label: "工号", width: 110 },
+      { prop: "name", label: "姓名", width: 100 },
+      { prop: "deptName", label: "部门", minWidth: 130 },
+      { prop: "position", label: "岗位", minWidth: 140 },
+      { prop: "hireDate", label: "入职日期", width: 110 },
+      { prop: "status", label: "员工状态", width: 100, slot: "status" },
+      { prop: "action", label: "操作", width: 90, fixed: "right", slot: "action" },
+    ],
+    actionTexts: ["查看档案"],
+    statusMeta(value) {
+      return value === "leave"
+        ? { label: "离职", type: "info" }
+        : { label: "在职", type: "success" };
+    },
+    async load({ organizationId, reportMonth, page, size }) {
+      const data = await getStructureDetails(
+        { pageNum: page, size, flowType: "ON_JOB" },
+        { organizationId, month: reportMonth },
+      );
+      return {
+        total: data.count,
+        list: data.list.map((item) => ({
+          jobNumber: item.employeeNo,
+          name: item.name,
+          deptName: item.department,
+          position: item.position,
+          hireDate: item.joinDate,
+          status: item.status === "离职" ? "leave" : "active",
+        })),
+      };
+    },
+    async exportAll({ organizationId, reportMonth }) {
+      await exportStructureDetails(
+        { flowType: "ON_JOB" },
+        { organizationId, month: reportMonth },
+      );
+      return null;
+    },
+  },
+  MONTH_ONBOARD: {
+    title: "本月入职 · 下钻明细",
+    countUnit: "人",
+    caliber({ monthRange }) {
+      return monthRange.start
+        ? `入职日期 ∈ [${monthRange.start}, ${monthRange.end}]`
+        : "入职日期在本统计月内";
+    },
+    columns: [
+      { prop: "jobNumber", label: "工号", width: 110 },
+      { prop: "name", label: "姓名", width: 100 },
+      { prop: "deptName", label: "部门", minWidth: 110 },
+      { prop: "position", label: "岗位", minWidth: 120 },
+      { prop: "hireDate", label: "入职日期", width: 110 },
+      { prop: "source", label: "入职来源", minWidth: 100 },
+      { prop: "handler", label: "经办人", width: 100 },
+      { prop: "action", label: "操作", width: 100, fixed: "right", slot: "action" },
+    ],
+    actionTexts: ["查看入岗单"],
+    async load({ organizationId, reportMonth, page, size }) {
+      const data = await getStructureDetails(
+        { pageNum: page, size, flowType: "ONBOARD" },
+        { organizationId, month: reportMonth },
+      );
+      return {
+        total: data.count,
+        list: data.list.map((item) => ({
+          jobNumber: item.employeeNo,
+          name: item.name,
+          deptName: item.department,
+          position: item.position,
+          hireDate: item.joinDate,
+          // 明细模型暂无入职来源 / 经办人字段,后端补充后自动展示
+          source: "",
+          handler: "",
+        })),
+      };
+    },
+    async exportAll({ organizationId, reportMonth }) {
+      await exportStructureDetails(
+        { flowType: "ONBOARD" },
+        { organizationId, month: reportMonth },
+      );
+      return null;
+    },
+  },
+  ATTENDANCE_ABNORMAL_DEPT: {
+    title: "考勤异常 · 待处理明细",
+    countUnit: "条",
+    caliber({ monthRange }) {
+      return monthRange.start
+        ? `统计月(${monthRange.start} ~ ${monthRange.end})内未关账考勤异常`
+        : "统计月内未关账考勤异常";
+    },
+    columns: [
+      { prop: "jobNumber", label: "工号", width: 110 },
+      { prop: "name", label: "姓名", width: 100 },
+      { prop: "deptName", label: "部门", minWidth: 120 },
+      { prop: "type", label: "异常类型", minWidth: 120 },
+      { prop: "date", label: "异常日期", width: 110 },
+      { prop: "status", label: "处理状态", width: 100, slot: "status" },
+      { prop: "action", label: "操作", width: 90, fixed: "right", slot: "action" },
+    ],
+    actionTexts: ["去处理"],
+    statusMeta: attendanceStatusMeta,
+    async load({ organizationId, monthRange, page, size }) {
+      // ★ 实测:dateStart/dateEnd 是必填口径,缺一个就返回 {code:'-1', message:''} 的静默失败
+      const data = await getAttendanceExceptionReport({
+        pageNum: page,
+        size,
+        deptId: organizationId || undefined,
+        dateStart: monthRange.start || undefined,
+        dateEnd: monthRange.end || undefined,
+      });
+      return { total: data.count, list: await adaptAttendanceRows(data.list) };
+    },
+    /** 无导出接口:分页拉全量后本地导出(单页上限 200,最多 50 页) */
+    async exportAll(ctx) {
+      const size = 200;
+      const base = {
+        size,
+        deptId: ctx.organizationId || undefined,
+        dateStart: ctx.monthRange.start || undefined,
+        dateEnd: ctx.monthRange.end || undefined,
+      };
+      const first = await getAttendanceExceptionReport({ ...base, pageNum: 1 });
+      const rows = [...first.list];
+      const total = Number(first.count) || rows.length;
+      for (let pageNum = 2; rows.length < total && pageNum <= 50; pageNum += 1) {
+        const next = await getAttendanceExceptionReport({ ...base, pageNum });
+        if (!next.list?.length) break;
+        rows.push(...next.list);
+      }
+      return adaptAttendanceRows(rows);
+    },
+  },
+  CONTRACT_EXPIRE: {
+    title: "合同到期 · 下钻明细",
+    countUnit: "条",
+    caliber({ statisticsDate, warnEndDate }) {
+      return `合同到期日 ∈ [统计日 ${statisticsDate || "—"}, 统计日+预警期30天 ${warnEndDate || "—"}] 且续签状态≠已续签`;
+    },
+    columns: [
+      { prop: "jobNumber", label: "工号", width: 100 },
+      { prop: "name", label: "姓名", width: 90 },
+      { prop: "deptName", label: "部门", minWidth: 100 },
+      { prop: "position", label: "岗位", minWidth: 110 },
+      { prop: "contractNo", label: "合同编号", width: 120 },
+      { prop: "contractType", label: "合同类型", minWidth: 110 },
+      { prop: "startDate", label: "合同起始日", width: 110 },
+      { prop: "endDate", label: "合同到期日", width: 110 },
+      { prop: "remainDays", label: "剩余天数", width: 90, slot: "days" },
+      { prop: "status", label: "续签状态", width: 90, slot: "status" },
+      { prop: "action", label: "操作", width: 130, fixed: "right", slot: "action" },
+    ],
+    actionTexts: ["办理续签", "查看合同"],
+    paginated: false,
+    statusMeta(value) {
+      return value === "signed"
+        ? { label: "已续签", type: "success" }
+        : { label: "未续签", type: "warning" };
+    },
+    async load() {
+      // 非分页接口(后端口径:到期未续签合同全量,days=30 预警期)
+      const list = await getContractUnrenewedReport({ days: 30 });
+      const rows = (Array.isArray(list) ? list : []).map(adaptContractRow);
+      return { list: rows, total: rows.length };
+    },
+  },
+};
+
+/**
+ * 考勤异常行适配:实测返回模型只有 userId / attendanceDate / exceptionType / status,
+ * 姓名、工号、部门用主数据批量接口按 userId 补齐(带缓存、自动分片);
+ * 补齐失败直接抛错,不静默降级成空列。
+ */
+async function adaptAttendanceRows(items = []) {
+  const source = Array.isArray(items) ? items : [];
+  const rows = source.map((item) => ({
+    jobNumber: item.employeeNo || item.jobNumber || "",
+    name: item.employeeName || item.userName || "",
+    deptName: item.departmentName || item.deptName || "",
+    type: exceptionLabel(item.exceptionType),
+    date: sliceDate(item.attendanceDate),
+    status: item.status || "",
+  }));
+  const ids = source.map((item) => item.userId).filter(Boolean);
+  if (!ids.length) return rows;
+  const briefs = await loadEmployeeBriefs(ids);
+  return rows.map((row, index) => {
+    const brief = briefs.get(String(source[index].userId || ""));
+    if (!brief) return row;
+    return {
+      ...row,
+      jobNumber: brief.jobNumber || row.jobNumber,
+      name: brief.name || row.name,
+      deptName: brief.deptName || row.deptName,
+    };
+  });
+}
+
+/** 合同到期行适配 */
+function adaptContractRow(item = {}) {
+  return {
+    jobNumber: item.employeeCode || "",
+    name: item.employeeName || "",
+    deptName: item.departmentName || "",
+    position: item.positionName || "",
+    contractNo: item.contractNo || "",
+    contractType: contractTypeLabel(item.contractType),
+    startDate: sliceDate(item.startDate),
+    endDate: sliceDate(item.endDate),
+    remainDays: item.remainingDays ?? "",
+    status: "unsigned",
+  };
+}
+
+export default {
+  name: "MetricDrillDownDialog",
+  props: {
+    visible: Boolean,
+    /** 卡片编码:ON_JOB_TOTAL / MONTH_ONBOARD / ATTENDANCE_ABNORMAL_DEPT / CONTRACT_EXPIRE */
+    code: { type: String, default: "" },
+    /** 统计月份(yyyy-MM),口径区间与明细查询共用 */
+    reportMonth: { type: String, default: "" },
+    /** 统计日(yyyy-MM-dd),口径展示用 */
+    statisticsDate: { type: String, default: "" },
+    /** 所属机构(首页机构选择联动) */
+    organizationId: { type: String, default: "" },
+  },
+  data() {
+    return {
+      rows: [],
+      total: 0,
+      page: 1,
+      size: 20,
+      loading: false,
+      exporting: false,
+    };
+  },
+  computed: {
+    show: {
+      get() {
+        return this.visible;
+      },
+      set(value) {
+        this.$emit("update:visible", value);
+      },
+    },
+    config() {
+      return DRILL_DOWN_CONFIGS[this.code] || null;
+    },
+    /**
+     * 统计月份起止(yyyy-MM-dd)。reportMonth 缺失时回落当前自然月:
+     * ★ 实测考勤异常接口 dateStart/dateEnd 必填,不传会返回 {code:'-1', message:''} 的静默失败,
+     * 且口径行展示的就是实际查询区间,不做无区间的隐式查询。
+     */
+    monthRange() {
+      const now = new Date();
+      const fallback = { year: now.getFullYear(), month: now.getMonth() + 1 };
+      const match = String(this.reportMonth || "").match(/^(\d{4})-(\d{1,2})$/);
+      const year = match ? Number(match[1]) : fallback.year;
+      const month = match ? Number(match[2]) : fallback.month;
+      const lastDay = new Date(year, month, 0).getDate();
+      return {
+        start: `${year}-${pad2(month)}-01`,
+        end: `${year}-${pad2(month)}-${pad2(lastDay)}`,
+      };
+    },
+    /** 统计日 + 预警期 30 天 */
+    warnEndDate() {
+      const date = new Date(this.statisticsDate);
+      if (Number.isNaN(date.getTime())) return "";
+      date.setDate(date.getDate() + 30);
+      return formatDate(date);
+    },
+    caliberText() {
+      return this.config ? this.config.caliber(this) : "";
+    },
+    totalText() {
+      return Number(this.total || 0).toLocaleString();
+    },
+  },
+  watch: {
+    visible(value) {
+      if (value && this.code) this.reload();
+    },
+  },
+  methods: {
+    reload() {
+      this.page = 1;
+      this.load();
+    },
+    handlePageChange(page) {
+      this.page = page;
+      this.load();
+    },
+    async load() {
+      if (!this.config?.load) return;
+      // 请求序号防竞态:快速翻页 / 换卡片时丢弃过期响应
+      const seq = (this._loadSeq = (this._loadSeq || 0) + 1);
+      this.loading = true;
+      try {
+        const result = await this.config.load(this.loadContext());
+        if (seq !== this._loadSeq) return;
+        this.rows = result.list || [];
+        this.total = Number(result.total) || 0;
+      } catch (error) {
+        if (seq !== this._loadSeq) return;
+        this.rows = [];
+        this.total = 0;
+        this.$message.error(error?.message || "明细数据加载失败");
+      } finally {
+        if (seq === this._loadSeq) this.loading = false;
+      }
+    },
+    loadContext() {
+      return {
+        organizationId: this.organizationId,
+        reportMonth: this.reportMonth,
+        monthRange: this.monthRange,
+        statisticsDate: this.statisticsDate,
+        page: this.page,
+        size: this.size,
+      };
+    },
+    statusMeta(row) {
+      return (
+        this.config?.statusMeta?.(row?.status) || { label: "--", type: "info" }
+      );
+    },
+    cellText(row, column) {
+      if (column.slot === "status") return this.statusMeta(row).label;
+      const value = row[column.prop];
+      return value === null || value === undefined ? "" : String(value);
+    },
+    /**
+     * 导出:all=true 优先走配置的 exportAll(服务端导出或全量拉取后本地导出),
+     * all=false 直接导当前页 rows。
+     */
+    async exportRows(all) {
+      if (!this.config || this.exporting) return;
+      this.exporting = true;
+      try {
+        let rows = this.rows;
+        if (all && this.config.exportAll) {
+          const result = await this.config.exportAll(this.loadContext());
+          // 返回空值 = 服务端已处理下载,前端不再落文件
+          if (!result) {
+            this.$message.success("导出任务已开始下载");
+            return;
+          }
+          rows = result;
+        }
+        if (!rows.length) {
+          this.$message.warning("暂无数据,已导出表头");
+        }
+        this.writeLocalExcel(rows);
+        this.$message.success(`已导出 ${rows.length} 条`);
+      } catch (error) {
+        this.$message.error(error?.message || "导出失败");
+      } finally {
+        this.exporting = false;
+      }
+    },
+    writeLocalExcel(rows) {
+      const columns = this.config.columns.filter(
+        (column) => column.slot !== "action",
+      );
+      const header = columns.map((column) => column.label);
+      const body = rows.map((row) =>
+        columns.map((column) => this.cellText(row, column)),
+      );
+      const sheet = XLSX.utils.aoa_to_sheet([header, ...body]);
+      sheet["!cols"] = columns.map((column) => ({
+        wch: Math.max(8, column.label.length * 2),
+      }));
+      const book = XLSX.utils.book_new();
+      XLSX.utils.book_append_sheet(book, sheet, "明细");
+      XLSX.writeFile(book, `${this.config.title}_${formatDate(new Date())}.xlsx`);
+    },
+    handleAction(action) {
+      this.$message.info(`「${action}」功能入口暂未开放`);
+    },
+  },
+};
+</script>
+
+<style lang="scss">
+/* append-to-body 弹窗:样式必须非 scoped。element-ui 的 custom-class 直接挂在 .el-dialog 上,故本类即弹窗根元素 */
+.metric-drill-dialog {
+  display: flex;
+  flex-direction: column;
+  max-height: 86vh;
+  overflow: hidden;
+  background:
+    linear-gradient(165deg, rgba(0, 50, 85, 0.92), rgba(2, 16, 32, 0.96) 42%, rgba(0, 28, 50, 0.92));
+  border: 1px solid rgba(0, 200, 255, 0.32);
+  box-shadow:
+    0 18px 48px rgba(0, 0, 0, 0.5),
+    0 0 28px rgba(0, 160, 255, 0.12) inset;
+
+  .el-dialog__header {
+    padding: 14px 20px 12px;
+    border-bottom: 1px solid rgba(0, 200, 255, 0.2);
+    background: linear-gradient(90deg, rgba(0, 120, 200, 0.16), transparent 60%);
+  }
+
+  .el-dialog__title {
+    color: #eaf9ff;
+    font-size: 15px;
+    font-weight: 650;
+    letter-spacing: 0.08em;
+    text-shadow: 0 0 12px rgba(0, 200, 255, 0.35);
+  }
+
+  .el-dialog__headerbtn .el-dialog__close {
+    color: #6f97b0;
+
+    &:hover {
+      color: #00eaff;
+    }
+  }
+
+  .el-dialog__body {
+    flex: 1 1 auto;
+    min-height: 0;
+    padding: 12px 20px 16px;
+    overflow: auto;
+  }
+
+  .drill-meta {
+    display: flex;
+    align-items: center;
+    flex-wrap: wrap;
+    gap: 4px 8px;
+    margin-bottom: 12px;
+    color: #9fc9e2;
+    font-size: 12px;
+
+    b {
+      color: #7af6ff;
+      font-weight: 650;
+    }
+
+    &__divider {
+      color: rgba(0, 180, 255, 0.35);
+    }
+
+    &__muted {
+      color: #5d82a0;
+    }
+  }
+
+  .drill-actions {
+    display: flex;
+    gap: 10px;
+    margin-left: auto;
+  }
+
+  .drill-btn {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    height: 28px;
+    padding: 0 14px;
+    font-size: 12px;
+    color: #bfe6ff;
+    cursor: pointer;
+    background: rgba(0, 60, 100, 0.35);
+    border: 1px solid rgba(0, 200, 255, 0.35);
+    border-radius: 3px;
+    transition: all 0.2s;
+
+    &:hover {
+      color: #fff;
+      border-color: rgba(0, 230, 255, 0.7);
+      box-shadow: 0 0 12px rgba(0, 200, 255, 0.35);
+    }
+
+    &:disabled {
+      opacity: 0.6;
+      cursor: not-allowed;
+    }
+
+    &--primary {
+      color: #001018;
+      font-weight: 600;
+      background: linear-gradient(135deg, #7af6ff, #1ec8ff);
+      border-color: transparent;
+
+      &:hover {
+        color: #001018;
+        box-shadow: 0 0 14px rgba(0, 220, 255, 0.55);
+      }
+    }
+  }
+
+  /* el-table 深色皮肤 */
+  .el-table {
+    flex: 1 1 auto;
+    min-height: 200px;
+    color: #d8effc;
+    background: transparent;
+
+    &::before,
+    &::after {
+      background-color: rgba(0, 140, 220, 0.2);
+    }
+
+    /* 项目全局 hr-list-standard.scss 有 `.el-table th.el-table__cell > .cell { color:#303133 !important }`,
+       深色弹窗要白字必须同样带 !important,否则被全局规则盖掉 */
+    th,
+    th.el-table__cell {
+      color: #fff !important;
+      font-weight: 600;
+      background: rgba(0, 55, 95, 0.45) !important;
+      border-bottom: 1px solid rgba(0, 200, 255, 0.25);
+
+      > .cell {
+        color: #fff !important;
+        font-weight: 600;
+      }
+    }
+
+    td {
+      border-bottom: 1px solid rgba(0, 140, 220, 0.14);
+    }
+
+    tr {
+      background: transparent;
+
+      &:hover > td {
+        background: rgba(0, 120, 200, 0.14) !important;
+      }
+    }
+
+    &--striped .el-table__body tr.el-table__row--striped td {
+      background: rgba(0, 40, 70, 0.3);
+    }
+
+    .el-table__empty-block {
+      min-height: 160px;
+      color: #6f97b0;
+      background: rgba(0, 25, 48, 0.3);
+    }
+  }
+
+  .drill-action {
+    margin-right: 12px;
+
+    &:last-child {
+      margin-right: 0;
+    }
+  }
+
+  .drill-footer {
+    display: flex;
+    align-items: center;
+    gap: 14px;
+    margin-top: 12px;
+    color: #9fc9e2;
+    font-size: 12px;
+
+    b {
+      color: #7af6ff;
+      font-weight: 650;
+    }
+
+    &__total {
+      white-space: nowrap;
+    }
+
+    .drill-pager {
+      margin-left: auto;
+      padding: 0;
+      color: #9fc9e2;
+
+      button {
+        padding: 0 6px;
+        min-width: 26px;
+        height: 26px;
+        line-height: 26px;
+        color: #bfe6ff;
+        background: rgba(0, 60, 100, 0.35);
+
+        &:disabled {
+          color: #4a6a85;
+          background: rgba(0, 40, 70, 0.35);
+        }
+      }
+
+      .el-pager li {
+        min-width: 26px;
+        height: 26px;
+        line-height: 26px;
+        color: #bfe6ff;
+        background: rgba(0, 60, 100, 0.35);
+
+        &.active {
+          color: #001018;
+          font-weight: 600;
+          background: linear-gradient(135deg, #7af6ff, #1ec8ff);
+        }
+      }
+    }
+
+    .drill-btn {
+      margin-left: auto;
+    }
+
+    /* 无分页(合同到期)时导出按钮靠右 */
+    .drill-pager + .drill-btn {
+      margin-left: 0;
+    }
+  }
+}
+</style>

+ 89 - 12
src/views/homeView/index.vue

@@ -41,7 +41,7 @@
         :class="[
           `metric-card--${item.tone}`,
           `metric-card--delay-${index}`,
-          { 'metric-card--clickable': item.drillDownPath },
+          { 'metric-card--clickable': item.drillDownPath || hasDrillDown(item) },
         ]"
         :title="item.message || undefined"
         @click="openDrillDown(item)"
@@ -66,6 +66,11 @@
             <template v-else>{{ formatNumber(item.value) }}</template>
             <small v-if="item.unit">{{ item.unit }}</small>
           </strong>
+          <span v-if="item.secondaryValue != null" class="metric-sub">
+            <i>{{ item.secondaryLabel }}</i>
+            {{ formatNumber(item.secondaryValue)
+            }}<small v-if="item.secondaryUnit">{{ item.secondaryUnit }}</small>
+          </span>
         </div>
         <span class="metric-card__code">0{{ index + 1 }}</span>
       </article>
@@ -84,10 +89,18 @@
           </div>
         </div>
         <div class="flow-rates" v-if="flow.onboardRate != null || flow.leaveRate != null">
-          <span class="flow-rate flow-rate--hire" v-if="flow.onboardRate != null">
+          <span
+            class="flow-rate flow-rate--hire"
+            v-if="flow.onboardRate != null"
+            title="入职率 = 统计月入职人数 ÷ 月初在职人数 × 100"
+          >
             <small>入职率</small>{{ percentText(flow.onboardRate) }}
           </span>
-          <span class="flow-rate flow-rate--resign" v-if="flow.leaveRate != null">
+          <span
+            class="flow-rate flow-rate--resign"
+            v-if="flow.leaveRate != null"
+            title="离职率 = 统计月离职人数 ÷ 月初在职人数 × 100"
+          >
             <small>离职率</small>{{ percentText(flow.leaveRate) }}
           </span>
         </div>
@@ -189,12 +202,25 @@
         <div v-else class="todo-empty">暂无紧急待办</div>
       </article>
     </section>
+
+    <!-- 顶部卡片下钻明细弹窗(数据接口暂未接入,先出表头与导出) -->
+    <metric-drill-down-dialog
+      :visible.sync="drillDownVisible"
+      :code="drillDownCode"
+      :report-month="reportMonth"
+      :statistics-date="statisticsDate"
+      :organization-id="organizationId"
+    />
   </main>
 </template>
 
 <script>
 import * as echarts from "echarts";
 import { getHrOverviewDataCenter } from "@/api/hr/overview";
+/* 顶部卡片下钻弹窗(点击卡片开弹窗,不再跳路由页) */
+import MetricDrillDownDialog, {
+  DRILL_DOWN_CONFIGS,
+} from "./components/MetricDrillDownDialog.vue";
 /* 所属机构选项统一取登录缓存(loginChangeGroupVOList),不再调组织列表接口 */
 import { getOrgOptions, getCurrentOrgId } from "@/utils/current-org";
 import {
@@ -220,6 +246,12 @@ const CARD_PRESETS = {
     icon: "el-icon-warning-outline",
     tone: "orange",
     badge: true,
+    /**
+     * 该卡片契约返回两个数:value = 异常类型数、secondaryValue = 异常部门数。
+     * 卡片名指向「部门数」,故展示时以 secondaryValue 作主值、value 作副值(异常类型数)。
+     */
+    primaryFrom: "secondary",
+    secondaryLabel: "异常类型",
   },
   CONTRACT_EXPIRE: {
     label: "合同到期",
@@ -280,6 +312,7 @@ function toNullableNumber(value) {
 
 export default {
   name: "HomeView",
+  components: { MetricDrillDownDialog },
   data() {
     return {
       loading: false,
@@ -295,9 +328,12 @@ export default {
         onboard: [],
         leave: [],
         onJob: [],
+        beginningOnJob: [],
         netChange: [],
         onboardRate: null,
         leaveRate: null,
+        monthlyOnboardRate: [],
+        monthlyLeaveRate: [],
       },
       flowMessage: "",
       headcount: {
@@ -313,6 +349,9 @@ export default {
       charts: {},
       resizeObserver: null,
       resizeFrame: null,
+      /* 顶部卡片下钻弹窗 */
+      drillDownVisible: false,
+      drillDownCode: "",
     };
   },
   computed: {
@@ -371,6 +410,10 @@ export default {
     Object.values(this.charts).forEach((chart) => chart && chart.dispose());
   },
   methods: {
+    /** 卡片是否有下钻弹窗(模板里用,避免直接引模块常量) */
+    hasDrillDown(item) {
+      return Boolean(item?.id && DRILL_DOWN_CONFIGS[item.id]);
+    },
     formatNumber(value) {
       if (value === null || value === undefined || value === "") return "--";
       return toNumber(value, 0).toLocaleString();
@@ -432,9 +475,12 @@ export default {
         onboard: [],
         leave: [],
         onJob: [],
+        beginningOnJob: [],
         netChange: [],
         onboardRate: null,
         leaveRate: null,
+        monthlyOnboardRate: [],
+        monthlyLeaveRate: [],
       };
       this.flowMessage = "";
       this.headcount = {
@@ -463,15 +509,27 @@ export default {
     mapTopCards(cards = []) {
       return (Array.isArray(cards) ? cards : []).map((card, index) => {
         const preset = CARD_PRESETS[card.cardCode] || {};
-        const value = toNullableNumber(card.value);
+        /* 卡片两个数值位:value(主) / secondaryValue(副),具体哪个当主值由 preset.primaryFrom 决定 */
+        const base = { value: toNullableNumber(card.value), unit: card.unit || "" };
+        const extra = {
+          value: toNullableNumber(card.secondaryValue),
+          unit: card.secondaryUnit || "",
+        };
+        /* 主值取自哪个字段由 preset.primaryFrom 声明;缺值保持 null 显示「--」,不做兜底替换 */
+        const swap = preset.primaryFrom === "secondary";
+        const primary = swap ? extra : base;
+        const secondary = swap ? base : extra;
         return {
           id: card.cardCode || `card-${index}`,
           label: card.cardName || preset.label || "统计指标",
-          value,
-          unit: card.unit || "",
+          value: primary.value,
+          unit: primary.unit,
+          secondaryValue: secondary.value,
+          secondaryUnit: secondary.unit,
+          secondaryLabel: preset.secondaryLabel || "附加",
           icon: preset.icon || "el-icon-s-data",
           tone: preset.tone || "cyan",
-          badge: preset.badge && value != null && value > 0 ? value : undefined,
+          badge: preset.badge && primary.value != null && primary.value > 0 ? primary.value : undefined,
           dataStatus: card.dataStatus || "",
           message: card.message || "",
           drillDownPath: card.drillDownPath || "",
@@ -486,9 +544,13 @@ export default {
         onboard: trends.map((item) => toNullableNumber(item.onboardCount)),
         leave: trends.map((item) => toNullableNumber(item.leaveCount)),
         onJob: trends.map((item) => toNullableNumber(item.onJobCount)),
+        /* 月初在职人数:契约中为流动率统一分母 */
+        beginningOnJob: trends.map((item) => toNullableNumber(item.beginningOnJobCount)),
         netChange: trends.map((item) => toNullableNumber(item.netChangeCount)),
         onboardRate: toNullableNumber(data.turnoverOnboardRate),
         leaveRate: toNullableNumber(data.turnoverLeaveRate),
+        monthlyOnboardRate: trends.map((item) => toNullableNumber(item.onboardRate)),
+        monthlyLeaveRate: trends.map((item) => toNullableNumber(item.leaveRate)),
       };
     },
     /** 部门编制占比 */
@@ -679,15 +741,24 @@ export default {
     },
     flowTooltip(params) {
       const index = params?.[0]?.dataIndex ?? 0;
-      const lines = [
-        `<b>${this.flow.months[index] || ""}</b>`,
-        `入职:${toNumber(this.flow.onboard[index], 0)} 人`,
-        `离职:${toNumber(this.flow.leave[index], 0)} 人`,
-      ];
+      const lines = [`<b>${this.flow.months[index] || ""}</b>`];
+      const beginning = this.flow.beginningOnJob[index];
+      if (beginning != null) lines.push(`月初在职:${beginning} 人`);
+      const onboard = toNumber(this.flow.onboard[index], 0);
+      const onboardRate = this.flow.monthlyOnboardRate[index];
+      lines.push(
+        `入职:${onboard} 人${onboardRate != null ? `(入职率 ${this.percentText(onboardRate)})` : ""}`,
+      );
+      const leave = toNumber(this.flow.leave[index], 0);
+      const leaveRate = this.flow.monthlyLeaveRate[index];
+      lines.push(
+        `离职:${leave} 人${leaveRate != null ? `(离职率 ${this.percentText(leaveRate)})` : ""}`,
+      );
       const onJob = this.flow.onJob[index];
       if (onJob != null) lines.push(`在职:${onJob} 人`);
       const net = this.flow.netChange[index];
       if (net != null) lines.push(`净增:${net > 0 ? "+" : ""}${net} 人`);
+      lines.push(`<span style="opacity:.7">流动率分母为月初在职人数</span>`);
       return lines.join("<br/>");
     },
     renderStaffingChart() {
@@ -780,6 +851,12 @@ export default {
       );
     },
     openDrillDown(item) {
+      // 顶部四个卡片:点击打开下钻弹窗(原路由页未注册,跳转是空白页)
+      if (item?.id && DRILL_DOWN_CONFIGS[item.id]) {
+        this.drillDownCode = item.id;
+        this.drillDownVisible = true;
+        return;
+      }
       let path = item?.drillDownPath;
       if (
         path === "/contract-management" ||

+ 3 - 7
src/views/hrManagement/employeeArchive/components/EmployeeArchiveForm.vue

@@ -2283,9 +2283,7 @@ export default {
           });
         }
         this.sequenceOptions = list;
-        if (list.length) {
-          this._emptySequenceTipKey = '';
-        } else if (!requestFailed) {
+        if (!list.length && !requestFailed) {
           // 所选部门下没有维护任何岗位序列(接口异常时不误报):引导去组织机构的岗位信息里维护
           this.warnEmptySequences(deptIds);
         }
@@ -2295,11 +2293,9 @@ export default {
         this.sequenceLoading = false;
       }
     },
-    /** 部门下没有岗位序列时提示一次(同一批部门不重复弹) */
+    /** 部门下没有岗位序列时提示:每次选中无岗位部门都弹,不做去重(用户要求) */
     warnEmptySequences(deptIds = []) {
-      const key = deptIds.map((id) => String(id)).sort().join(',');
-      if (!key || this._emptySequenceTipKey === key) return;
-      this._emptySequenceTipKey = key;
+      if (!deptIds.length) return;
       this.$message.warning('该部门下暂无配置岗位信息,请在组织机构的该部门补充岗位信息');
     },
     /** 切换岗位序列:清空岗位及以下的级联字段与选项,再按该序列加载岗位 */

+ 81 - 152
src/views/hrManagement/employeeArchive/index.vue

@@ -27,7 +27,7 @@
                   <i :class="isCompanyNode(data) ? 'el-icon-office-building' : 'el-icon-folder'" />
                   <span>{{ data.name }}</span>
                 </span>
-                <em>{{ data.staffCount ?? 0 }}</em>
+                <em>{{ data.userCount == null ? '—' : data.userCount }}</em>
               </span>
             </el-tree>
           </template>
@@ -80,11 +80,11 @@
             </el-row>
           </el-form>
           <ele-pro-table ref="table" row-key="id" :columns="columns" :datasource="fetchPage" :need-page="true"
-            :page-size="20" :cache-key="cacheKeyUrl" stripe class="employee-table"
-            @selection-change="selectedRows = $event" @row-click="openDetail">
+            :init-load="false" :page-size="20" :cache-key="cacheKeyUrl" stripe class="employee-table"
+            @selection-change="selectedRows = $event">
             <template v-slot:toolbar>
               <el-button type="primary" icon="el-icon-plus" class="ele-btn-icon" @click="openCreate">
-                新增员工
+                新增
               </el-button>
               <el-dropdown trigger="click" @command="handleImportCommand">
                 <el-button icon="el-icon-upload2" class="ele-btn-icon">
@@ -108,15 +108,6 @@
               </el-dropdown>
               <template v-if="selectedRows.length">
                 <span class="selected-count">已选 {{ selectedRows.length }} 人</span>
-                <el-dropdown trigger="click" @command="batchCommand">
-                  <el-button class="ele-btn-icon">
-                    批量操作<i class="el-icon-arrow-down el-icon--right"></i>
-                  </el-button>
-                  <el-dropdown-menu slot="dropdown">
-                    <el-dropdown-item command="enable" icon="el-icon-circle-check">设为在职</el-dropdown-item>
-                    <el-dropdown-item command="disable" icon="el-icon-circle-close">设为待岗</el-dropdown-item>
-                  </el-dropdown-menu>
-                </el-dropdown>
               </template>
             </template>
             <template v-slot:name="{ row }">
@@ -175,7 +166,7 @@
 import EmployeeArchiveForm from './components/EmployeeArchiveForm.vue';
 import EmployeeArchiveDetail from './components/EmployeeArchiveDetail.vue';
 import {
-  listOrganizations,
+  listOrganizationsWithUserCount,
   getUserPage,
   getEmployeeProfile,
   saveEmployee as saveEmployeeApi,
@@ -184,7 +175,6 @@ import {
   adaptEmployeeProfile,
   syncWorkExperiences,
   listWorkExperiences,
-  exportUsers,
   downloadUserTemplate,
   importUsers,
   fetchAllUsers,
@@ -211,24 +201,6 @@ const STATUS_OPTIONS = [
   { value: 'disabled', label: '待岗', type: 'danger' }
 ];
 
-const INDICATOR_DATA = [
-  { key: 'total', label: '在册员工', suffix: '人', icon: 'el-icon-user', tone: 'blue' },
-  { key: 'probation', label: '试用期员工', suffix: '人', icon: 'el-icon-timer', tone: 'amber' },
-  { key: 'arrival', label: '本月入职', suffix: '人', icon: 'el-icon-circle-plus-outline', tone: 'green' },
-  { key: 'turnover', label: '本月离职', suffix: '人', icon: 'el-icon-remove-outline', tone: 'red' }
-];
-
-function formatJoin(value) {
-  if (!value) return '';
-  const text = String(value);
-  return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : text;
-}
-
-function currentMonthPrefix() {
-  const now = new Date();
-  return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
-}
-
 function escapeCsv(value) {
   const text = String(value == null ? '' : value);
   return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
@@ -274,19 +246,32 @@ export default {
       detailVisible: false,
       selectedEmployee: null,
       tableTotal: 0,
+      /* 工号池:只在打开「新增/编辑员工」弹窗时按需拉取,页面加载不发这个全量请求 */
       existingNumbers: [],
-      metricValues: {
-        total: 0,
-        probation: 0,
-        arrival: 0,
-        turnover: 0
-      }
+      employeeNumbersLoaded: false
     };
   },
   computed: {
     styleResponsive() {
       return this.$store.state.theme?.styleResponsive !== false;
     },
+    /** 登录机构 id:不受组织树选择影响(「全部员工」人数、工号池范围都用它) */
+    loginOrgId() {
+      return this.defaultOrganizationId || this.scopedOrgIds[0] || '';
+    },
+    /** 工号池 / 机构范围的查询参数(固定登录机构,不跟随组织树里选中的部门) */
+    loginOrgParams() {
+      return this.loginOrgId ? { groupId: this.loginOrgId } : {};
+    },
+    /**
+     * 「全部员工」人数:取组织列表接口(getGroupListWithUserCount)下发的 userCount,
+     * 与左树节点同一个后端字段,前端不做统计。列表未就绪时返回 null,模板显示「—」。
+     */
+    metricsTotal() {
+      const node = this.orgById[String(this.loginOrgId)] || null;
+      const count = node?.userCount;
+      return count == null || count === '' ? null : Number(count);
+    },
     columns() {
       return [
         { type: 'selection', width: 56, fixed: 'left', align: 'center', headerAlign: 'center' },
@@ -302,18 +287,6 @@ export default {
         { columnKey: 'action', label: '操作', width: 140, fixed: 'right', align: 'left', resizable: false, slot: 'action' }
       ];
     },
-    metrics() {
-      return INDICATOR_DATA.map((item) => {
-        if (item.key === 'total') return { ...item, value: this.metricValues.total, note: '在册(不含离职)' };
-        if (item.key === 'probation') return { ...item, value: this.metricValues.probation, note: '试用 status=5' };
-        if (item.key === 'arrival') return { ...item, value: this.metricValues.arrival, note: '本月入职(前端汇总)' };
-        if (item.key === 'turnover') return { ...item, value: this.metricValues.turnover, note: '本月离职(前端汇总)' };
-        return item;
-      });
-    },
-    metricsTotal() {
-      return this.metricValues.total;
-    },
     currentOrganizationName() {
       if (!this.selectedOrganizationId) return '全部员工';
       return this.orgById[String(this.selectedOrganizationId)]?.name || '全部员工';
@@ -354,7 +327,9 @@ export default {
       this.loading = true;
       try {
         await this.loadOrganizations();
-        await this.loadMetricsAndCounts();
+        // 页面加载只发两个请求:组织列表(含 userCount)+ 当前这一页员工列表。
+        // 「全部员工」人数、左树人数都取组织列表下发的 userCount,前端不再额外统计。
+        // 表格 :init-load="false":只在这里发唯一一次列表请求(等 orgById 就绪,部门/公司名映射完整)
         this.$nextTick(() => this.$refs.table?.reload({ page: 1 }));
       } finally {
         this.loading = false;
@@ -394,9 +369,14 @@ export default {
         legalEntity: company || employee.legalEntity || ''
       };
     },
+    /**
+     * 加载组织树 + 人数。
+     * 人数直接用接口返回的 userCount(后端口径),前端不做任何统计、也不逐部门发请求。
+     * 员工数据变更后调用本方法即可拿到新的人数(接口不接本地缓存)。
+     */
     async loadOrganizations() {
       try {
-        const list = await listOrganizations();
+        const list = await listOrganizationsWithUserCount();
         const records = Array.isArray(list) ? list : [];
         const map = {};
         records.forEach((item) => { map[String(item.id)] = item; });
@@ -439,55 +419,34 @@ export default {
       });
       return scoped;
     },
-    applyStaffCounts(tree, countMap) {
-      const walk = (nodes) => {
-        nodes.forEach((node) => {
-          const children = node.children || [];
-          walk(children);
-          const selfCount = Number(countMap[String(node.id)] || 0);
-          const childCount = children.reduce((sum, child) => sum + Number(child.staffCount || 0), 0);
-          this.$set(node, 'staffCount', selfCount + childCount);
-        });
-      };
-      walk(tree);
-    },
-    // 合并 loadMetrics 与 refreshStaffCounts:fetchAllUsers({ isQueryLZ: 0 }) 只调一次
-    async loadMetricsAndCounts() {
-      const month = currentMonthPrefix();
-      // 机构范围:指标、组织树人数、工号查重同样只统计当前登录机构的人
-      const scope = this.scopeParams();
+    /**
+     * 员工数据变更后刷新组织列表:人数(userCount)随之更新。
+     * 「全部员工」总人数 = 登录机构节点的 userCount,跟着本方法一起刷新,不需要额外请求。
+     */
+    async refreshOrganizations() {
+      // 机构范围可能变了,工号池下次开弹窗重新拉
+      this.employeeNumbersLoaded = false;
+      await this.loadOrganizations();
+    },
+    /**
+     * 工号池(「员工编号已存在」查重 + 自动生成员工编号的数据源)。
+     * 只有新增 / 编辑弹窗用得到,所以在弹窗打开时按需拉取,页面加载不发这个全量请求。
+     * 失败必须明确提示:静默失败会让编号查重悄悄失效。
+     */
+    async ensureEmployeeNumbers() {
+      if (this.employeeNumbersLoaded) return;
+      this.employeeNumbersLoaded = true;
       try {
-        const [totalRes, probationRes, activeBundle, leaveBundle] = await Promise.all([
-          getUserPage({ ...scope, pageNum: 1, size: 1, isQueryLZ: 0 }),
-          getUserPage({ ...scope, pageNum: 1, size: 1, status: '5' }),
-          fetchAllUsers({ ...scope, isQueryLZ: 0 }, { pageSize: 200, maxPages: 30 }),
-          fetchAllUsers({ ...scope, status: '6', isQueryLZ: 1 }, { pageSize: 200, maxPages: 20 })
-        ]);
-        // 指标统计
-        this.metricValues.total = Number(totalRes?.count ?? activeBundle.count ?? 0);
-        this.metricValues.probation = Number(probationRes?.count ?? 0);
-        this.metricValues.arrival = activeBundle.list.filter((item) => formatJoin(item.joinDate).startsWith(month)).length;
-        this.metricValues.turnover = leaveBundle.list.filter((item) => {
-          const leave = formatJoin(item.leaveDate);
-          return leave.startsWith(month) || (!leave && formatJoin(item.updateTime).startsWith(month));
-        }).length;
-        // 组织树人数统计(复用同一份 activeBundle.list,避免重复请求)
-        const countMap = {};
-        activeBundle.list.forEach((user) => {
-          const ids = [
-            ...(Array.isArray(user.deptIds) ? user.deptIds : []),
-            user.deptId,
-            user.groupId
-          ].filter((id) => id != null && id !== '');
-          Array.from(new Set(ids.map(String))).forEach((id) => {
-            countMap[id] = (countMap[id] || 0) + 1;
-          });
-        });
-        this.applyStaffCounts(this.organizationTree, countMap);
-        this.existingNumbers = activeBundle.list.map((item) => item.jobNumber).filter(Boolean);
+        const all = await fetchAllUsers(
+          { ...this.loginOrgParams, isQueryLZ: 0 },
+          { pageSize: 500, maxPages: 30 },
+        );
+        const numbers = all.list.map((item) => item.jobNumber).filter(Boolean);
+        this.existingNumbers = Array.from(new Set([...this.existingNumbers, ...numbers]));
       } catch (error) {
-        this.metricValues = { total: 0, probation: 0, arrival: 0, turnover: 0 };
-        // 人数统计失败不影响主流程
+        // 允许下次重新尝试
+        this.employeeNumbersLoaded = false;
+        this.$message.warning(error?.message || '员工编号数据加载失败,新增时无法自动校验编号是否重复');
       }
     },
     needsClientFilter() {
@@ -613,20 +572,16 @@ export default {
       return !value || String(data.name || '').toLowerCase().includes(value.toLowerCase());
     },
     selectOrganization(data) {
-      // 切到另一个机构时指标与人数统计也要同步刷新(点部门不影响指标)
-      const switchedOrg = String(data.id) !== String(this.scopeGroupId());
+      // 只筛右侧列表:左树人数由组织列表接口下发的 userCount 决定,与选中项无关,这里不重拉
       this.selectedOrganizationId = data.id;
       this.$refs.table?.reload({ page: 1 });
-      if (switchedOrg) this.loadMetricsAndCounts();
     },
     selectAllOrganizations() {
       // 可见范围仅限登录机构:这里回到当前登录机构,而不是查全部机构的人
       const nextOrg = this.defaultOrganizationId || this.scopedOrgIds[0] || '';
-      const switchedOrg = String(nextOrg) !== String(this.scopeGroupId());
       this.selectedOrganizationId = nextOrg;
       this.$refs.organizationTree?.setCurrentKey(nextOrg || null);
       this.$refs.table?.reload({ page: 1 });
-      if (switchedOrg) this.loadMetricsAndCounts();
     },
     applyFilters() {
       this.appliedFilters = {
@@ -651,6 +606,8 @@ export default {
     openCreate() {
       this.editingEmployee = null;
       this.formVisible = true;
+      // 弹窗里要用工号池做查重 / 自动生成编号,此时才拉
+      this.ensureEmployeeNumbers();
     },
     async openEdit(employee) {
       try {
@@ -664,6 +621,8 @@ export default {
           hireDate: detail.hireDate || employee.hireDate || '',
         };
         this.formVisible = true;
+        // 弹窗里要用工号池做查重,此时才拉
+        this.ensureEmployeeNumbers();
       } catch (error) {
         this.$message.error(error.message || '员工详情加载失败');
       } finally {
@@ -720,7 +679,7 @@ export default {
             this.$message.warning(error.message || '基本信息已保存,工作履历同步失败');
           }
         }
-        this.loadMetricsAndCounts()
+        this.refreshOrganizations()
           .then(() => this.$refs.table?.reload())
           .catch(() => this.$refs.table?.reload());
         if (!payload.id && savedId) {
@@ -738,35 +697,6 @@ export default {
         finish(error || new Error('员工保存失败'));
       }
     },
-    batchCommand(command) {
-      const enable = command === 'enable';
-      const label = enable ? '在职' : '待岗';
-      this.$confirm(`确认将已选 ${this.selectedRows.length} 名员工设为${label}吗?`, '批量更新状态', {
-        type: 'warning'
-      })
-        .then(async () => {
-          try {
-            const rows = [...this.selectedRows];
-            for (const item of rows) {
-              // eslint-disable-next-line no-await-in-loop
-              await saveEmployeeApi(
-                serializeEmployee({
-                  ...item,
-                  formStatus: enable ? '全职' : item.formStatus || '全职',
-                  enabled: enable
-                })
-              );
-            }
-            this.$refs.table?.clearSelection();
-            this.$message.success(`已更新 ${rows.length} 名员工的状态`);
-            await this.loadMetricsAndCounts();
-            this.$refs.table?.reload();
-          } catch (error) {
-            this.$message.error(error.message || '批量更新失败');
-          }
-        })
-        .catch(() => { });
-    },
     async handleImportCommand(command) {
       if (command === 'template') {
         try {
@@ -787,7 +717,7 @@ export default {
         this.loading = true;
         await importUsers(file);
         this.$message.success('导入成功');
-        await this.loadMetricsAndCounts();
+        await this.refreshOrganizations();
         this.$refs.table?.reload({ page: 1 });
       } catch (error) {
         this.$message.error(error.message || '导入失败');
@@ -821,24 +751,23 @@ export default {
         this.$message.success(`已导出 ${this.selectedRows.length} 条(本地 CSV,因服务端不支持按勾选 ID 导出)`);
         return;
       }
+      // 「导出当前结果」必须与页面所见完全一致:关键词/性别/入职日期等筛选只有前端口径,
+      // 服务端导出不认这些条件,直接调会把没筛选过的数据导出去(表格信息对不上)→ 统一本地导出
       try {
-        const base = this.buildBaseParams();
-        await exportUsers(base);
-        this.$message.success('导出任务已开始下载');
-      } catch (error) {
-        // 服务端导出失败时,降级为当前筛选结果本地导出
-        try {
-          const result = await this.fetchPage({ page: 1, limit: Math.max(this.tableTotal || 200, 200) });
-          const rows = result?.list || [];
-          if (!rows.length) {
-            this.$message.warning('当前没有可导出的员工数据');
-            return;
-          }
-          this.exportRowsLocally(rows, `员工档案_筛选_${new Date().toISOString().slice(0, 10)}.csv`);
-          this.$message.success(`服务端导出失败,已降级导出 ${rows.length} 条本地 CSV`);
-        } catch (fallbackError) {
-          this.$message.error(error.message || '导出失败');
+        this.loading = true;
+        const total = Math.max(Number(this.tableTotal) || 0, 200);
+        const result = await this.fetchPage({ page: 1, limit: total });
+        const rows = result?.list || [];
+        if (!rows.length) {
+          this.$message.warning('当前没有可导出的员工数据');
+          return;
         }
+        this.exportRowsLocally(rows, `员工档案_当前结果_${new Date().toISOString().slice(0, 10)}.csv`);
+        this.$message.success(`已导出 ${rows.length} 条`);
+      } catch (error) {
+        this.$message.error(error.message || '导出失败');
+      } finally {
+        this.loading = false;
       }
     }
   }

+ 1 - 1
src/views/hrManagement/itemReceiptManagement/index.vue

@@ -39,7 +39,7 @@
         :page-size="20">
         <template v-slot:toolbar>
           <el-button type="primary" icon="el-icon-plus" class="ele-btn-icon" :loading="creating" @click="openCreate">
-            新建领用单
+            新建
           </el-button>
         </template>
 

+ 12 - 2
src/views/hrManagement/onboardingHandling/index.vue

@@ -40,7 +40,7 @@
         :page-size="20" class="onboarding-table" @selection-change="selectedRows = $event">
         <template v-slot:toolbar>
           <el-button type="primary" icon="el-icon-plus" class="ele-btn-icon" @click="openCreate">
-            办理入职
+            新建
           </el-button>
         </template>
         <!-- 点员工姓名开详情,替代原操作列「查看」入口 -->
@@ -68,7 +68,8 @@
             icon="el-icon-edit" @click.stop="openEdit(row)">
             办理
           </el-link>
-          <el-link type="danger" :underline="false" icon="el-icon-delete" @click.stop="deleteRecord(row)">
+          <el-link v-if="canDelete(row)" type="danger" :underline="false" icon="el-icon-delete"
+            @click.stop="deleteRecord(row)">
             删除
           </el-link>
         </template>
@@ -2162,7 +2163,16 @@ export default {
         legalEntity: emp.legalEntity || "",
       });
     },
+    /** 只有草稿才能删除:未进入审批流程的入职单(办理入职 / 合同草稿) */
+    canDelete(row) {
+      const progress = String(row?.onboardingProgress || "").toUpperCase();
+      return progress === "ONBOARDING" || progress === "CONTRACT_DRAFT";
+    },
     async deleteRecord(row) {
+      if (!this.canDelete(row)) {
+        this.$message.warning("只有草稿状态的入职单可以删除");
+        return;
+      }
       const userId = row?.userId || row?.id;
       if (!userId) {
         this.$message.warning("缺少员工主键,无法删除");

+ 53 - 50
src/views/hrManagement/positionTransfer/index.vue

@@ -38,7 +38,7 @@
         :page-size="20" :cache-key="cacheKeyUrl" class="transfer-table">
         <template v-slot:toolbar>
           <el-button type="primary" icon="el-icon-plus" class="ele-btn-icon" @click="openCreate">
-            新增岗位变更
+            新建
           </el-button>
         </template>
         <!-- 点异动单号开详情,替代原操作列「查看」入口 -->
@@ -93,10 +93,6 @@
             @click.stop="cancelRecord(row)">
             取消
           </el-link>
-          <el-link v-if="canWithdraw(row)" type="primary" :underline="false" icon="el-icon-refresh-left"
-            @click.stop="withdrawRecord(row)">
-            撤回
-          </el-link>
           <el-link v-if="canLaunch(row)" type="primary" :underline="false" icon="el-icon-s-promotion"
             @click.stop="launchApproval(row)">
             提交
@@ -298,27 +294,34 @@
                 <el-date-picker v-model="form.effectiveDate" type="date" value-format="yyyy-MM-dd" placeholder="请选择生效日期"
                   style="width: 100%" />
               </el-form-item>
-              <el-form-item label="异动类型" prop="changeType">
-                <el-select v-model="form.changeType" placeholder="请选择异动类型">
-                  <el-option v-for="item in changeTypeOptions" :key="item.value" :label="item.label"
-                    :value="item.value" />
+              <!-- 签署方式原在下方区块,与异动类型互换位置后占原异动类型所在单元格 -->
+              <el-form-item label="签署方式" prop="signType">
+                <!-- 与合同管理「签署方式」保持一致的分段按钮样式 -->
+                <div class="pt-sign-method">
+                  <!-- 线上模板功能未开通:按钮弱化展示,点击只提示不切换 -->
+                  <button type="button" class="pt-method-btn is-unavailable"
+                    :class="{ 'is-active': form.signType === 'online' }" title="暂无开通模板功能,敬请期待"
+                    @click="onSignTypeChange('online')">线上模板</button>
+                  <button type="button" class="pt-method-btn" :class="{ 'is-active': form.signType === 'offline' }"
+                    @click="onSignTypeChange('offline')">线下附件签章</button>
+                </div>
+              </el-form-item>
+              <!-- 调动模板 / 签章附件:二者只能出现一个,占签署方式右侧同一单元格,按签署方式切换 -->
+              <el-form-item v-if="form.signType === 'online'" label="调动模板" prop="onlineDocumentTemplateId">
+                <el-select v-model="form.onlineDocumentTemplateId" filterable clearable placeholder="选择已启用的岗位调动模板"
+                  style="width: 100%" @visible-change="onTemplateDropdown">
+                  <el-option v-for="item in enabledTemplates" :key="item.id"
+                    :label="item.templateName || item.name || `模板-${item.id}`" :value="item.id" />
                 </el-select>
               </el-form-item>
+              <el-form-item v-else label="签章附件">
+                <fileMain v-model="approvalDocIds" :type="formReadOnly ? 'view' : ''" size="small" />
+              </el-form-item>
             </div>
-            <el-form-item label="签章附件">
-              <fileMain v-model="approvalDocIds" :type="formReadOnly ? 'view' : ''" size="small" />
-            </el-form-item>
-            <el-form-item label="签署方式" prop="signType">
-              <el-radio-group v-model="form.signType">
-                <el-radio label="online">线上 Word 模板</el-radio>
-                <el-radio label="offline">线下 / 仅附件签章</el-radio>
-              </el-radio-group>
-            </el-form-item>
-            <el-form-item v-if="form.signType === 'online'" label="调动模板" prop="onlineDocumentTemplateId">
-              <el-select v-model="form.onlineDocumentTemplateId" filterable clearable placeholder="选择已启用的岗位调动模板"
-                style="width: 100%">
-                <el-option v-for="item in enabledTemplates" :key="item.id"
-                  :label="item.templateName || item.name || `模板-${item.id}`" :value="item.id" />
+            <el-form-item label="异动类型" prop="changeType">
+              <el-select v-model="form.changeType" placeholder="请选择异动类型">
+                <el-option v-for="item in changeTypeOptions" :key="item.value" :label="item.label"
+                  :value="item.value" />
               </el-select>
             </el-form-item>
             <el-form-item label="变更原因" prop="reason">
@@ -380,7 +383,6 @@ import {
   canCancelUserChange,
   canEditUserChange,
   canSubmitUserChange,
-  canWithdrawUserChange,
   cancelUserChange,
   createUserChange,
   getEmploymentPeriods,
@@ -389,7 +391,6 @@ import {
   loadTransferChangeList,
   updateUserChange,
   validateUserChange,
-  withdrawUserChange,
   withEmployeeInfo,
 } from "@/api/hr/userChange";
 import BpmProcessDetailDialog from "@/views/bpm/processInstance/detailDialog.vue";
@@ -643,13 +644,6 @@ export default {
           align: "left",
           slot: "audit",
         },
-        {
-          columnKey: "docStatus",
-          label: "单据状态",
-          width: 100,
-          align: "left",
-          slot: "docStatus",
-        },
         {
           prop: "effectiveDate",
           label: "生效日期",
@@ -799,6 +793,33 @@ export default {
       );
       this.form.positionLevel = hit ? hit.positionLevel : "";
     },
+    /** 线上模板功能未开通的统一提示:先关掉上一条,避免连点堆叠多条提示 */
+    warnTemplateDisabled() {
+      this.$message.closeAll();
+      this.$message.warning("暂无开通模板功能,敬请期待");
+    },
+    /** 调动模板下拉展开时同样提示(编辑历史线上异动单时该字段仍会渲染) */
+    onTemplateDropdown(visible) {
+      if (visible) this.warnTemplateDisabled();
+    },
+    /** 签署方式分段按钮切换:普通按钮不会触发 change,需手动校验并处理另一侧残留值 */
+    onSignTypeChange(value) {
+      if (this.form.signType === value) return;
+      if (value === "online") {
+        // 线上模板未开通:只提示,不切换签署方式
+        this.warnTemplateDisabled();
+        return;
+      }
+      this.form.signType = value;
+      // 调动模板与签章附件互斥:切换时两侧都清空,避免提交时同时带上模板与附件
+      this.form.onlineDocumentTemplateId = null;
+      this.approvalDocIds = [];
+      this.form.approvalDocumentFileId = null;
+      this.$nextTick(() => {
+        this.$refs.transferForm?.validateField("signType");
+        this.$refs.transferForm?.clearValidate(["onlineDocumentTemplateId"]);
+      });
+    },
     /** 清空岗位序列以下的级联字段与下拉选项(岗位/性质/层级) */
     clearPositionCascade() {
       this.form.targetPositionId = null;
@@ -1137,9 +1158,6 @@ export default {
       const status = String(row?.approvalStatus || "").toUpperCase();
       return row?.docStatus !== "approved" && status !== "APPROVED";
     },
-    canWithdraw(row) {
-      return canWithdrawUserChange(row);
-    },
     openCreate() {
       this.formMode = "create";
       this.form = emptyForm();
@@ -1553,21 +1571,6 @@ export default {
         })
         .catch(() => { });
     },
-    withdrawRecord(row) {
-      this.$prompt("请填写撤回原因", "撤回异动审批", {
-        inputType: "textarea",
-      })
-        .then(async ({ value }) => {
-          try {
-            await withdrawUserChange(row.id, { reason: value });
-            this.$message.success("已撤回审批");
-            await this.loadList();
-          } catch (error) {
-            showRequestError(error, "撤回失败");
-          }
-        })
-        .catch(() => { });
-    },
     canLaunch(row) {
       // 草稿 / 已驳回均可提交审批(已驳回即使仍有旧流程实例 id 也允许重新发起)
       return canSubmitUserChange(row);

+ 32 - 6
src/views/hrManagement/regularizationHandling/index.vue

@@ -65,7 +65,7 @@
         :page-size="20" :cache-key="cacheKey" class="regularization-table" @selection-change="selectedRows = $event">
         <template v-slot:toolbar>
           <el-button type="primary" icon="el-icon-plus" class="ele-btn-icon" @click="openCreate()">
-            转正申请
+            新建
           </el-button>
           <el-button icon="el-icon-download" class="ele-btn-icon" @click="exportRows">
             导出
@@ -242,8 +242,10 @@
               </el-form-item>
               <el-form-item label="签署方式">
                 <div class="sign-method-switch">
-                  <button type="button" class="sign-method-btn" :class="{ 'is-active': isOnlineSign }"
-                    :disabled="readOnly" @click="!readOnly && setSigningMethod('ONLINE')">
+                  <!-- 线上模板功能未开通:按钮弱化展示,点击只提示不切换 -->
+                  <button type="button" class="sign-method-btn is-unavailable" :class="{ 'is-active': isOnlineSign }"
+                    :disabled="readOnly" title="暂无开通模板功能,敬请期待"
+                    @click="!readOnly && setSigningMethod('ONLINE')">
                     线上签署
                   </button>
                   <button type="button" class="sign-method-btn" :class="{ 'is-active': !isOnlineSign }"
@@ -252,14 +254,15 @@
                   </button>
                 </div>
               </el-form-item>
+              <!-- 转正申请模板 / 申请资料:二者只能出现一个,占签署方式右侧同一单元格 -->
               <el-form-item v-if="isOnlineSign" label="转正申请模板" prop="onlineDocumentTemplateId">
                 <el-select v-model="form.onlineDocumentTemplateId" filterable clearable :loading="templateLoading"
-                  placeholder="请选择已启用线上模板">
+                  placeholder="请选择已启用线上模板" @visible-change="onTemplateDropdown">
                   <el-option v-for="item in enabledTemplates" :key="item.id" :label="item.templateName || item.name"
                     :value="item.id" />
                 </el-select>
               </el-form-item>
-              <el-form-item v-else label="申请资料" prop="offlineFileIds" class="is-span-2">
+              <el-form-item v-else label="申请资料" prop="offlineFileIds">
                 <fileMain v-model="offlineDocIds" :type="readOnly ? 'view' : ''" size="small" />
               </el-form-item>
             </div>
@@ -1023,11 +1026,34 @@ export default {
       const signingMethod = this.normalizeSigningMethod(raw);
       return { signingMethod, online: signingMethod === "ONLINE" };
     },
-    /** 切换签署方式:同时更新 signingMethod 与线上开关 */
+    /** 线上模板功能未开通的统一提示:先关掉上一条,避免连点堆叠多条提示 */
+    warnTemplateDisabled() {
+      this.$message.closeAll();
+      this.$message.warning("暂无开通模板功能,敬请期待");
+    },
+    /** 转正申请模板下拉展开时同样提示(历史线上转正单该字段仍会渲染) */
+    onTemplateDropdown(visible) {
+      if (visible) this.warnTemplateDisabled();
+    },
+    /** 切换签署方式:同时更新 signingMethod 与线上开关,并清掉另一侧取值 */
     setSigningMethod(method) {
       const next = String(method).toUpperCase() === "ONLINE" ? "ONLINE" : "OFFLINE";
+      if (next === "ONLINE") {
+        // 线上模板未开通:只提示,不切换签署方式
+        this.warnTemplateDisabled();
+        return;
+      }
+      if (this.form.signingMethod === next) return;
       this.form.signingMethod = next;
       this.form.online = next === "ONLINE";
+      // 线上模板与线下附件互斥:切换时两侧都清空,避免提交时同时带上模板与附件
+      this.form.onlineDocumentTemplateId = null;
+      this.form.offlineFileIds = [];
+      this.form.approvalDocumentFileId = null;
+      this.offlineDocIds = [];
+      this.$nextTick(() => {
+        this.$refs.applyForm?.clearValidate(["onlineDocumentTemplateId", "offlineFileIds"]);
+      });
     },
     /** 取员工的转正单主键:优先列表行,其次分页接口,最后本地缓存兜底 */
     async resolveBillId(row = {}) {

+ 21 - 7
src/views/hrManagement/resignationApplication/index.vue

@@ -244,9 +244,10 @@
               </el-form-item>
               <el-form-item label="办理方式" prop="handlingMode">
                 <div class="cm-create-sign-method">
-                  <button type="button" class="cm-method-btn"
+                  <!-- 线上模板功能未开通:按钮弱化展示,点击只提示不切换 -->
+                  <button type="button" class="cm-method-btn is-unavailable"
                     :class="{ 'is-active': editing.handlingMode === 'ONLINE' }" :disabled="formReadOnly"
-                    @click="!formReadOnly && setHandlingMode('ONLINE')">
+                    title="暂无开通模板功能,敬请期待" @click="!formReadOnly && setHandlingMode('ONLINE')">
                     线上模板
                   </button>
                   <button type="button" class="cm-method-btn"
@@ -258,7 +259,7 @@
               </el-form-item>
               <el-form-item v-if="isOnlineHandling" label="离职模板" prop="onlineDocumentTemplateId">
                 <el-select v-model="editing.onlineDocumentTemplateId" filterable clearable :loading="templateLoading"
-                  :disabled="formReadOnly" placeholder="请选择已启用的线上模板">
+                  :disabled="formReadOnly" placeholder="请选择已启用的线上模板" @visible-change="onTemplateDropdown">
                   <el-option v-for="item in enabledTemplates" :key="item.id" :label="item.templateName || item.name"
                     :value="item.id" />
                 </el-select>
@@ -829,16 +830,29 @@ export default {
         this.templateLoading = false;
       }
     },
+    /** 线上模板功能未开通的统一提示:先关掉上一条,避免连点堆叠多条提示 */
+    warnTemplateDisabled() {
+      this.$message.closeAll();
+      this.$message.warning("暂无开通模板功能,敬请期待");
+    },
+    /** 离职模板下拉展开时同样提示(历史线上离职单该字段仍会渲染) */
+    onTemplateDropdown(visible) {
+      if (visible) this.warnTemplateDisabled();
+    },
     /** 切换办理方式:ONLINE 线上模板 / OFFLINE 线下合同,清掉另一分支取值与校验提示 */
     setHandlingMode(mode) {
       const next =
         String(mode || "").toUpperCase() === "ONLINE" ? "ONLINE" : "OFFLINE";
-      this.editing.handlingMode = next;
       if (next === "ONLINE") {
-        this.editing.offlineFileIds = [];
-      } else {
-        this.editing.onlineDocumentTemplateId = null;
+        // 线上模板未开通:只提示,不切换办理方式
+        this.warnTemplateDisabled();
+        return;
       }
+      if (this.editing.handlingMode === next) return;
+      this.editing.handlingMode = next;
+      // 线上模板与线下附件互斥:切换时两侧都清空,避免提交时同时带上模板与附件
+      this.editing.onlineDocumentTemplateId = null;
+      this.editing.offlineFileIds = [];
       this.$nextTick(() => {
         this.$refs.form?.clearValidate([
           "onlineDocumentTemplateId",

+ 10 - 40
src/views/hrManagement/resignationHandoverOrder/HandoverGenerateDialog.vue

@@ -112,7 +112,7 @@
             <span><i class="el-icon-edit-outline"></i></span>
             <div>
               <h3>交接信息</h3>
-              <p>填写交接日期、交接人及交接方式</p>
+              <p>填写交接日期、交接人及交接附件</p>
             </div>
           </header>
           <div class="form-grid">
@@ -128,28 +128,10 @@
               </el-select>
             </el-form-item>
           </div>
-          <el-form-item label="交接方式" required>
-            <div class="sign-method-switch">
-              <button type="button" class="sign-method-btn" :class="{ 'is-active': generateForm.mode === 'online' }"
-                :disabled="isEdit" @click="setMode('online')">
-                线上文档
-              </button>
-              <button type="button" class="sign-method-btn" :class="{ 'is-active': generateForm.mode === 'offline' }"
-                :disabled="isEdit" @click="setMode('offline')">
-                线下附件
-              </button>
-            </div>
-            <div class="form-hint-line">
-              {{
-                generateForm.mode === "online"
-                  ? "线上交接由服务端创建线上文档,无需上传附件"
-                  : "线下交接必须上传交接附件(可多选)"
-              }}
-              <template v-if="isEdit">;交接单创建后不可更换交接方式</template>
-            </div>
-          </el-form-item>
-          <el-form-item v-if="generateForm.mode === 'offline'" label="交接附件" required>
+          <!-- 交接方式固定为线下(handoverType=OFFLINE),交接附件恒常展示且必传 -->
+          <el-form-item label="交接附件" required>
             <fileMain :value="generateForm.offlineFileIds" @updateVal="onOfflineFileChange" size="small" />
+            <div class="form-hint-line">必须上传交接附件(可多选)</div>
           </el-form-item>
           <el-form-item label="交接说明">
             <el-input v-model.trim="generateForm.remark" type="textarea" :rows="2" maxlength="200" show-word-limit
@@ -360,7 +342,6 @@ const emptyForm = () => ({
   resignationId: "",
   handoverDate: today(),
   remark: "",
-  mode: "offline",
   offlineFileIds: [],
   handoverUserId: undefined,
   items: defaultItems(),
@@ -563,10 +544,6 @@ export default {
           resignationId: String(detail.resignationId || ""),
           handoverDate: detail.handoverDate || today(),
           remark: detail.remark || "",
-          mode:
-            String(detail.handoverType || "").toUpperCase() === "ONLINE"
-              ? "online"
-              : "offline",
           offlineFileIds: [...(detail.attachmentIdList || [])],
           handoverUserId: detail.handoverUserId
             ? String(detail.handoverUserId)
@@ -765,11 +742,6 @@ export default {
       if (["IN_PROGRESS", "PROCESSING"].includes(key)) return "warning";
       return "info";
     },
-    setMode(mode) {
-      if (this.isEdit) return;
-      this.generateForm.mode = mode;
-      if (mode === "online") this.generateForm.offlineFileIds = [];
-    },
     addItem() {
       this.generateForm.items.push(emptyItem());
     },
@@ -801,10 +773,10 @@ export default {
         this.$message.warning("请选择交接人");
         return;
       }
-      const handoverType =
-        this.generateForm.mode === "online" ? "ONLINE" : "OFFLINE";
-      if (handoverType === "OFFLINE" && !this.generateForm.offlineFileIds.length) {
-        this.$message.warning("线下交接必须上传交接附件");
+      // 交接方式固定为线下(线上交接未开通)
+      const handoverType = "OFFLINE";
+      if (!this.generateForm.offlineFileIds.length) {
+        this.$message.warning("必须上传交接附件");
         return;
       }
       if (!this.generateForm.items.length) {
@@ -834,10 +806,8 @@ export default {
           receiverUserId: item.receiverUserId || undefined,
         })),
       };
-      // ONLINE 不得传附件主键;OFFLINE 必须传逗号分隔附件主键
-      if (handoverType === "OFFLINE") {
-        payload.attachmentIds = this.generateForm.offlineFileIds.join(",");
-      }
+      // 线下交接必须传逗号分隔附件主键
+      payload.attachmentIds = this.generateForm.offlineFileIds.join(",");
 
       if (isEdit) {
         // 仅回传已进入归还清单(有主键)的行:修改接口不接受新增归还明细;

+ 1 - 1
src/views/hrManagement/resignationHandoverOrder/index.vue

@@ -70,7 +70,7 @@
         :page-size="20" class="resignation-table">
         <template v-slot:toolbar>
           <el-button type="primary" icon="el-icon-plus" class="ele-btn-icon" @click="openGenerate">
-            创建交接单
+            新建
           </el-button>
           <el-button icon="el-icon-download" class="ele-btn-icon" @click="exportData">
             导出

+ 13 - 3
src/views/position/positionApplication/index.vue

@@ -49,7 +49,9 @@
           <span v-else>-</span>
         </template>
         <template v-slot:code="{ row }">
-          {{ row.code || "-" }}
+          <el-link v-if="row.code" type="primary" :underline="false" @click.stop="openView(row)">{{
+            row.code }}</el-link>
+          <span v-else>-</span>
         </template>
         <template v-slot:name="{ row }">
           {{ row.positionName || "-" }}
@@ -236,10 +238,10 @@
                   placeholder="请选择岗位性质"><el-option v-for="item in typeOptions" :key="String(item.value)"
                     :label="item.label" :value="item.value" /></el-select></el-form-item>
               <el-form-item label="岗位序列" prop="positionSequence"><el-select v-model="form.positionSequence" filterable
-                  placeholder="请选择岗位序列(选填)" @change="handleSequenceChange"><el-option v-for="item in sequenceOptions"
+                  placeholder="请选择岗位序列" @change="handleSequenceChange"><el-option v-for="item in sequenceOptions"
                     :key="item" :label="item" :value="item" /></el-select></el-form-item>
               <el-form-item label="岗位层级" prop="positionLevel"><el-select v-model="form.positionLevel"
-                  placeholder="请选择岗位层级(选填,随岗位序列联动)"><el-option v-for="item in levelOptions" :key="item.value"
+                  placeholder="请选择岗位层级(随岗位序列联动)"><el-option v-for="item in levelOptions" :key="item.value"
                     :label="item.label" :value="item.value" /></el-select></el-form-item>
               <el-form-item label="申请招聘人数" prop="headcount"><el-input-number v-model="form.headcount" :min="1"
                   :max="999" controls-position="right" /></el-form-item>
@@ -488,6 +490,13 @@ export default {
           { required: true, message: "请选择所属机构", trigger: "change" },
         ],
         type: [{ required: true, message: "请选择岗位性质", trigger: "change" }],
+        // 岗位层级选项由岗位序列联动产生,序列不选则层级无可选项,故两者一并必填
+        positionSequence: [
+          { required: true, message: "请选择岗位序列", trigger: "change" },
+        ],
+        positionLevel: [
+          { required: true, message: "请选择岗位层级", trigger: "change" },
+        ],
         headcount: [
           { required: true, message: "请输入申请招聘人数", trigger: "change" },
         ],
@@ -514,6 +523,7 @@ export default {
           formatter: (_r, _c, value) => value || "-",
         },
         {
+          prop: "code",
           columnKey: "code",
           label: "岗位编码",
           width: 150,