Ver Fonte

考勤明细页按后端补全字段重做表头(上班/下班两级)+ 规则助手员工多选不折叠 + 我的工资条列改 minWidth 撑满:api/hr/attendance.js 新增 adaptDailyAttendance 归一模型,姓名直取 userName、岗位 postName、所属部门 deptName/deptNames、机构 groupName、当日状态 statusNameList、计算状态 calculationStatusName,punchRecords 内 punchMethodName/punchTypeName/sourceTypeName/statusName/address 全部用后端中文,删掉前端本地英文码映射表(DAILY_PUNCH_METHOD_LABEL/dailyPunchMethodLabel 一并移除);打卡记录按 punchType=IN/OUT 分成 inPunches/outPunches,列表表头改两级——上班(打卡情况/打卡类型/打卡时间/打卡地点)与下班同名四列,打卡时间只留 HH:mm:ss,日期列取消排序(服务端分页下前端排序只排当前页会误导)并固定左侧;详情抽屉同步改用 postName/deptName/statusText/calculationStatusName 与 punchTypeName;RuleAssistantPanel 员工多选去掉 collapse-tags,全员展示不折叠;我的工资条内容列由固定 width 改 minWidth,避免表格右侧留大块空白;员工档案「全部员工」文案注明(在职)

lihaiting há 1 dia atrás
pai
commit
2a1fa92f4e

+ 78 - 74
src/api/hr/attendance.js

@@ -1870,19 +1870,79 @@ export function exceptionLabel(type) {
 
 /* ==================== 考勤明细(每日考勤 GET /hr/attendance/daily) ==================== */
 
-/** 打卡方式展示文案(punchRecords.punchMethod) */
-export const DAILY_PUNCH_METHOD_LABEL = {
-  GPS: 'GPS打卡',
-  WIFI: 'WiFi打卡',
-  BEACON: '蓝牙打卡',
-  ATTENDANCE_MACHINE: '考勤机打卡',
-  FACE: '人脸打卡',
-  PC: 'PC打卡',
-  FIELD: '外勤打卡',
-};
-
-export function dailyPunchMethodLabel(method) {
-  return DAILY_PUNCH_METHOD_LABEL[method] || method || '';
+/**
+ * 每日考勤结果返回模型 → 页面行模型。
+ * ★ 2026-11-02 后端补 userName(姓名)/ postName(岗位):姓名改直取 userName,不再按 userId 走
+ *   loadEmployeeBriefs 匹配主数据。
+ * ★ 2026-10-30 后端已补中文字段:deptName(主部门)/deptNames(隶属部门,逗号分隔)/groupName(机构)/
+ *   statusNameList(当日状态名称)/punchAddress(打卡地址汇总)/workAddress(工作地址)/
+ *   calculationStatusName(计算状态名称),punchRecords 内新增 punchMethodName/punchTypeName/
+ *   sourceTypeName/statusName/punchAddress —— 前端本地英文码映射全部移除,直接展示后端中文,未返回显空。
+ * ★ 2026-11-02 列表表头改两级:punchRecords 内 punchType=IN 归「上班」、OUT 归「下班」,
+ *   各自输出 inPunches / outPunches(打卡情况 statusName、打卡类型 sourceTypeName、打卡时间 punchTime)。
+ */
+export function adaptDailyAttendance(raw = {}) {
+  const records = Array.isArray(raw.punchRecords) ? raw.punchRecords : [];
+  const statusNames = Array.isArray(raw.statusNameList) ? raw.statusNameList : [];
+  const toPunchItem = (item) => ({
+    punchRecordId: keepBigIntId(item.punchRecordId),
+    /* 打卡时间只留时分秒 HH:mm:ss(2026-11-02 用户要求,日期看「日期」列) */
+    punchTime: formatDailyTime(item.punchTime),
+    punchMethod: item.punchMethodName || '',
+    /* punchType 保留后端原始码(IN/OUT);punchTypeName 是其中文名(上班/下班) */
+    punchType: item.punchType || '',
+    punchTypeName: item.punchTypeName || '',
+    sourceType: item.sourceTypeName || '',
+    /* 上下班分组「打卡地点」列:punchRecords 内的 address 直取(2026-11-03 用户指定字段) */
+    address: item.address || '',
+    status: item.statusName || '',
+    statusText: item.statusName || '',
+    punchAddress: item.punchAddress || '',
+  });
+  return {
+    id: keepBigIntId(raw.id),
+    userId: keepBigIntId(raw.userId),
+    /* 姓名/岗位:后端字段直取(2026-11-02 起不再按 id 匹配主数据) */
+    name: raw.userName || '',
+    postName: raw.postName || '',
+    /* 部门/机构:★ 2026-11-02 用户确认「所属部门」字段是 deptName(主部门),
+       不是 deptNames(隶属部门,列表页不用);「所属机构」字段是 groupName。两者都直取,不改名 */
+    deptName: raw.deptName || '',
+    groupName: raw.groupName || '',
+    attendanceDate: String(raw.attendanceDate || '').slice(0, 10),
+    statusList: Array.isArray(raw.statusList) ? raw.statusList : [],
+    statusNameList: statusNames,
+    statusText: statusNames.join('、'),
+    /* 打卡地址/工作地址:后端 2026-10-30 新增,直取 */
+    punchAddress: raw.punchAddress || '',
+    punchStatuses: records.map(toPunchItem),
+    /* 上下班分组(2026-11-02 用户指定):punchRecords 内 punchType=IN 归「上班」、OUT 归「下班」,
+       每组各自渲染 打卡情况(statusName)/ 打卡类型(sourceTypeName)/ 打卡时间(punchTime);
+       非 IN/OUT 或缺失的记录两组都不展示,不做兜底 */
+    inPunches: records.filter((item) => item.punchType === 'IN').map(toPunchItem),
+    outPunches: records.filter((item) => item.punchType === 'OUT').map(toPunchItem),
+    actualIn: formatDailyDateTime(raw.actualIn),
+    actualOut: formatDailyDateTime(raw.actualOut),
+    workAddress: raw.workAddress || '',
+    scheduledStart: formatDailyDateTime(raw.scheduledStart),
+    scheduledEnd: formatDailyDateTime(raw.scheduledEnd),
+    requiredMinutes: raw.requiredMinutes ?? null,
+    actualWorkMinutes: raw.actualWorkMinutes ?? null,
+    effectiveWorkMinutes: raw.effectiveWorkMinutes ?? null,
+    lateMinutes: raw.lateMinutes ?? null,
+    earlyMinutes: raw.earlyMinutes ?? null,
+    absenceMinutes: raw.absenceMinutes ?? null,
+    leaveMinutes: raw.leaveMinutes ?? null,
+    outingMinutes: raw.outingMinutes ?? null,
+    tripMinutes: raw.tripMinutes ?? null,
+    overtimeMinutes: raw.overtimeMinutes ?? null,
+    nightMinutes: raw.nightMinutes ?? null,
+    exceptionCount: raw.exceptionCount ?? null,
+    calculationStatus: raw.calculationStatus || '',
+    calculationStatusName: raw.calculationStatusName || '',
+    lastCalcTime: formatDailyDateTime(raw.lastCalcTime),
+    raw,
+  };
 }
 
 /** ISO 时间 → yyyy-MM-dd HH:mm:ss(无时间部分只留日期) */
@@ -1894,10 +1954,11 @@ function formatDailyDateTime(value) {
   return time ? `${date} ${time}` : date;
 }
 
-/** 打卡记录「打卡情况」:补卡来源优先标补卡,其余按 status 出文案 */
-export function dailyPunchStatusText(record = {}) {
-  if (record.sourceType === 'REPAIR') return '补卡';
-  return { VALID: '正常', INVALID: '无效', PENDING_APPROVAL: '待审批' }[record.status] || record.status || '';
+/** 打卡记录时间 → HH:mm:ss(只留时分秒;接口没给秒则补 :00,取不到时间显空) */
+function formatDailyTime(value) {
+  const match = String(value || '').match(/(\d{1,2}:\d{2})(?::(\d{2}))?/);
+  if (!match) return '';
+  return match[2] !== undefined ? `${match[1]}:${match[2]}` : `${match[1]}:00`;
 }
 
 /**
@@ -1922,61 +1983,4 @@ export const getDailyAttendanceDetailPage = async (params = {}) => {
   return pageResult(await unwrap(await request.get('/hr/attendance/daily', { params: query })));
 };
 
-/**
- * 每日考勤结果返回模型 → 页面行模型。
- * ★ 接口不返回姓名/部门(只回 userId):姓名、工号、部门走 loadEmployeeBriefs 主数据批量接口补齐,
- *   查不到就留空;打卡地址、工作地址接口完全没有对应字段,恒留空,缺口转后端改造清单。
- * ★ 所属机构:/main/user/batch 实测不返回 groupName(2026-10-30),brief.company 恒空 → 列留空。
- */
-export function adaptDailyAttendance(raw = {}, brief = null) {
-  const records = Array.isArray(raw.punchRecords) ? raw.punchRecords : [];
-  const statusList = Array.isArray(raw.statusList) ? raw.statusList : [];
-  return {
-    id: keepBigIntId(raw.id),
-    userId: keepBigIntId(raw.userId),
-    name: brief?.name || '',
-    employeeNo: brief?.employeeNo || '',
-    department: brief?.department || '',
-    organization: brief?.company || '',
-    attendanceDate: String(raw.attendanceDate || '').slice(0, 10),
-    statusList,
-    statusText: statusList.map((item) => exceptionLabel(item)).join('、'),
-    punchTypes: [
-      ...new Set(records.map((item) => dailyPunchMethodLabel(item.punchMethod)).filter(Boolean)),
-    ].join('、'),
-    /* 接口未返回打卡地址(文档明确 punchRecords 不含定位),留空待后端补充 */
-    punchAddress: '',
-    punchStatuses: records.map((item) => ({
-      punchRecordId: keepBigIntId(item.punchRecordId),
-      punchTime: formatDailyDateTime(item.punchTime),
-      punchMethod: dailyPunchMethodLabel(item.punchMethod),
-      punchType: item.punchType || '',
-      sourceType: item.sourceType || '',
-      status: item.status || '',
-      statusText: dailyPunchStatusText(item),
-    })),
-    actualIn: formatDailyDateTime(raw.actualIn),
-    actualOut: formatDailyDateTime(raw.actualOut),
-    /* 接口未返回工作地址,留空待后端补充 */
-    workAddress: '',
-    scheduledStart: formatDailyDateTime(raw.scheduledStart),
-    scheduledEnd: formatDailyDateTime(raw.scheduledEnd),
-    requiredMinutes: raw.requiredMinutes ?? null,
-    actualWorkMinutes: raw.actualWorkMinutes ?? null,
-    effectiveWorkMinutes: raw.effectiveWorkMinutes ?? null,
-    lateMinutes: raw.lateMinutes ?? null,
-    earlyMinutes: raw.earlyMinutes ?? null,
-    absenceMinutes: raw.absenceMinutes ?? null,
-    leaveMinutes: raw.leaveMinutes ?? null,
-    outingMinutes: raw.outingMinutes ?? null,
-    tripMinutes: raw.tripMinutes ?? null,
-    overtimeMinutes: raw.overtimeMinutes ?? null,
-    nightMinutes: raw.nightMinutes ?? null,
-    exceptionCount: raw.exceptionCount ?? null,
-    calculationStatus: raw.calculationStatus || '',
-    lastCalcTime: formatDailyDateTime(raw.lastCalcTime),
-    raw,
-  };
-}
-
 

+ 1 - 1
src/views/attendance/components/RuleAssistantPanel.vue

@@ -11,13 +11,13 @@
         </el-form-item>
         <template v-if="form.assistantEnabled">
           <el-form-item label="无需打卡员工">
+            <!-- 不加 collapse-tags:多选的员工要全部展示(2026-11-03 用户反馈折叠看不到全量) -->
             <el-select
               v-model="form.exemptUserIds"
               multiple
               filterable
               remote
               clearable
-              collapse-tags
               reserve-keyword
               placeholder="搜索并选择员工"
               style="width:100%"

+ 161 - 61
src/views/attendance/daily/index.vue

@@ -73,7 +73,7 @@
         :need-page="true"
         :page-size="20"
         :page-sizes="[10, 20, 50]"
-        :cache-key="$tableCacheKey('hr-attendance-daily-v3')"
+        :cache-key="$tableCacheKey('hr-attendance-daily-v9')"
       >
         <template v-slot:toolbar>
           <!-- 后端暂无导入接口:点击如实提示,缺口已转后端改造清单 -->
@@ -88,15 +88,57 @@
           </el-link>
         </template>
 
-        <template slot="statusText" slot-scope="{ row }">
-          <span>{{ row.statusText }}</span>
+        <template slot="statusNameList" slot-scope="{ row }">
+          <!-- 出勤情况:当日状态名称(如 正常/迟到/旷工),tag 一行一个、左对齐(2026-11-03 用户要求排列整齐) -->
+          <div v-for="(name, index) in row.statusNameList" :key="index" class="status-tag-line">
+            <el-tag size="mini" :type="attendanceStatusTagType(name)" class="punch-tag">
+              {{ name }}
+            </el-tag>
+          </div>
+        </template>
+
+        <template slot="inStatus" slot-scope="{ row }">
+          <!-- 上班·打卡情况:punchRecords 中 punchType=IN 记录的 statusName(后端中文直取) -->
+          <template v-if="row.inPunches.length">
+            <el-tag
+              v-for="(item, index) in row.inPunches"
+              :key="index"
+              size="mini"
+              :type="punchStatusTagType(item)"
+              class="punch-tag"
+            >
+              {{ item.statusText }}
+            </el-tag>
+          </template>
+        </template>
+
+        <template slot="inType" slot-scope="{ row }">
+          <!-- 上班·打卡类型:记录的 sourceTypeName(移动端/考勤设备/补卡),纯文本逐行(2026-11-03 用户要求不用标签) -->
+          <div v-for="(item, index) in row.inPunches" :key="index" class="punch-time-line">
+            {{ item.sourceType }}
+          </div>
+        </template>
+
+        <template slot="inTime" slot-scope="{ row }">
+          <!-- 上班·打卡时间:多条记录逐行展示,接口未返回则留空 -->
+          <div v-for="(item, index) in row.inPunches" :key="index" class="punch-time-line">
+            {{ item.punchTime }}
+          </div>
+        </template>
+
+        <template slot="inAddress" slot-scope="{ row }">
+          <!-- 上班·打卡地点:punchRecords 中 punchType=IN 记录的 address,逐行展示,未返回则留空 -->
+          <div v-for="(item, index) in row.inPunches" :key="index" class="punch-time-line">
+            {{ item.address }}
+          </div>
         </template>
 
-        <template slot="punchStatuses" slot-scope="{ row }">
-          <template v-if="row.punchStatuses.length">
+        <template slot="outStatus" slot-scope="{ row }">
+          <!-- 下班·打卡情况:punchRecords 中 punchType=OUT 记录的 statusName -->
+          <template v-if="row.outPunches.length">
             <el-tag
-              v-for="item in row.punchStatuses"
-              :key="item.punchRecordId"
+              v-for="(item, index) in row.outPunches"
+              :key="index"
               size="mini"
               :type="punchStatusTagType(item)"
               class="punch-tag"
@@ -106,6 +148,27 @@
           </template>
         </template>
 
+        <template slot="outType" slot-scope="{ row }">
+          <!-- 下班·打卡类型:记录的 sourceTypeName,纯文本逐行(2026-11-03 用户要求不用标签) -->
+          <div v-for="(item, index) in row.outPunches" :key="index" class="punch-time-line">
+            {{ item.sourceType }}
+          </div>
+        </template>
+
+        <template slot="outTime" slot-scope="{ row }">
+          <!-- 下班·打卡时间:多条记录逐行展示,接口未返回则留空 -->
+          <div v-for="(item, index) in row.outPunches" :key="index" class="punch-time-line">
+            {{ item.punchTime }}
+          </div>
+        </template>
+
+        <template slot="outAddress" slot-scope="{ row }">
+          <!-- 下班·打卡地点:punchRecords 中 punchType=OUT 记录的 address,逐行展示,未返回则留空 -->
+          <div v-for="(item, index) in row.outPunches" :key="index" class="punch-time-line">
+            {{ item.address }}
+          </div>
+        </template>
+
         <template slot="empty">
           <el-empty description="暂无数据" />
         </template>
@@ -121,10 +184,10 @@
     >
       <el-descriptions v-if="detailRow" :column="2" border size="small">
         <el-descriptions-item label="姓名">{{ detailRow.name }}</el-descriptions-item>
-        <el-descriptions-item label="工号">{{ detailRow.employeeNo }}</el-descriptions-item>
-        <el-descriptions-item label="所属部门">{{ detailRow.department }}</el-descriptions-item>
+        <el-descriptions-item label="岗位">{{ detailRow.postName }}</el-descriptions-item>
+        <el-descriptions-item label="所属部门">{{ detailRow.deptName }}</el-descriptions-item>
         <el-descriptions-item label="日期">{{ detailRow.attendanceDate }}</el-descriptions-item>
-        <el-descriptions-item label="出勤情况" :span="2">{{ detailRow.statusText }}</el-descriptions-item>
+        <el-descriptions-item label="打卡情况" :span="2">{{ detailRow.statusText }}</el-descriptions-item>
         <el-descriptions-item label="计划上班">{{ detailRow.scheduledStart }}</el-descriptions-item>
         <el-descriptions-item label="计划下班">{{ detailRow.scheduledEnd }}</el-descriptions-item>
         <el-descriptions-item label="上班打卡时间">{{ detailRow.actualIn }}</el-descriptions-item>
@@ -141,7 +204,7 @@
         <el-descriptions-item label="出差(分钟)">{{ minuteText(detailRow.tripMinutes) }}</el-descriptions-item>
         <el-descriptions-item label="加班(分钟)">{{ minuteText(detailRow.overtimeMinutes) }}</el-descriptions-item>
         <el-descriptions-item label="夜班(分钟)">{{ minuteText(detailRow.nightMinutes) }}</el-descriptions-item>
-        <el-descriptions-item label="计算状态">{{ calculationStatusText(detailRow.calculationStatus) }}</el-descriptions-item>
+        <el-descriptions-item label="计算状态">{{ detailRow.calculationStatusName }}</el-descriptions-item>
         <el-descriptions-item label="最近核算时间">{{ detailRow.lastCalcTime }}</el-descriptions-item>
       </el-descriptions>
 
@@ -150,7 +213,7 @@
         <el-table :data="detailRow.punchStatuses" size="small" border>
           <el-table-column prop="punchTime" label="打卡时间" min-width="150" />
           <el-table-column prop="punchMethod" label="打卡方式" min-width="100" />
-          <el-table-column prop="punchType" label="打卡类型" min-width="120" />
+          <el-table-column prop="punchTypeName" label="打卡类型" min-width="120" />
           <el-table-column prop="statusText" label="打卡情况" min-width="90" />
           <el-table-column prop="sourceType" label="数据来源" min-width="90" />
         </el-table>
@@ -165,7 +228,6 @@
 
 <script>
 import * as XLSX from 'xlsx';
-import { loadEmployeeBriefs } from '@/api/organization';
 import { getCurrentOrgId, getOrgOptions } from '@/utils/current-org';
 import { loadOrganizationList } from '@/utils/org-list-cache';
 import {
@@ -178,12 +240,6 @@ import {
   adaptDailyAttendance,
 } from '@/api/hr/attendance';
 
-const CALCULATION_STATUS_LABEL = {
-  PROVISIONAL: '实时暂态',
-  CALCULATED: '最终结果',
-  LOCKED: '月结锁定',
-};
-
 /* 日期筛选默认当月:本月 1 号 ~ 今天(2026-10-30 用户指定默认查当月) */
 function currentMonthRange() {
   const now = new Date();
@@ -205,24 +261,39 @@ export default {
         dateRange: currentMonthRange(),
       },
       filterOrgOptions: [],
-      /* 组织树全量部门(扁平,带 level 缩进);按选中机构子树过滤见 filterDeptOptions */
       allDeptOptions: [],
       orgTreeNodes: [],
       columns: [
         { type: 'index', label: '序号', width: 60, align: 'center', headerAlign: 'center', fixed: 'left' },
-        /* /hr/attendance/daily 与 /main/user/batch 均未返回机构字段:列保留、值留空,缺口转后端清单 */
-        { prop: 'organization', label: '所属机构', minWidth: 180 },
+        /* 日期列不排序:后端 /hr/attendance/daily 无排序参数,服务端分页下前端排序只能排当前页,会误导 */
+        { prop: 'attendanceDate', label: '日期', minWidth: 110, fixed: 'left' },
         { prop: 'name', label: '姓名', minWidth: 100, slot: 'name', fixed: 'left' },
-        { prop: 'department', label: '所属部门', minWidth: 140 },
-        { prop: 'attendanceDate', label: '日期', minWidth: 110, sortable: 'custom' },
-        { prop: 'statusText', label: '出勤情况', minWidth: 120, slot: 'statusText' },
-        { prop: 'punchTypes', label: '打卡类型', minWidth: 130 },
-        /* 接口未返回打卡地址:列保留、值留空,缺口转后端改造清单 */
-        { prop: 'punchAddress', label: '打卡地址', minWidth: 140 },
-        { prop: 'punchStatuses', label: '打卡情况', minWidth: 120, slot: 'punchStatuses' },
-        { prop: 'actualIn', label: '上班打卡时间', minWidth: 160 },
-        { prop: 'actualOut', label: '下班打卡时间', minWidth: 160 },
-        /* 接口未返回工作地址:列保留、值留空,缺口转后端改造清单 */
+        { prop: 'postName', label: '岗位', minWidth: 130 },
+        { prop: 'groupName', label: '所属机构', minWidth: 220 },
+        { prop: 'deptName', label: '所属部门', minWidth: 140 },
+        /* 出勤情况:当日状态名称集合,后端 statusNameList 直取(2026-11-02 用户指定字段);tag 逐行左对齐 */
+        { prop: 'statusNameList', label: '出勤情况', minWidth: 130, align: 'left', headerAlign: 'left', slot: 'statusNameList' },
+        {
+          label: '上班',
+          align: 'center',
+          children: [
+            { prop: 'inStatus', label: '打卡状态', minWidth: 100, slot: 'inStatus' },
+            { prop: 'inType', label: '打卡类型', minWidth: 110, slot: 'inType' },
+            { prop: 'inTime', label: '打卡时间', minWidth:90, slot: 'inTime' },
+            /* 打卡地点:punchRecords 内 address 直取(2026-11-03 用户指定),分组各自取 IN/OUT 记录 */
+            { prop: 'inAddress', label: '打卡地点', minWidth:90, slot: 'inAddress' },
+          ],
+        },
+        {
+          label: '下班',
+          align: 'center',
+          children: [
+            { prop: 'outStatus', label: '打卡状态', minWidth: 100, slot: 'outStatus' },
+            { prop: 'outType', label: '打卡类型', minWidth: 110, slot: 'outType' },
+            { prop: 'outTime', label: '打卡时间', minWidth: 90, slot: 'outTime' },
+            { prop: 'outAddress', label: '打卡地点', minWidth:90, slot: 'outAddress' },
+          ],
+        },
         { prop: 'workAddress', label: '工作地址', minWidth: 140 },
       ],
       /* 当前页数据快照:本地 excel 导出用(后端暂无导出接口) */
@@ -236,8 +307,6 @@ export default {
     this.loadDeptOptions();
   },
   computed: {
-    /* 部门选项:选中机构时只列该机构子树下的部门(不含机构自身);
-       机构不在组织树里时展示全量部门,保证部门筛选仍可用 */
     filterDeptOptions() {
       if (!this.filter.orgId) return this.allDeptOptions;
       const node = this.findOrgNode(this.orgTreeNodes, this.filter.orgId);
@@ -304,25 +373,29 @@ export default {
         dateStart: (this.filter.dateRange || [])[0] || undefined,
         dateEnd: (this.filter.dateRange || [])[1] || undefined,
       });
-      /* 接口只回 userId:姓名/工号/部门走主数据批量接口补齐(≤200/次,带缓存) */
-      const briefs = await loadEmployeeBriefs((data.list || []).map((item) => item.userId));
-      this.currentRows = (data.list || []).map((raw) =>
-        adaptDailyAttendance(raw, briefs.get(String(raw.userId ?? ''))),
-      );
+      /* 姓名/岗位/部门/机构/状态/地址等字段均直取 daily 接口返回(后端已补中文与姓名岗位字段) */
+      this.currentRows = (data.list || []).map((raw) => adaptDailyAttendance(raw));
       return { list: this.currentRows, count: data.count };
     },
     punchStatusTagType(item) {
-      if (item.statusText === '正常') return 'success';
+      /* 文案与色值均按后端中文字段判断:statusName=有效/已作废/待审批,sourceType=移动端/考勤设备/补卡 */
+      if (item.sourceType === '补卡' || item.statusText === '已作废') return 'danger';
+      if (item.statusText === '有效') return 'success';
       if (item.statusText === '待审批') return 'warning';
-      if (item.statusText === '补卡' || item.statusText === '无效') return 'danger';
+      return 'info';
+    },
+    /* 出勤情况 tag 颜色:按后端状态中文字段判断(正常=绿,迟到/早退/缺卡类=橙,旷工=红,其他=灰) */
+    attendanceStatusTagType(name) {
+      const text = String(name || '');
+      if (!text) return 'info';
+      if (text.includes('正常')) return 'success';
+      if (text.includes('旷工')) return 'danger';
+      if (text.includes('迟到') || text.includes('早退') || text.includes('缺')) return 'warning';
       return 'info';
     },
     minuteText(value) {
       return value === null || value === undefined ? '' : String(value);
     },
-    calculationStatusText(status) {
-      return CALCULATION_STATUS_LABEL[status] || status || '';
-    },
     viewRow(row) {
       this.detailRow = row;
       this.detailVisible = true;
@@ -331,35 +404,53 @@ export default {
       this.$message.info('导入考勤明细的后端接口暂未提供,请后端补充导入接口');
     },
     /* 后端暂无导出接口:先导出当前页已加载数据(xlsx,SheetJS 本地生成,与员工档案导入模板同款);
-       全量导出待后端补接口。注意 SheetJS 社区版只支持写 .xlsx,写不了老格式 .xls(BIFF8)。 */
+       全量导出待后端补接口。注意 SheetJS 社区版只支持写 .xlsx,写不了老格式 .xls(BIFF8)。
+       表头两级(2026-11-02 起与列表一致):上班/下班 横跨三列合并,其余列纵向合并两行。 */
     onExport() {
       if (!this.currentRows.length) {
         this.$message.warning('暂无考勤明细可导出');
         return;
       }
+      /* 列顺序与页面一致:序号/日期/姓名/岗位/所属机构/所属部门/出勤情况;
+         上班/下班 各四列(打卡状态/打卡类型/打卡时间/打卡地点,地点=punchRecords.address,2026-11-03 起随页面) */
+      const singleCols = ['序号', '日期', '姓名', '岗位', '所属机构', '所属部门', '出勤情况'];
       const header = [
-        '序号', '姓名', '所属机构', '所属部门', '日期', '出勤情况', '打卡类型',
-        '打卡地址', '打卡情况', '上班打卡时间', '下班打卡时间', '工作地址',
+        [...singleCols, '上班', '', '', '', '下班', '', '', '', '工作地址'],
+        ['', '', '', '', '', '', '', '打卡状态', '打卡类型', '打卡时间', '打卡地点', '打卡状态', '打卡类型', '打卡时间', '打卡地点', ''],
       ];
+      const joinText = (list) => list.filter(Boolean).join(' ');
       const body = this.currentRows.map((row, index) => [
         index + 1,
-        row.name,
-        row.organization,
-        row.department,
         row.attendanceDate,
-        row.statusText,
-        row.punchTypes,
-        row.punchAddress,
-        row.punchStatuses.map((item) => item.statusText).join(' '),
-        row.actualIn,
-        row.actualOut,
+        row.name,
+        row.postName,
+        row.groupName,
+        row.deptName,
+        row.statusNameList.join('、'),
+        joinText(row.inPunches.map((item) => item.statusText)),
+        joinText(row.inPunches.map((item) => item.sourceType)),
+        joinText(row.inPunches.map((item) => item.punchTime)),
+        joinText(row.inPunches.map((item) => item.address)),
+        joinText(row.outPunches.map((item) => item.statusText)),
+        joinText(row.outPunches.map((item) => item.sourceType)),
+        joinText(row.outPunches.map((item) => item.punchTime)),
+        joinText(row.outPunches.map((item) => item.address)),
         row.workAddress,
       ]);
-      const sheet = XLSX.utils.aoa_to_sheet([header, ...body], { cellDates: true });
-      /* 中文按 2 字宽估列宽,表头不被截断;日期/时间列给宽一点 */
-      sheet['!cols'] = header.map((name, index) => ({
-        wch: index === 4 || index >= 9 ? Math.max(20, name.length * 2) : Math.max(10, name.length * 2),
-      }));
+      const sheet = XLSX.utils.aoa_to_sheet([...header, ...body], { cellDates: true });
+      /* 合并:序号~出勤情况(0~6 列)与 工作地址(15 列)纵向合并两行;
+         上班 7~10 列、下班 11~14 列横向合并第一行 */
+      const merges = [];
+      [0, 1, 2, 3, 4, 5, 6, 15].forEach((c) => merges.push({ s: { r: 0, c }, e: { r: 1, c } }));
+      merges.push({ s: { r: 0, c: 7 }, e: { r: 0, c: 10 } });
+      merges.push({ s: { r: 0, c: 11 }, e: { r: 0, c: 14 } });
+      sheet['!merges'] = merges;
+      /* 中文按 2 字宽估列宽,表头不被截断;日期/时间/地址列给宽一点 */
+      const widthNames = [...singleCols, '打卡状态', '打卡类型', '打卡时间', '打卡地点', '打卡状态', '打卡类型', '打卡时间', '打卡地点', '工作地址'];
+      sheet['!cols'] = widthNames.map((name, index) => {
+        const wide = index === 1 || index === 10 || index === 14 || index >= 15;
+        return { wch: Math.max(wide ? 20 : 12, name.length * 2) };
+      });
       const book = XLSX.utils.book_new();
       XLSX.utils.book_append_sheet(book, sheet, '考勤明细');
       XLSX.writeFile(book, `考勤明细-${new Date().toISOString().slice(0, 10)}.xlsx`);
@@ -378,6 +469,15 @@ export default {
   margin-right: 6px;
   margin-bottom: 2px;
 }
+/* 上下班分组「打卡时间」列:多条打卡记录逐行展示 */
+.punch-time-line {
+  line-height: 20px;
+}
+/* 出勤情况列:tag 一行一个、强制左对齐(防全局/表头样式带偏) */
+.status-tag-line {
+  text-align: left;
+  line-height: 26px;
+}
 .punch-records {
   margin-top: 16px;
   h4 {

+ 1 - 49
src/views/hrManagement/employeeArchive/index.vue

@@ -17,7 +17,7 @@
             <el-input v-model="treeKeyword" clearable prefix-icon="el-icon-search" placeholder="搜索公司或部门"
               class="tree-search" />
             <div class="all-organization" :class="{ active: !selectedOrganizationId }" @click="selectAllOrganizations">
-              <span><i class="el-icon-menu"></i>全部员工</span><em>{{ metricsTotal }}</em>
+              <span><i class="el-icon-menu"></i>全部员工(在职)</span><em>{{ metricsTotal }}</em>
             </div>
             <!-- 树滚动区:绝对定位撑满剩余空间,树内容再长也不会把面板撑高(面板高度跟右侧表格走) -->
             <div class="tree-scroll">
@@ -197,22 +197,11 @@ import {
 import {
   buildOrganizationTree
 } from '@/utils/organization-tree';
-/* 导入/导出列与《员工档案导入模板.xlsx》完全一致(含 2 处重复表头与样例行),导出文件可直接回导 */
 import { writeEmployeeTemplateXlsx, downloadEmployeeTemplate } from '@/utils/employee-template';
-/* 员工档案可见机构范围:只取登录缓存所属机构(sessionStorage.loginChangeGroupVOList) */
 import { getOrgOptions, getCurrentOrgId } from '@/utils/current-org';
 import { getCurrentUser } from '@/utils/token-util';
-/* 「状态」筛选项:与新增/编辑员工档案共用 hr_user_status 字典(value = 字典值 = 后端 status) */
 import { loadEmployeeStatusOptions } from '@/utils/employee-status-dict';
 
-/* 列表「状态」列的文案已改为**直接用接口返回的 statusName**(后端新增字段),
-   本地不再维护 active/probation/inactive/disabled 四态文案表(原 STATUS_OPTIONS 已删);
-   tag 颜色按 statusName 文案关键词匹配,见 statusNameTagType()。
-   筛选的下拉与参数仍用 hr_user_status 字典的数字码(见 loadFilterStatusOptions)。 */
-
-/* 员工档案导出/模板列已抽到 utils/employee-template.js —— 表头与《员工档案导入模板.xlsx》逐列对齐,
-   本文件不再维护 CSV 列清单,避免两处口径漂移 */
-
 export default {
   name: 'EmployeeArchive',
   components: { EmployeeArchiveForm, EmployeeArchiveDetail },
@@ -221,20 +210,16 @@ export default {
       loading: false,
       organizationTree: [],
       orgById: {},
-      /* 「状态」筛选的下拉选项:hr_user_status 字典(与新增/编辑员工档案同一份) */
       filterStatusOptions: [],
       treeProps: { children: 'children', label: 'name' },
       treeKeyword: '',
       treeCollapsed: false,
-      /* 表格高度自适应:初始给个兜底值,mounted 后按视口剩余空间动态计算(见 computeTableHeight) */
       tableHeight: 520,
       selectedOrganizationId: '',
       /* 可见机构范围:登录缓存所属机构列表(员工档案只能看自己所属机构的人) */
       scopedOrgs: [],
       scopedOrgIds: [],
       defaultOrganizationId: '',
-      /* 查询条件:工号 / 姓名 / 入职区间。员工状态 2026-10-28 从筛选下拉改为表头上方的 tab,
-         status 字段仍保留在 filters / appliedFilters 里,值由 statusTabs 选中项写入(默认「在职」) */
       filters: { jobNumber: '', name: '', status: '', hireRange: [] },
       appliedFilters: { jobNumber: '', name: '', status: '', hireRange: [] },
       cacheKeyUrl: 'hr-pc-employee-archive-v5',
@@ -244,10 +229,8 @@ export default {
       detailVisible: false,
       selectedEmployee: null,
       tableTotal: 0,
-      /* 工号池:只在打开「新增/编辑员工」弹窗时按需拉取,页面加载不发这个全量请求 */
       existingNumbers: [],
       employeeNumbersLoaded: false,
-      /* 导出用字典(工厂 / 班组):人员行只有 factoryId / teamId,名称要查字典;导出时按需加载一次并缓存 */
       factoryById: {},
       factoryDictLoaded: false,
       teamById: {},
@@ -269,11 +252,6 @@ export default {
     loginOrgParams() {
       return this.loginOrgId ? { groupId: this.loginOrgId } : {};
     },
-    /**
-     * 「全部员工」人数:组织树顶层节点(公司)下发的 userCount 之和;
-     * 顶层节点缺 userCount 时返回 null(模板显示「—」),前端不做人数统计。
-     * 2026-10-26 组织树放开为全公司后,原「登录机构节点 userCount」口径随之作废。
-     */
     metricsTotal() {
       const roots = this.organizationTree || [];
       if (!roots.length) return null;
@@ -284,14 +262,6 @@ export default {
       }
       return total;
     },
-    /**
-     * 列结构对齐另一套系统的员工列表(2026-10-21 用户拍板):
-     * 姓名 / 所属机构 / 主所属部门 / 隶属部门 / 工号 / 手机号 / 岗位 / 所属工厂 / 用户账号 /
-     * 性别 / 状态 / 是否启用 / 创建时间 / 操作。
-     * 2026-10-28 用户指定字段口径:所属机构 = groupName、主所属部门 = deptName(新增列)、
-     * 原「部门」改名「隶属部门」= deptNames。
-     * 列结构变了,cacheKeyUrl 要跟着 bump(v5),否则旧的列显隐缓存会串。
-     */
     columns() {
       return [
         { type: 'selection', width: 30, fixed: 'left', align: 'center', headerAlign: 'center' },
@@ -316,12 +286,6 @@ export default {
       if (!this.selectedOrganizationId) return '全部员工';
       return this.orgById[String(this.selectedOrganizationId)]?.name || '全部员工';
     },
-    /**
-     * 员工状态 tab:一个 hr_user_status 字典项对应一个 tab,value 直接用字典里的数字状态码
-     * (就是接口 status 参数,2026-10-30 用户拍板:不再按文案猜映射、不再有内部英文 key)。
-     * 顺序按 在职(在岗) → 试用期 → 已离职 → 待岗 → 退休 排;字典里多出来的状态排在后面照常渲染。
-     * 最后一项「全部」是前端固定项(value 空串 → 不传 status,查全量)。
-     */
     statusTabs() {
       const preferred = ['在职', '在岗', '试用', '离职', '待岗', '退休'];
       const orderOf = (label) => {
@@ -333,8 +297,6 @@ export default {
         (a, b) => orderOf(a.label) - orderOf(b.label)
       );
       const tabs = ordered.map((item) => ({ label: item.label, value: String(item.value) }));
-      /* 末尾固定追加「全部」(2026-10-28 用户要求):value 为空串 → 不带 status 参数,
-         查全量员工(含离职,见 buildBaseParams 的 isQueryLZ) */
       tabs.push({ label: '全部', value: '' });
       return tabs;
     },
@@ -365,18 +327,11 @@ export default {
   },
   async created() {
     this.syncOrgScope();
-    /* ★ 非响应式的运行时字段必须在这里(第一个 await 之前)初始化:
-       created 是 async 时 Vue 不会等 await 就继续挂载,mounted 会先跑,
-       而 mounted 里已经在用 tableHeightTimers.push —— 放在 await 后面就会
-       报「Cannot read properties of undefined (reading 'push')」。
-       高度重算的延迟计时器与容器尺寸观察器(不走 data,避免无谓响应式) */
     this.tableHeightTimers = [];
     this.tableResizeObserver = null;
     this.tableResizeFrame = 0;
     this.handleWindowResize = () => this.computeTableHeight();
     window.addEventListener('resize', this.handleWindowResize);
-    /* 状态 tab 依赖 hr_user_status 字典才能拿到「在职」的码值,所以先取字典再拉首屏;
-       字典失败不阻塞列表(loadFilterStatusOptions 内部已提示,statusTabs 为空时不传 status) */
     this.loading = true;
     await this.loadFilterStatusOptions();
     this.filters.status = this.defaultStatusValue;
@@ -384,9 +339,6 @@ export default {
     this.bootstrap();
   },
   mounted() {
-    /* 首帧 + 多次延迟重算:首次进入时外层宿主(嵌入平台 iframe、菜单/tab 容器)
-       尺寸尚未稳定,一次测量会拿到偏小的可用高度(用户反馈:首进偏矮、刷新才铺满);
-       分几帧补算,取到稳定值 */
     this.$nextTick(() => this.computeTableHeight());
     [300, 800, 1500].forEach((delay) => {
       this.tableHeightTimers.push(setTimeout(() => this.computeTableHeight(), delay));

+ 7 - 5
src/views/salary/myPayslip/index.vue

@@ -198,13 +198,15 @@ export default {
       confirmRequestIdAt: 0,
       appealRequestId: '',
       appealRequestIdAt: 0,
+      /* ★ 内容列用 minWidth(不用固定 width):全部固定宽时表格右侧会留一大块空白;
+         minWidth 让各列均分余下宽度自动撑满整行 */
       columns: [
         { type: 'index', label: '序号', width: 60, align: 'center', headerAlign: 'center' },
-        { columnKey: 'payrollMonth', prop: 'payrollMonth', label: '工资月份', width: 130, align: 'center', slot: 'payrollMonth' },
-        { columnKey: 'payslipStatus', label: '状态', width: 110, align: 'center', slot: 'payslipStatus' },
-        { columnKey: 'publishTime', label: '发布时间', width: 160, align: 'center', slot: 'publishTime' },
-        { columnKey: 'firstViewTime', label: '首次查看时间', width: 160, align: 'center', slot: 'firstViewTime' },
-        { columnKey: 'confirmTime', label: '确认时间', width: 160, align: 'center', slot: 'confirmTime' },
+        { columnKey: 'payrollMonth', prop: 'payrollMonth', label: '工资月份', minWidth: 120, align: 'center', slot: 'payrollMonth' },
+        { columnKey: 'payslipStatus', label: '状态', width: 100, align: 'center', slot: 'payslipStatus' },
+        { columnKey: 'publishTime', label: '发布时间', minWidth: 150, align: 'center', slot: 'publishTime' },
+        { columnKey: 'firstViewTime', label: '首次查看时间', minWidth: 150, align: 'center', slot: 'firstViewTime' },
+        { columnKey: 'confirmTime', label: '确认时间', minWidth: 150, align: 'center', slot: 'confirmTime' },
         { columnKey: 'action', label: '操作', width: 110, align: 'left', fixed: 'right', slot: 'action' }
       ]
     };