|
|
|
|
@ -3,7 +3,7 @@
|
|
|
|
|
:model-value="props.modelValue"
|
|
|
|
|
@update:model-value="emit('update:modelValue', $event)"
|
|
|
|
|
:title="`${props.detailData.courseName} - 考勤详情`"
|
|
|
|
|
width="720px"
|
|
|
|
|
width="1010px"
|
|
|
|
|
destroy-on-close
|
|
|
|
|
>
|
|
|
|
|
<div class="detail-header">
|
|
|
|
|
@ -13,24 +13,52 @@
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<el-tabs v-model="detailTab">
|
|
|
|
|
<el-tab-pane label="未签到学生" name="absent">
|
|
|
|
|
<el-table :data="absentStudents" stripe max-height="300">
|
|
|
|
|
<el-tab-pane label="考勤详情" name="detail">
|
|
|
|
|
<!-- 搜索区 -->
|
|
|
|
|
<div class="search-bar">
|
|
|
|
|
<el-input v-model="searchForm.studentName" placeholder="学生姓名" clearable size="default" style="width: 180px" @clear="handleSearch" />
|
|
|
|
|
<el-select v-model="searchForm.attStatus" placeholder="考勤状态" clearable size="default" style="width: 140px" @change="handleSearch">
|
|
|
|
|
<el-option label="未签到" :value="0" />
|
|
|
|
|
<el-option label="正常" :value="1" />
|
|
|
|
|
<el-option label="迟到" :value="2" />
|
|
|
|
|
<el-option label="缺勤" :value="3" />
|
|
|
|
|
<el-option label="早退" :value="4" />
|
|
|
|
|
<el-option label="请假" :value="5" />
|
|
|
|
|
</el-select>
|
|
|
|
|
<el-button type="primary" size="default" @click="handleSearch">查询</el-button>
|
|
|
|
|
<el-button size="default" @click="handleReset">重置</el-button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<el-table :data="detailList" stripe v-loading="loading" max-height="380" @selection-change="handleSelectionChange">
|
|
|
|
|
<el-table-column type="selection" width="45" />
|
|
|
|
|
<el-table-column prop="studentId" label="学号" width="120" />
|
|
|
|
|
<el-table-column prop="name" label="姓名" width="100" />
|
|
|
|
|
<el-table-column prop="className" label="班级" min-width="140" />
|
|
|
|
|
<el-table-column prop="reason" label="原因" width="100">
|
|
|
|
|
<el-table-column prop="studentNo" label="学号" width="120" />
|
|
|
|
|
<el-table-column prop="studentName" label="姓名" width="100" />
|
|
|
|
|
<el-table-column prop="attDate" label="考勤日期" width="120" />
|
|
|
|
|
<el-table-column prop="checkInTime" label="签到时间" width="170" />
|
|
|
|
|
<el-table-column prop="checkOutTime" label="签退时间" width="170" />
|
|
|
|
|
<el-table-column prop="faceSimilarity" label="人脸相似度" width="110" align="center">
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<span>{{ (row.faceSimilarity * 100).toFixed(1) }}%</span>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<el-table-column prop="attStatus" label="考勤状态" width="100" align="center">
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<el-tag size="small" :type="row.reason === '请假' ? 'warning' : 'danger'">
|
|
|
|
|
{{ row.reason }}
|
|
|
|
|
<el-tag size="small" :type="statusTagType(row.attStatus)">
|
|
|
|
|
{{ statusLabel(row.attStatus) }}
|
|
|
|
|
</el-tag>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
</el-table>
|
|
|
|
|
</el-tab-pane>
|
|
|
|
|
|
|
|
|
|
<el-tab-pane label="出勤统计" name="stats">
|
|
|
|
|
<div ref="detailChartRef" style="height: 300px"></div>
|
|
|
|
|
<el-pagination
|
|
|
|
|
v-model:current-page="pagination.current"
|
|
|
|
|
v-model:page-size="pagination.pageSize"
|
|
|
|
|
:total="total"
|
|
|
|
|
:page-sizes="[10, 20, 50]"
|
|
|
|
|
layout="total, sizes, prev, pager, next"
|
|
|
|
|
background
|
|
|
|
|
style="margin-top: 16px; justify-content: flex-end"
|
|
|
|
|
/>
|
|
|
|
|
</el-tab-pane>
|
|
|
|
|
</el-tabs>
|
|
|
|
|
|
|
|
|
|
@ -42,9 +70,9 @@
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
import { ref, watch, nextTick } from 'vue'
|
|
|
|
|
import { ref, reactive, watch } from 'vue'
|
|
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
|
import * as echarts from 'echarts'
|
|
|
|
|
import { getDetailPage } from '@/api/attendance'
|
|
|
|
|
|
|
|
|
|
const props = defineProps({
|
|
|
|
|
modelValue: { type: Boolean, default: false },
|
|
|
|
|
@ -53,45 +81,122 @@ const props = defineProps({
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits(['update:modelValue'])
|
|
|
|
|
|
|
|
|
|
const detailTab = ref('absent')
|
|
|
|
|
const detailChartRef = ref(null)
|
|
|
|
|
|
|
|
|
|
const absentStudents = ref([
|
|
|
|
|
{ studentId: '2021001', name: '张三', className: '计算机2021-1班', reason: '缺勤' },
|
|
|
|
|
{ studentId: '2021015', name: '李四', className: '计算机2021-1班', reason: '请假' },
|
|
|
|
|
{ studentId: '2021020', name: '王五', className: '计算机2021-1班', reason: '缺勤' }
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
const initDetailChart = () => {
|
|
|
|
|
if (!detailChartRef.value) return
|
|
|
|
|
const chart = echarts.init(detailChartRef.value)
|
|
|
|
|
chart.setOption({
|
|
|
|
|
tooltip: { trigger: 'axis' },
|
|
|
|
|
legend: { data: ['出勤', '缺勤', '请假'], bottom: 0 },
|
|
|
|
|
grid: { top: 20, right: 20, bottom: 40, left: 40 },
|
|
|
|
|
xAxis: { type: 'category', data: ['5/26', '5/27', '5/28', '5/29', '5/30', '5/31', '6/01'] },
|
|
|
|
|
yAxis: { type: 'value' },
|
|
|
|
|
series: [
|
|
|
|
|
{ name: '出勤', type: 'bar', stack: 'total', data: [40, 42, 43, 41, 44, 43, 44], color: '#52c41a' },
|
|
|
|
|
{ name: '缺勤', type: 'bar', stack: 'total', data: [3, 1, 0, 2, 1, 0, 1], color: '#f5222d' },
|
|
|
|
|
{ name: '请假', type: 'bar', stack: 'total', data: [2, 2, 2, 2, 0, 2, 0], color: '#faad14' }
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
const detailTab = ref('detail')
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
const detailList = ref([])
|
|
|
|
|
const total = ref(0)
|
|
|
|
|
|
|
|
|
|
const searchForm = reactive({
|
|
|
|
|
studentName: '',
|
|
|
|
|
attStatus: null
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const pagination = reactive({
|
|
|
|
|
current: 1,
|
|
|
|
|
pageSize: 10
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const statusMap = {
|
|
|
|
|
0: { label: '未签到', type: 'warning' },
|
|
|
|
|
1: { label: '正常', type: 'success' },
|
|
|
|
|
2: { label: '迟到', type: '' },
|
|
|
|
|
3: { label: '缺勤', type: 'danger' },
|
|
|
|
|
4: { label: '早退', type: '' },
|
|
|
|
|
5: { label: '请假', type: 'warning' }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const statusLabel = (val) => statusMap[val]?.label || '未知'
|
|
|
|
|
const statusTagType = (val) => statusMap[val]?.type || 'info'
|
|
|
|
|
|
|
|
|
|
// 选中项
|
|
|
|
|
const selectedRows = ref([])
|
|
|
|
|
const handleSelectionChange = (rows) => {
|
|
|
|
|
selectedRows.value = rows
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fetchDetailList = async () => {
|
|
|
|
|
loading.value = true
|
|
|
|
|
try {
|
|
|
|
|
const params = {
|
|
|
|
|
current: pagination.current,
|
|
|
|
|
size: pagination.pageSize,
|
|
|
|
|
taskId: props.detailData.id
|
|
|
|
|
}
|
|
|
|
|
if (searchForm.studentName) params.studentName = searchForm.studentName
|
|
|
|
|
if (searchForm.attStatus !== null && searchForm.attStatus !== '') params.attStatus = searchForm.attStatus
|
|
|
|
|
const res = await getDetailPage(params)
|
|
|
|
|
if (res?.code === 200 && res.data) {
|
|
|
|
|
detailList.value = res.data.records || []
|
|
|
|
|
total.value = res.data.total || 0
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
detailList.value = []
|
|
|
|
|
total.value = 0
|
|
|
|
|
} finally {
|
|
|
|
|
loading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleSearch = () => {
|
|
|
|
|
pagination.current = 1
|
|
|
|
|
fetchDetailList()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleReset = () => {
|
|
|
|
|
searchForm.studentName = ''
|
|
|
|
|
searchForm.attStatus = null
|
|
|
|
|
pagination.current = 1
|
|
|
|
|
fetchDetailList()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const batchExport = () => ElMessage.success('批量导出成功')
|
|
|
|
|
// 弹窗打开时重置 tab
|
|
|
|
|
const batchExport = () => {
|
|
|
|
|
const list = selectedRows.value.length > 0 ? selectedRows.value : detailList.value
|
|
|
|
|
if (list.length === 0) {
|
|
|
|
|
ElMessage.warning('没有数据可导出')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CSV 表头
|
|
|
|
|
const headers = ['学号', '姓名', '考勤日期', '签到时间', '签退时间', '人脸相似度', '考勤状态']
|
|
|
|
|
const rows = list.map(row => [
|
|
|
|
|
row.studentNo,
|
|
|
|
|
row.studentName,
|
|
|
|
|
row.attDate,
|
|
|
|
|
row.checkInTime,
|
|
|
|
|
row.checkOutTime,
|
|
|
|
|
row.faceSimilarity ? (row.faceSimilarity * 100).toFixed(1) + '%' : '',
|
|
|
|
|
statusLabel(row.attStatus)
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
// 生成 CSV 内容
|
|
|
|
|
const csvContent = [headers, ...rows]
|
|
|
|
|
.map(r => r.map(cell => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(','))
|
|
|
|
|
.join('\n')
|
|
|
|
|
const BOM = '\uFEFF'
|
|
|
|
|
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
|
|
|
|
|
const url = URL.createObjectURL(blob)
|
|
|
|
|
const a = document.createElement('a')
|
|
|
|
|
a.href = url
|
|
|
|
|
a.download = `考勤详情_${props.detailData.courseName || ''}_${new Date().toLocaleDateString()}.csv`
|
|
|
|
|
a.click()
|
|
|
|
|
URL.revokeObjectURL(url)
|
|
|
|
|
ElMessage.success(`已导出 ${list.length} 条记录`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 弹窗打开时加载数据
|
|
|
|
|
watch(() => props.modelValue, (val) => {
|
|
|
|
|
if (val) {
|
|
|
|
|
detailTab.value = 'absent'
|
|
|
|
|
detailTab.value = 'detail'
|
|
|
|
|
pagination.current = 1
|
|
|
|
|
fetchDetailList()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 切换到出勤统计 tab 时初始化图表
|
|
|
|
|
watch(detailTab, (val) => {
|
|
|
|
|
if (val === 'stats') {
|
|
|
|
|
nextTick(() => initDetailChart())
|
|
|
|
|
}
|
|
|
|
|
// 分页变化重新请求
|
|
|
|
|
watch(() => pagination.current, () => fetchDetailList())
|
|
|
|
|
watch(() => pagination.pageSize, () => {
|
|
|
|
|
pagination.current = 1
|
|
|
|
|
fetchDetailList()
|
|
|
|
|
})
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
@ -106,4 +211,11 @@ watch(detailTab, (val) => {
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
color: #525252;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.search-bar {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
align-items: center;
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
|