<template>
|
<div class="attendance-calendar">
|
<!-- 日历头部统计信息 -->
|
<div class="calendar-stats">
|
<div class="stat-item">
|
<span class="stat-dot present"></span>
|
<span>正常出勤: {{ stats.presentDays }}天</span>
|
</div>
|
<div class="stat-item">
|
<span class="stat-dot absent"></span>
|
<span>缺勤/异常: {{ stats.absentDays }}天</span>
|
</div>
|
<div class="stat-item">
|
<span class="stat-dot trip"></span>
|
<span>出差: {{ stats.tripDays }}天</span>
|
</div>
|
<div class="stat-item">
|
<span class="stat-dot late"></span>
|
<span>迟到/早退: {{ stats.lateDays }}天</span>
|
</div>
|
</div>
|
|
<!-- Element UI 日历组件 -->
|
<el-calendar v-model="calendarValue" :first-day-of-week="1">
|
<template #date-cell="{ data }">
|
<div
|
class="calendar-date"
|
:class="getDateStatusClass(data.day)"
|
@click="handleDateClick(data)"
|
>
|
<div class="date-number">{{ data.day.split('-')[2] }}</div>
|
|
<!-- 状态背景色块 -->
|
<div class="status-background" :class="getBackgroundStatus(data.day)"></div>
|
|
<div class="date-events">
|
<!-- 出勤状态标记 -->
|
<div v-if="getAttendanceStatus(data.day) !== 'absent'" class="status-mark">
|
<el-tooltip
|
:content="getAttendanceTooltip(data.day)"
|
placement="top"
|
>
|
<span class="status-dot" :class="getAttendanceStatus(data.day)"></span>
|
</el-tooltip>
|
</div>
|
|
<!-- 出差标记 -->
|
<div v-if="hasBusinessTrip(data.day)" class="trip-mark">
|
<el-tooltip content="出差" placement="top">
|
<i class="el-icon-location-outline"></i>
|
</el-tooltip>
|
</div>
|
|
<!-- 缺勤标记 -->
|
<div v-if="getAttendanceStatus(data.day) === 'absent'" class="absent-mark">
|
<el-tooltip content="缺勤" placement="top">
|
<i class="el-icon-close"></i>
|
</el-tooltip>
|
</div>
|
</div>
|
|
<!-- 简略信息显示 -->
|
<div class="brief-info">
|
<div v-if="getAttendanceStatus(data.day) === 'present'" class="info-item present-info">
|
√
|
</div>
|
<div v-else-if="getAttendanceStatus(data.day) === 'late'" class="info-item late-info">
|
!
|
</div>
|
<div v-else-if="getAttendanceStatus(data.day) === 'absent'" class="info-item absent-info">
|
×
|
</div>
|
<div v-if="hasBusinessTrip(data.day)" class="info-item trip-info">
|
✈
|
</div>
|
</div>
|
|
<!-- 日期详细信息(悬浮显示) -->
|
<div class="date-details">
|
<div
|
v-for="event in getDateEvents(data.day)"
|
:key="event.id"
|
class="detail-item"
|
>
|
<span class="detail-type">{{ event.type === 'attendance' ? '出勤' : '出差' }}</span>
|
<span class="detail-info">{{ event.text }}</span>
|
</div>
|
<div v-if="getDateEvents(data.day).length === 0" class="detail-item">
|
<span class="detail-type">无记录</span>
|
</div>
|
</div>
|
</div>
|
</template>
|
</el-calendar>
|
|
<!-- 日期详情对话框 -->
|
<el-dialog
|
:title="`${selectedDate} 考勤详情`"
|
v-model="detailDialogVisible"
|
width="500px"
|
>
|
<div v-if="selectedDateInfo">
|
<div class="detail-section">
|
<h4>出勤信息</h4>
|
<div v-if="selectedDateInfo.attendance">
|
<p>上班时间: {{ selectedDateInfo.attendance.checkIn || '未打卡' }}</p>
|
<p>下班时间: {{ selectedDateInfo.attendance.checkOut || '未打卡' }}</p>
|
<p>状态:
|
<el-tag :type="getStatusTagType(selectedDateInfo.attendance.status)">
|
{{ getStatusText(selectedDateInfo.attendance.status) }}
|
</el-tag>
|
</p>
|
</div>
|
<div v-else>
|
<p class="no-data">无出勤记录</p>
|
</div>
|
</div>
|
|
<div class="detail-section" v-if="selectedDateInfo.businessTrip">
|
<h4>出差信息</h4>
|
<p>目的地: {{ selectedDateInfo.businessTrip.destination }}</p>
|
<p>事由: {{ selectedDateInfo.businessTrip.reason }}</p>
|
<p>里程: {{ selectedDateInfo.businessTrip.distance }}公里</p>
|
</div>
|
</div>
|
<template #footer>
|
<el-button @click="detailDialogVisible = false">关闭</el-button>
|
</template>
|
</el-dialog>
|
</div>
|
</template>
|
|
<script>
|
export default {
|
name: 'AttendanceCalendar',
|
props: {
|
attendanceData: {
|
type: Array,
|
default: () => []
|
},
|
businessTripData: {
|
type: Array,
|
default: () => []
|
}
|
},
|
data() {
|
return {
|
calendarValue: new Date(),
|
detailDialogVisible: false,
|
selectedDate: '',
|
selectedDateInfo: null,
|
stats: {
|
presentDays: 0,
|
absentDays: 0,
|
tripDays: 0,
|
lateDays: 0
|
}
|
}
|
},
|
mounted() {
|
this.calculateStats()
|
},
|
watch: {
|
attendanceData: {
|
handler() {
|
this.calculateStats()
|
},
|
deep: true
|
},
|
businessTripData: {
|
handler() {
|
this.calculateStats()
|
},
|
deep: true
|
}
|
},
|
methods: {
|
// 获取日期状态类名
|
getDateStatusClass(date) {
|
const classes = []
|
if (this.isToday(date)) {
|
classes.push('today')
|
}
|
if (this.isSelected(date)) {
|
classes.push('selected')
|
}
|
|
// 添加状态类名
|
const status = this.getBackgroundStatus(date)
|
if (status) {
|
classes.push(status)
|
}
|
|
return classes
|
},
|
|
// 获取背景状态(用于背景色)
|
getBackgroundStatus(date) {
|
const attendance = this.attendanceData.find(item => item.date === date)
|
const hasTrip = this.hasBusinessTrip(date)
|
|
if (hasTrip) {
|
return 'has-trip'
|
}
|
|
if (attendance) {
|
switch (attendance.status) {
|
case 'present': return 'has-attendance'
|
case 'late': return 'has-late'
|
case 'absent': return 'has-absent'
|
default: return ''
|
}
|
}
|
|
return ''
|
},
|
|
// 判断是否为今天
|
isToday(date) {
|
const today = new Date()
|
const compareDate = new Date(date)
|
return today.toDateString() === compareDate.toDateString()
|
},
|
|
// 判断是否被选中
|
isSelected(date) {
|
return this.selectedDate === date
|
},
|
|
// 获取考勤状态
|
getAttendanceStatus(date) {
|
const attendance = this.attendanceData.find(item => item.date === date)
|
if (!attendance) return 'absent'
|
|
switch (attendance.status) {
|
case 'present': return 'present'
|
case 'late': return 'late'
|
case 'absent': return 'absent'
|
default: return 'absent'
|
}
|
},
|
|
// 获取状态文本
|
getStatusText(status) {
|
const statusMap = {
|
present: '正常出勤',
|
late: '迟到/早退',
|
absent: '缺勤/异常'
|
}
|
return statusMap[status] || '未知状态'
|
},
|
|
// 获取考勤状态提示
|
getAttendanceTooltip(date) {
|
const statusMap = {
|
present: '正常出勤',
|
late: '迟到/早退',
|
absent: '缺勤/异常'
|
}
|
return statusMap[this.getAttendanceStatus(date)] || '无记录'
|
},
|
|
// 判断是否有出差
|
hasBusinessTrip(date) {
|
return this.businessTripData.some(item =>
|
date >= item.startDate && date <= item.endDate
|
)
|
},
|
|
// 获取日期事件
|
getDateEvents(date) {
|
const events = []
|
const attendance = this.attendanceData.find(item => item.date === date)
|
|
if (attendance) {
|
events.push({
|
id: `attendance-${date}`,
|
type: 'attendance',
|
text: `${attendance.checkIn || '未打卡'} - ${attendance.checkOut || '未打卡'}`
|
})
|
}
|
|
const businessTrip = this.businessTripData.find(item =>
|
date >= item.startDate && date <= item.endDate
|
)
|
if (businessTrip) {
|
events.push({
|
id: `business-trip-${date}`,
|
type: 'businessTrip',
|
text: `前往${businessTrip.destination}`
|
})
|
}
|
|
return events
|
},
|
|
// 处理日期点击事件
|
handleDateClick(data) {
|
this.selectedDate = data.day
|
this.selectedDateInfo = {
|
attendance: this.attendanceData.find(item => item.date === data.day),
|
businessTrip: this.businessTripData.find(item =>
|
data.day >= item.startDate && data.day <= item.endDate
|
)
|
}
|
this.detailDialogVisible = true
|
},
|
|
// 获取状态标签类型
|
getStatusTagType(status) {
|
const typeMap = {
|
present: 'success',
|
late: 'warning',
|
absent: 'danger'
|
}
|
return typeMap[status] || 'info'
|
},
|
|
// 计算统计信息
|
calculateStats() {
|
// 重置统计
|
this.stats = { presentDays: 0, absentDays: 0, tripDays: 0, lateDays: 0 }
|
|
this.attendanceData.forEach(item => {
|
switch (item.status) {
|
case 'present': this.stats.presentDays++; break
|
case 'late': this.stats.lateDays++; break
|
case 'absent': this.stats.absentDays++; break
|
}
|
})
|
|
// 计算出差天数
|
const tripDays = new Set()
|
this.businessTripData.forEach(item => {
|
const start = new Date(item.startDate)
|
const end = new Date(item.endDate)
|
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
tripDays.add(d.toISOString().split('T')[0])
|
}
|
})
|
this.stats.tripDays = tripDays.size
|
}
|
}
|
}
|
</script>
|
|
<style scoped>
|
.attendance-calendar {
|
padding: 20px;
|
max-width: 100%;
|
}
|
|
.calendar-stats {
|
display: flex;
|
justify-content: space-around;
|
margin-bottom: 20px;
|
padding: 15px;
|
background: #f5f7fa;
|
border-radius: 8px;
|
}
|
|
.stat-item {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
}
|
|
.stat-dot {
|
width: 12px;
|
height: 12px;
|
border-radius: 50%;
|
display: inline-block;
|
}
|
|
.stat-dot.present { background-color: #67c23a; }
|
.stat-dot.absent { background-color: #f56c6c; }
|
.stat-dot.trip { background-color: #409eff; }
|
.stat-dot.late { background-color: #e6a23c; }
|
|
.calendar-date {
|
height: 80px;
|
padding: 4px;
|
border: 1px solid #ebeef5;
|
border-radius: 4px;
|
cursor: pointer;
|
transition: all 0.3s;
|
position: relative;
|
overflow: hidden;
|
}
|
|
.calendar-date:hover {
|
background-color: #f0f9ff;
|
border-color: #409eff;
|
transform: translateY(-2px);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
}
|
|
.calendar-date.today {
|
border-color: #409eff;
|
background-color: #f0f9ff;
|
}
|
|
.calendar-date.selected {
|
background-color: #ecf5ff;
|
border-color: #409eff;
|
}
|
|
/* 状态背景色 */
|
.status-background {
|
position: absolute;
|
top: 0;
|
left: 0;
|
right: 0;
|
bottom: 0;
|
opacity: 0.1;
|
z-index: 0;
|
}
|
|
.calendar-date.has-attendance .status-background {
|
background-color: #67c23a;
|
}
|
|
.calendar-date.has-late .status-background {
|
background-color: #e6a23c;
|
}
|
|
.calendar-date.has-absent .status-background {
|
background-color: #f56c6c;
|
}
|
|
.calendar-date.has-trip .status-background {
|
background: linear-gradient(135deg, #409eff 0%, #67c23a 100%);
|
}
|
|
.date-number {
|
font-weight: bold;
|
font-size: 14px;
|
margin-bottom: 2px;
|
position: relative;
|
z-index: 1;
|
}
|
|
.date-events {
|
display: flex;
|
flex-direction: column;
|
gap: 2px;
|
position: relative;
|
z-index: 1;
|
}
|
|
.status-mark, .trip-mark, .absent-mark {
|
display: flex;
|
align-items: center;
|
gap: 4px;
|
}
|
|
.status-dot {
|
width: 8px;
|
height: 8px;
|
border-radius: 50%;
|
display: inline-block;
|
}
|
|
.status-dot.present { background-color: #67c23a; }
|
.status-dot.late { background-color: #e6a23c; }
|
.status-dot.absent { background-color: #f56c6c; }
|
|
.trip-mark i {
|
color: #409eff;
|
font-size: 12px;
|
}
|
|
.absent-mark i {
|
color: #f56c6c;
|
font-size: 12px;
|
}
|
|
/* 简略信息显示 */
|
.brief-info {
|
position: absolute;
|
bottom: 4px;
|
right: 4px;
|
display: flex;
|
gap: 2px;
|
z-index: 1;
|
}
|
|
.info-item {
|
width: 16px;
|
height: 16px;
|
border-radius: 50%;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
font-size: 10px;
|
font-weight: bold;
|
}
|
|
.present-info {
|
background-color: #67c23a;
|
color: white;
|
}
|
|
.late-info {
|
background-color: #e6a23c;
|
color: white;
|
}
|
|
.absent-info {
|
background-color: #f56c6c;
|
color: white;
|
}
|
|
.trip-info {
|
background-color: #409eff;
|
color: white;
|
}
|
|
.date-details {
|
position: absolute;
|
top: 100%;
|
left: 0;
|
right: 0;
|
background: white;
|
border: 1px solid #ddd;
|
border-radius: 4px;
|
padding: 8px;
|
z-index: 1000;
|
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
display: none;
|
}
|
|
.calendar-date:hover .date-details {
|
display: block;
|
}
|
|
.detail-item {
|
font-size: 12px;
|
margin-bottom: 4px;
|
display: flex;
|
align-items: center;
|
}
|
|
.detail-type {
|
font-weight: bold;
|
margin-right: 4px;
|
min-width: 40px;
|
}
|
|
.detail-info {
|
color: #606266;
|
}
|
|
.detail-section {
|
margin-bottom: 20px;
|
}
|
|
.detail-section h4 {
|
margin-bottom: 10px;
|
color: #303133;
|
border-left: 4px solid #409eff;
|
padding-left: 8px;
|
}
|
|
.no-data {
|
color: #909399;
|
font-style: italic;
|
}
|
|
:deep(.el-calendar__header) {
|
padding: 10px;
|
border-bottom: 1px solid #ebeef5;
|
}
|
|
:deep(.el-calendar-day) {
|
padding: 0 !important;
|
height: 80px;
|
}
|
|
:deep(.el-calendar-table:not(.is-range) td) {
|
border: 1px solid #f0f0f0;
|
}
|
|
:deep(.el-calendar-table .el-calendar-day) {
|
height: 80px !important;
|
padding: 0 !important;
|
}
|
</style>
|