feat(attendanceCheck): 新增员工考勤详情弹窗功能

1.  为员工列表列添加详情按钮,点击可查看个人考勤详情
2.  新增个人考勤汇总卡片,展示考勤统计数据
3.  支持表格和日历两种明细视图,根据天数自动切换
4.  添加考勤日历视图样式与状态标识
5.  补充相关计算属性与工具方法支撑新功能
This commit is contained in:
2026-05-29 17:13:57 +08:00
parent aad568f320
commit 3a0f729669

View File

@@ -24,7 +24,12 @@
<div class="schedule-table-wrapper">
<el-table v-loading="loading" :data="attendanceData" border stripe>
<el-table-column prop="employeeName" label="员工" width="120" fixed="left" />
<el-table-column label="员工" width="150" fixed="left">
<template slot-scope="scope">
<span>{{ scope.row.employeeName }}</span>
<el-button type="text" size="mini" icon="el-icon-view" @click="handlePersonDetail(scope.row)"></el-button>
</template>
</el-table-column>
<el-table-column v-for="date in dateList" :key="date" :label="formatDateLabel(date)" width="150" align="center">
<template slot-scope="scope">
<div class="attendance-cell" :class="getAttendanceStatusClass(scope.row[date])"
@@ -385,6 +390,81 @@
</div>
</el-dialog>
<!-- 个人考勤总览 -->
<el-dialog :title="personDialogTitle" :visible.sync="personDialogVisible" width="900px" append-to-body>
<div v-if="currentPersonData" v-loading="personLoading">
<el-descriptions title="考勤汇总" :column="5" border style="margin-bottom: 20px;">
<el-descriptions-item label="姓名">{{ currentPersonData.employeeName }}</el-descriptions-item>
<el-descriptions-item label="正常">{{ personSummary.normal }}</el-descriptions-item>
<el-descriptions-item label="迟到/早退">{{ personSummary.abnormal }}</el-descriptions-item>
<el-descriptions-item label="半天旷工">{{ personSummary.absentHalf }}</el-descriptions-item>
<el-descriptions-item label="全天旷工">{{ personSummary.absentFull }}</el-descriptions-item>
<el-descriptions-item label="无记录">{{ personSummary.noRecord }}</el-descriptions-item>
<el-descriptions-item label="迟到合计">{{ personSummary.totalLateMinutes }}分钟</el-descriptions-item>
<el-descriptions-item label="早退合计">{{ personSummary.totalEarlyMinutes }}分钟</el-descriptions-item>
<el-descriptions-item label="总扣款">¥{{ personSummary.totalDeduct }}</el-descriptions-item>
<el-descriptions-item label="统计天数">{{ dateList.length }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">考勤明细{{ dateList.length }}</el-divider>
<!-- 表格视图小数据量(14) 特大数据量(>62) -->
<el-table v-if="personViewMode === 'table'" :data="personDetailTableData" border stripe size="small" max-height="400">
<el-table-column prop="workDate" label="日期" width="120" fixed="left" />
<el-table-column prop="shiftName" label="班次" width="120" />
<el-table-column prop="overallStatus" label="总体状态" width="100">
<template slot-scope="scope">
<span :class="getStatusClass(scope.row.overallStatus)">{{ getStatusText(scope.row.overallStatus) }}</span>
</template>
</el-table-column>
<el-table-column prop="p1FirstCheck" label="上班打卡" width="160">
<template slot-scope="scope">{{ formatTime(scope.row.p1FirstCheck) }}</template>
</el-table-column>
<el-table-column prop="p1LastCheck" label="下班打卡" width="160">
<template slot-scope="scope">{{ formatTime(scope.row.p1LastCheck) }}</template>
</el-table-column>
<el-table-column prop="p1LateMinutes" label="迟到(分)" width="80" align="center">
<template slot-scope="scope">
<span v-if="scope.row.p1LateMinutes > 0" class="late-info">{{ scope.row.p1LateMinutes }}</span>
<span v-else>0</span>
</template>
</el-table-column>
<el-table-column prop="p1EarlyMinutes" label="早退(分)" width="80" align="center">
<template slot-scope="scope">
<span v-if="scope.row.p1EarlyMinutes > 0" class="early-info">{{ scope.row.p1EarlyMinutes }}</span>
<span v-else>0</span>
</template>
</el-table-column>
<el-table-column prop="p1Deduct" label="扣款" width="80" align="center">
<template slot-scope="scope">¥{{ scope.row.p1Deduct || '0.00' }}</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
</el-table>
<!-- 日历视图中等数据量(15~62) -->
<div v-if="personViewMode === 'calendar'" class="calendar-view">
<div v-for="month in personCalendarMonths" :key="month.key" class="calendar-month">
<h4 class="calendar-month-title">{{ month.label }}</h4>
<div class="calendar-grid">
<div class="calendar-header" v-for="w in calendarWeekDays" :key="w">{{ w }}</div>
<div v-for="(cell, idx) in month.cells" :key="idx"
class="calendar-cell" :class="getCalendarCellClass(cell)">
<span class="calendar-day">{{ cell.day }}</span>
<span v-if="cell.date && cell.status" class="calendar-status">{{ getStatusText(cell.status) }}</span>
</div>
</div>
</div>
<div class="calendar-legend">
<span class="legend-item"><span class="legend-dot normal"></span>正常</span>
<span class="legend-item"><span class="legend-dot late"></span>迟到/早退</span>
<span class="legend-item"><span class="legend-dot absent-half"></span>半天旷工</span>
<span class="legend-item"><span class="legend-dot absent-full"></span>全天旷工</span>
<span class="legend-item"><span class="legend-dot no-record"></span>无记录</span>
</div>
</div>
</div>
</el-dialog>
<el-dialog title="考勤比对" :visible.sync="checkOpen" width="700px" append-to-body>
<el-form ref="checkForm" :model="checkForm" :rules="checkRules" label-width="80px">
<el-form-item label="开始日期" prop="startDate">
@@ -515,7 +595,12 @@ export default {
selectedUserIds: [],
detailRecordsLoading: false,
detailRecordsList: []
detailRecordsList: [],
personDialogVisible: false,
personDialogTitle: '',
currentPersonData: null,
personLoading: false,
};
},
computed: {
@@ -533,7 +618,88 @@ export default {
key: String(emp.infoId),
label: emp.name + '' + emp.dept + ''
}))
}
},
personSummary() {
if (!this.currentPersonData) return { normal: 0, abnormal: 0, absentHalf: 0, absentFull: 0, noRecord: 0, totalLateMinutes: 0, totalEarlyMinutes: 0, totalDeduct: '0.00' };
let normal = 0, abnormal = 0, absentHalf = 0, absentFull = 0, noRecord = 0;
let totalLate = 0, totalEarly = 0, totalDeduct = 0;
this.dateList.forEach(date => {
const record = this.currentPersonData[date];
if (!record) {
noRecord++;
return;
}
const status = record.overallStatus;
if (status === 'normal') normal++;
else if (status === 'absent_half') absentHalf++;
else if (status === 'absent_full') absentFull++;
else if (status === 'abnormal' || status === 'late_warn' || status === 'late_one' || status === 'late_two' || status === 'early_warn' || status === 'early_one' || status === 'early_two' || status === 'missed') abnormal++;
totalLate += Number(record.p1LateMinutes || 0) + Number(record.p2LateMinutes || 0);
totalEarly += Number(record.p1EarlyMinutes || 0) + Number(record.p2EarlyMinutes || 0);
totalDeduct += Number(record.p1Deduct || 0) + Number(record.p2Deduct || 0) + Number(record.totalDeduct || 0);
});
return { normal, abnormal, absentHalf, absentFull, noRecord, totalLateMinutes: totalLate, totalEarlyMinutes: totalEarly, totalDeduct: totalDeduct.toFixed(2) };
},
personViewMode() {
const days = this.dateList.length;
if (days <= 14) return 'table';
if (days <= 62) return 'calendar';
return 'table';
},
personDetailTableData() {
if (!this.currentPersonData) return [];
return this.dateList.map(date => {
const record = this.currentPersonData[date];
if (!record) {
return { workDate: date, shiftName: '-', overallStatus: null, p1FirstCheck: null, p1LastCheck: null, p2FirstCheck: null, p2LastCheck: null, p1LateMinutes: 0, p1EarlyMinutes: 0, p1Deduct: 0, p2LateMinutes: 0, p2EarlyMinutes: 0, p2Deduct: 0, totalDeduct: 0, remark: '' };
}
return {
workDate: date,
...record,
p1LateMinutes: record.p1LateMinutes || 0,
p1EarlyMinutes: record.p1EarlyMinutes || 0,
};
});
},
calendarWeekDays() {
return ['日', '一', '二', '三', '四', '五', '六'];
},
personCalendarMonths() {
if (!this.dateList || this.dateList.length === 0) return [];
const months = {};
this.dateList.forEach(date => {
const d = new Date(date);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
if (!months[key]) {
months[key] = { key, label: `${d.getFullYear()}${d.getMonth() + 1}`, dates: [] };
}
months[key].dates.push(date);
});
return Object.values(months).map(month => {
const firstDate = new Date(month.dates[0]);
const year = firstDate.getFullYear();
const monthNum = firstDate.getMonth();
const firstDay = new Date(year, monthNum, 1);
const lastDay = new Date(year, monthNum + 1, 0);
const startDayOfWeek = firstDay.getDay();
const totalDays = lastDay.getDate();
const cells = [];
for (let i = 0; i < startDayOfWeek; i++) {
cells.push({ day: '', date: null });
}
for (let day = 1; day <= totalDays; day++) {
const dateStr = `${year}-${String(monthNum + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const record = this.currentPersonData ? this.currentPersonData[dateStr] : null;
cells.push({
day,
date: dateStr,
status: record ? record.overallStatus : null,
inRange: month.dates.includes(dateStr),
});
}
return { ...month, cells };
});
},
},
created() {
this.getAllEmployees().then(() => {
@@ -1049,6 +1215,30 @@ export default {
}).finally(() => {
this.loading = false
})
},
handlePersonDetail(row) {
this.currentPersonData = row
this.personDialogTitle = row.employeeName + ' - 考勤总览'
this.personDialogVisible = true
},
getCalendarCellClass(cell) {
if (!cell.date) return 'cal-empty'
if (!cell.status) return 'cal-no-record'
switch (cell.status) {
case 'normal': return 'cal-normal'
case 'absent_half': return 'cal-absent-half'
case 'absent_full': return 'cal-absent-full'
case 'abnormal':
case 'late_warn':
case 'late_one':
case 'late_two':
case 'early_warn':
case 'early_one':
case 'early_two':
case 'missed':
return 'cal-abnormal'
default: return 'cal-default'
}
}
}
};
@@ -1229,4 +1419,128 @@ export default {
margin-right: 10px;
white-space: nowrap;
}
.early-info {
color: #f56c6c;
}
.calendar-view {
margin-top: 10px;
}
.calendar-month {
margin-bottom: 24px;
}
.calendar-month-title {
margin: 0 0 8px 0;
font-size: 14px;
color: #303133;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
border: 1px solid #ebeef5;
border-radius: 4px;
overflow: hidden;
}
.calendar-header {
text-align: center;
padding: 6px 4px;
background: #f5f7fa;
font-size: 12px;
color: #909399;
font-weight: bold;
}
.calendar-cell {
min-height: 50px;
padding: 4px;
background: #fff;
text-align: center;
font-size: 12px;
border-right: 1px solid #ebeef5;
border-bottom: 1px solid #ebeef5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.calendar-day {
font-weight: bold;
color: #303133;
font-size: 13px;
}
.calendar-status {
font-size: 10px;
margin-top: 2px;
}
.cal-empty {
background: #f9f9f9;
color: #c0c4cc;
}
.cal-no-record {
background: #fdf6ec;
color: #e6a23c;
}
.cal-normal {
background: #f0f9eb;
color: #67c23a;
}
.cal-abnormal {
background: #fef0f0;
color: #f56c6c;
}
.cal-absent-half {
background: #f4f4f5;
color: #909399;
}
.cal-absent-full {
background: #e9e9eb;
color: #606266;
}
.cal-default {
background: #f5f7fa;
color: #c0c4cc;
}
.calendar-legend {
display: flex;
gap: 16px;
margin-top: 12px;
flex-wrap: wrap;
font-size: 12px;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
color: #606266;
}
.legend-dot {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 2px;
}
.legend-dot.normal { background: #f0f9eb; border: 1px solid #67c23a; }
.legend-dot.late { background: #fef0f0; border: 1px solid #f56c6c; }
.legend-dot.absent-half { background: #f4f4f5; border: 1px solid #909399; }
.legend-dot.absent-full { background: #e9e9eb; border: 1px solid #606266; }
.legend-dot.no-record { background: #fdf6ec; border: 1px solid #e6a23c; }
</style>