| | |
| | | <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"> |
| | | <el-calendar v-model="calendarValue"> |
| | | <template #date-cell="{ data }"> |
| | | <div |
| | | class="calendar-date" |
| | | :class="getDateStatusClass(data.day)" |
| | | @click="handleDateClick(data)" |
| | | > |
| | | <div class="calendar-date"> |
| | | <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" |
| | | class="event-item" |
| | | :class="event.type" |
| | | > |
| | | <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> |
| | | <span class="event-dot"></span> |
| | | <span class="event-text">{{ event.text }}</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> |
| | | |
| | |
| | | export default { |
| | | name: 'AttendanceCalendar', |
| | | props: { |
| | | attendanceData: { |
| | | type: Array, |
| | | default: () => [] |
| | | }, |
| | | businessTripData: { |
| | | type: Array, |
| | | default: () => [] |
| | | } |
| | | attendanceData: Array, |
| | | businessTripData: Array |
| | | }, |
| | | 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 |
| | | calendarValue: new Date() |
| | | } |
| | | }, |
| | | 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) |
| | | |
| | | // 检查出勤记录 |
| | | const attendance = this.attendanceData.find(item => item.date === date) |
| | | if (attendance) { |
| | | events.push({ |
| | | id: `attendance-${date}`, |
| | | type: 'attendance', |
| | | text: `${attendance.checkIn || '未打卡'} - ${attendance.checkOut || '未打卡'}` |
| | | 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}` |
| | | type: 'business-trip', |
| | | text: `出差: ${businessTrip.endCity}` |
| | | }) |
| | | } |
| | | |
| | | 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 |
| | | } |
| | | } |
| | | } |
| | |
| | | <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%); |
| | | height: 100%; |
| | | display: flex; |
| | | flex-direction: column; |
| | | } |
| | | |
| | | .date-number { |
| | | font-weight: bold; |
| | | font-size: 14px; |
| | | margin-bottom: 2px; |
| | | position: relative; |
| | | z-index: 1; |
| | | margin-bottom: 4px; |
| | | } |
| | | |
| | | .date-events { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 2px; |
| | | position: relative; |
| | | z-index: 1; |
| | | flex: 1; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .status-mark, .trip-mark, .absent-mark { |
| | | .event-item { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 4px; |
| | | margin-bottom: 2px; |
| | | font-size: 12px; |
| | | } |
| | | |
| | | .status-dot { |
| | | width: 8px; |
| | | height: 8px; |
| | | .event-dot { |
| | | width: 6px; |
| | | height: 6px; |
| | | 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; |
| | | .event-item.attendance .event-dot { |
| | | background-color: #67c23a; |
| | | } |
| | | |
| | | .detail-section { |
| | | margin-bottom: 20px; |
| | | .event-item.business-trip .event-dot { |
| | | background-color: #409eff; |
| | | } |
| | | |
| | | .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; |
| | | .event-text { |
| | | white-space: nowrap; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | } |
| | | </style> |