You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

391 lines
10 KiB
Vue

<template>
<div class="page-container fade-in-up">
<div class="page-header">
<h2 class="page-title">历史记录查询</h2>
<p class="page-subtitle">查询课程考勤与行为识别历史记录</p>
</div>
<!-- 查询区 -->
<div class="filter-bar">
<el-input
v-model="query.courseName"
placeholder="输入课程名称"
:prefix-icon="Search"
clearable
size="default"
style="width: 200px"
/>
<el-date-picker v-model="query.date" type="date" placeholder="选择日期" size="default" value-format="YYYY-MM-DD" />
<el-button type="primary" :icon="Search" :loading="loading" @click="doSearch">查询</el-button>
<el-button :icon="RefreshRight" @click="doReset">重置</el-button>
</div>
<!-- 查询结果 -->
<div class="result-section">
<div class="section-header">
<h3>查询结果</h3>
<span class="result-count">共 {{ pagination.total }} 条记录</span>
</div>
<div v-loading="loading" class="image-masonry">
<div
v-for="record in records"
:key="record.id"
class="masonry-item"
@click="viewDetail(record)"
>
<div class="masonry-thumb">
<div class="water-glass">
<div
class="water-fill"
:style="{
height: record.attendanceRate + '%',
background: getWaterGradient(record.attendanceRate)
}"
>
<svg class="water-wave" viewBox="0 0 200 24" preserveAspectRatio="none">
<path d="M0,12 C40,4 60,20 100,12 C140,4 160,20 200,12 L200,24 L0,24 Z" :fill="getBorderColor(record.attendanceRate)" />
</svg>
<svg class="water-wave water-wave-back" viewBox="0 0 200 24" preserveAspectRatio="none">
<path d="M0,12 C40,18 60,6 100,12 C140,18 160,6 200,12 L200,24 L0,24 Z" :fill="getBorderColor(record.attendanceRate)" opacity="0.4" />
</svg>
</div>
<span class="water-value">{{ record.attendanceRate }}%</span>
</div>
<span class="water-label">出勤率</span>
</div>
<div class="masonry-info">
<span class="masonry-course" :title="record.courseName">{{ record.courseName }}</span>
<span class="masonry-time">
<el-icon :size="12"><Clock /></el-icon>
{{ record.attDate }}
</span>
<div class="masonry-stats">
<span>应到 {{ record.totalCount }}</span>
<el-divider direction="vertical" />
<span>实到 {{ record.actualCount }}</span>
</div>
<span class="masonry-absent">
缺勤 {{ record.absentCount }}
</span>
<div class="masonry-extra">
<span v-if="record.className" class="masonry-tag">{{ record.className }}</span>
<span v-if="record.classroomName" class="masonry-tag">{{ record.classroomName }}</span>
</div>
</div>
</div>
</div>
<div v-if="records.length" class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.current"
:total="pagination.total"
:page-size="pagination.size"
size="small"
background
layout="prev, pager, next"
@current-change="doSearch"
/>
</div>
<el-empty v-if="!loading && records.length === 0" description="暂无数据,请调整查询条件" />
</div>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="考勤详情" width="520px" destroy-on-close>
<div class="detail-container" v-if="currentRecord">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="考勤日期">{{ currentRecord.attDate }}</el-descriptions-item>
<el-descriptions-item label="课程ID">{{ currentRecord.courseId }}</el-descriptions-item>
<el-descriptions-item label="开始时间">{{ currentRecord.startTime }}</el-descriptions-item>
<el-descriptions-item label="结束时间">{{ currentRecord.endTime }}</el-descriptions-item>
<el-descriptions-item label="应到人数">{{ currentRecord.totalCount }}</el-descriptions-item>
<el-descriptions-item label="实到人数">{{ currentRecord.actualCount }}</el-descriptions-item>
<el-descriptions-item label="缺勤人数">
<el-tag type="danger" size="small">{{ currentRecord.absentCount }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="迟到人数">{{ currentRecord.lateCount }}</el-descriptions-item>
<el-descriptions-item label="早退人数">{{ currentRecord.leaveEarlyCount }}</el-descriptions-item>
<el-descriptions-item label="出勤率">
<el-tag :type="getRateType(currentRecord.attendanceRate)" size="small">
{{ currentRecord.attendanceRate }}%
</el-tag>
</el-descriptions-item>
</el-descriptions>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { Search, RefreshRight, Clock } from '@element-plus/icons-vue'
import { getAttendanceHistory } from '@/api/history'
const loading = ref(false)
const detailVisible = ref(false)
const currentRecord = ref(null)
const query = reactive({
courseName: '',
date: null
})
const pagination = reactive({
current: 1,
size: 20,
total: 0
})
const records = ref([])
/** 查询 */
const doSearch = async () => {
loading.value = true
try {
const params = {
current: pagination.current,
size: pagination.size,
courseName: query.courseName || undefined,
attDate: query.date || undefined
}
const res = await getAttendanceHistory(params)
if (res && res.data) {
records.value = res.data.records || []
pagination.current = res.data.current
pagination.total = res.data.total
pagination.size = res.data.size
}
} catch {
// 错误已在拦截器统一处理
} finally {
loading.value = false
}
}
/** 重置 */
const doReset = () => {
query.courseName = ''
query.date = null
pagination.current = 1
doSearch()
}
/** 根据出勤率获取水波颜色 */
const getBorderColor = (rate) => {
if (rate >= 95) return '#52c41a'
if (rate >= 85) return '#faad14'
return '#ff4d4f'
}
/** 根据出勤率获取水面渐变 */
const getWaterGradient = (rate) => {
if (rate >= 95) return 'linear-gradient(180deg, rgba(82,196,26,0.3) 0%, rgba(82,196,26,0.65) 100%)'
if (rate >= 85) return 'linear-gradient(180deg, rgba(250,173,20,0.3) 0%, rgba(250,173,20,0.65) 100%)'
return 'linear-gradient(180deg, rgba(255,77,79,0.3) 0%, rgba(255,77,79,0.65) 100%)'
}
/** 根据出勤率获取 tag 类型 */
const getRateType = (rate) => {
if (rate >= 95) return 'success'
if (rate >= 85) return 'warning'
return 'danger'
}
/** 查看详情 */
const viewDetail = (record) => {
currentRecord.value = record
detailVisible.value = true
}
onMounted(() => {
doSearch()
})
</script>
<style lang="scss" scoped>
.result-section {
background: #ffffff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h3 {
font-size: 16px;
font-weight: 600;
color: #262626;
}
}
.result-count {
font-size: 12px;
color: #bfbfbf;
}
}
.image-masonry {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.masonry-item {
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
border: 1px solid #f0f0f0;
&:hover {
border-color: #52c41a;
box-shadow: 0 4px 12px rgba(82, 196, 26, 0.12);
transform: translateY(-2px);
}
}
.masonry-thumb {
background: #f9fafb;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 18px 0 12px;
}
/* ========== 水球玻璃 ========== */
.water-glass {
width: 110px;
height: 110px;
border-radius: 50%;
position: relative;
overflow: hidden;
background: rgba(255, 255, 255, 0.25);
border: 3px solid rgba(255, 255, 255, 0.5);
box-shadow:
0 0 15px rgba(0, 0, 0, 0.08),
inset 0 2px 8px rgba(255, 255, 255, 0.4),
inset 0 -2px 8px rgba(0, 0, 0, 0.05);
}
.water-fill {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 0%;
transition: height 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
overflow: visible;
}
.water-wave {
position: absolute;
top: -16px;
left: 0;
width: 200%;
height: 20px;
animation: wave 3s linear infinite;
}
.water-wave-back {
animation: wave 4s linear infinite reverse;
}
@keyframes wave {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
.water-label {
font-size: 12px;
color: #8c8c8c;
margin-top: 4px;
}
.water-value {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 24px;
font-weight: 700;
color: #262626;
z-index: 2;
pointer-events: none;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.7);
}
.masonry-info {
padding: 8px 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.masonry-course {
font-size: 14px;
font-weight: 600;
color: #262626;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.masonry-time {
font-size: 13px;
color: #525252;
display: flex;
align-items: center;
gap: 4px;
font-family: monospace;
}
.masonry-stats {
font-size: 13px;
color: #bfbfbf;
display: flex;
align-items: center;
gap: 4px;
}
.masonry-absent {
font-size: 13px;
color: #ff4d4f;
}
.masonry-extra {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 2px;
}
.masonry-tag {
font-size: 12px;
color: #8c8c8c;
background: #f5f5f5;
border-radius: 3px;
padding: 1px 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pagination-wrap {
display: flex;
justify-content: center;
margin-top: 16px;
}
.detail-container {
.el-descriptions {
margin-top: 8px;
}
}
</style>