feat:课堂行为分析、历史记录查询接口对接

master
zhoulexin 3 weeks ago
parent fc7f642463
commit 7ff83d303c

@ -0,0 +1,21 @@
import request from '@/utils/request'
/** 获取行为类型统计(含占比数据) */
export const getBehaviorTypesWithStats = (params) => {
return request.get('/behavior/types-with-stats', { params })
}
/** 获取各行为时段分布数据 */
export const getBehaviorTimePeriod = (params) => {
return request.get('/behavior/stats/time-period', { params })
}
/** 获取行为标记图片记录(分页) */
export const getBehaviorRecords = (params) => {
return request.get('/behavior/records', { params })
}
/** 获取行为类型列表 */
export const getBehaviorTypes = () => {
return request.get('/behavior/types')
}

@ -0,0 +1,6 @@
import request from '@/utils/request'
/** 获取考勤历史记录(分页) */
export const getAttendanceHistory = (params) => {
return request.get('/attendance/history', { params })
}

@ -7,18 +7,11 @@
<!-- 课程选择区 --> <!-- 课程选择区 -->
<div class="filter-bar"> <div class="filter-bar">
<el-select v-model="filters.course" placeholder="选择课程" size="default" style="width: 200px"> <el-input v-model="filters.courseName" placeholder="搜索课程" clearable size="default" style="width: 200px" />
<el-option label="高等数学A" value="math_a" /> <el-input v-model="filters.teacherName" placeholder="搜索教师" clearable size="default" style="width: 180px" />
<el-option label="计算机导论" value="cs_intro" /> <el-date-picker v-model="filters.date" type="date" placeholder="选择日期" size="default" value-format="YYYY-MM-DD" />
<el-option label="大学英语B" value="eng_b" /> <el-button type="primary" :icon="Search" :loading="loading" @click="fetchData"></el-button>
</el-select> <el-button :icon="RefreshRight" @click="handleReset"></el-button>
<el-select v-model="filters.teacher" placeholder="教师" clearable size="default" style="width: 140px">
<el-option label="王教授" value="wang" />
<el-option label="张教授" value="zhang" />
<el-option label="李老师" value="li" />
</el-select>
<el-date-picker v-model="filters.date" type="date" placeholder="选择日期" size="default" />
<el-button type="primary" :icon="Search">查询</el-button>
</div> </div>
<!-- 行为统计图表 --> <!-- 行为统计图表 -->
@ -32,7 +25,7 @@
<div ref="pieChartRef" class="chart-body"></div> <div ref="pieChartRef" class="chart-body"></div>
<div class="behavior-legend"> <div class="behavior-legend">
<span v-for="b in behaviors" :key="b.name" class="legend-dot" :style="{ background: b.color }"> <span v-for="b in behaviors" :key="b.name" class="legend-dot" :style="{ background: b.color }">
{{ b.name }} {{ b.percent }}% {{ b.name }} {{ behaviorPercent(b) }}%
</span> </span>
</div> </div>
</div> </div>
@ -50,39 +43,52 @@
<div class="image-section"> <div class="image-section">
<div class="section-header"> <div class="section-header">
<h3>行为标记图片</h3> <h3>行为标记图片</h3>
<el-radio-group v-model="imageFilter" size="small"> <el-radio-group v-model="imageFilter" size="small" @change="onFilterChange">
<el-radio-button value="all">全部</el-radio-button> <el-radio-button value="">全部</el-radio-button>
<el-radio-button value="专注">专注</el-radio-button> <el-radio-button
<el-radio-button value="举手">举手</el-radio-button> v-for="bt in behaviorTypes"
<el-radio-button value="低头">低头</el-radio-button> :key="bt.id"
<el-radio-button value="交谈">交谈</el-radio-button> :value="bt.id"
>{{ bt.typeName }}</el-radio-button>
</el-radio-group> </el-radio-group>
</div> </div>
<div class="image-grid"> <div v-if="imageRecords.length" class="image-grid">
<div <div
v-for="(img, index) in filteredImages" v-for="(img, index) in imageRecords"
:key="index" :key="img.id || index"
class="image-card" class="image-card"
@click="previewImage(img)" @click="previewImage(img)"
> >
<div class="image-placeholder" :style="{ borderColor: getBehaviorColor(img.type) }"> <div class="image-placeholder" :style="{ borderColor: getTypeColor(img.behaviorTypeId) }">
<el-icon :size="32" :color="getBehaviorColor(img.type)"> <img v-if="img.snapshotUrl" :src="img.snapshotUrl" alt="" class="snapshot-img" />
<component :is="getBehaviorIcon(img.type)" /> <el-icon v-else :size="32" :color="getTypeColor(img.behaviorTypeId)">
<PictureFilled />
</el-icon> </el-icon>
</div> </div>
<div class="image-info"> <div class="image-info">
<span class="image-time">{{ img.time }}</span> <span class="image-time">{{ img.behaviorTime }}</span>
<el-tag :type="getBehaviorTagType(img.type)" size="small">{{ img.type }}</el-tag> <el-tag :color="getTypeColor(img.behaviorTypeId)" size="small" effect="dark">
{{ img.behaviorTypeName }}
</el-tag>
</div> </div>
<div class="image-check" @click.stop> <div class="image-check" @click.stop>
<el-checkbox v-model="img.checked" /> <el-checkbox v-model="img.checked" />
</div> </div>
</div> </div>
</div> </div>
<el-empty v-else description="暂无数据" :image-size="80" />
<div class="image-actions">
<el-pagination small background layout="prev, pager, next" :total="40" /> <div v-if="imageRecords.length" class="image-actions">
<el-pagination
small
background
layout="prev, pager, next"
:total="pagination.total"
:current-page="pagination.current"
:page-size="pagination.size"
@current-change="onPageChange"
/>
<el-button v-if="hasChecked" type="primary" size="small" :icon="Download" @click="batchDownload"> <el-button v-if="hasChecked" type="primary" size="small" :icon="Download" @click="batchDownload">
批量下载 ({{ checkedCount }}) 批量下载 ({{ checkedCount }})
</el-button> </el-button>
@ -93,9 +99,12 @@
<el-dialog v-model="previewVisible" title="图片预览" width="600px"> <el-dialog v-model="previewVisible" title="图片预览" width="600px">
<div class="preview-container"> <div class="preview-container">
<div class="preview-placeholder"> <div class="preview-placeholder">
<el-icon :size="64" color="#d9d9d9"><PictureFilled /></el-icon> <img v-if="currentPreview?.snapshotUrl" :src="currentPreview.snapshotUrl" alt="" class="preview-img" />
<p>图片预览区域</p> <template v-else>
<span class="preview-meta">{{ currentPreview?.time }} | {{ currentPreview?.type }}</span> <el-icon :size="64" color="#d9d9d9"><PictureFilled /></el-icon>
<p>暂无图片</p>
</template>
<span class="preview-meta">{{ currentPreview?.behaviorTime }} | {{ currentPreview?.behaviorTypeName }}</span>
</div> </div>
</div> </div>
</el-dialog> </el-dialog>
@ -106,64 +115,53 @@
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { getBehaviorTypesWithStats, getBehaviorTimePeriod, getBehaviorRecords, getBehaviorTypes } from '@/api/behavior'
const filters = reactive({ const filters = reactive({
course: 'math_a', courseName: '',
teacher: '', teacherName: '',
date: null date: null
}) })
const imageFilter = ref('all') const imageFilter = ref('')
//
const behaviorTypes = ref([])
const behaviors = ref([ //
{ name: '专注听讲', percent: 65, color: '#52c41a' }, const behaviors = ref([])
{ name: '举手互动', percent: 15, color: '#1890ff' }, const loading = ref(false)
{ name: '低头书写', percent: 12, color: '#722ed1' },
{ name: '交谈讨论', percent: 5, color: '#faad14' },
{ name: '其他', percent: 3, color: '#bfbfbf' }
])
const pieChartRef = ref(null) const pieChartRef = ref(null)
const barChartRef = ref(null) const barChartRef = ref(null)
let pieChart = null let pieChart = null
let barChart = null let barChart = null
// //
const images = ref([ const imageRecords = ref([])
{ time: '08:15:32', type: '专注', checked: false }, const pagination = reactive({
{ time: '08:18:05', type: '举手', checked: false }, current: 1,
{ time: '08:22:41', type: '专注', checked: false }, size: 10,
{ time: '08:30:18', type: '低头', checked: false }, total: 0
{ time: '08:35:56', type: '专注', checked: false },
{ time: '08:42:10', type: '交谈', checked: false },
{ time: '08:48:33', type: '举手', checked: false },
{ time: '08:55:20', type: '专注', checked: false }
])
const filteredImages = computed(() => {
if (imageFilter.value === 'all') return images.value
return images.value.filter(i => i.type === imageFilter.value)
}) })
const checkedCount = computed(() => images.value.filter(i => i.checked).length) const checkedCount = computed(() => imageRecords.value.filter(i => i.checked).length)
const hasChecked = computed(() => checkedCount.value > 0) const hasChecked = computed(() => checkedCount.value > 0)
const previewVisible = ref(false) const previewVisible = ref(false)
const currentPreview = ref(null) const currentPreview = ref(null)
const getBehaviorColor = (type) => { /** 根据行为类型 ID 获取对应颜色 */
const map = { '专注': '#52c41a', '举手': '#1890ff', '低头': '#722ed1', '交谈': '#faad14' } const getTypeColor = (behaviorTypeId) => {
return map[type] || '#bfbfbf' const bt = behaviorTypes.value.find(t => t.id === behaviorTypeId)
} return bt ? bt.color : '#bfbfbf'
const getBehaviorTagType = (type) => {
const map = { '专注': 'success', '举手': '', '低头': 'info', '交谈': 'warning' }
return map[type] || 'info'
} }
const getBehaviorIcon = (type) => { /** 计算单个行为占比 */
const map = { '专注': 'View', '举手': 'Pointer', '低头': 'Reading', '交谈': 'ChatDotRound' } const behaviorPercent = (b) => {
return map[type] || 'QuestionFilled' if (!behaviors.value.length) return 0
const total = behaviors.value.reduce((sum, item) => sum + (item.count || 0), 0)
return total > 0 ? ((b.count / total) * 100).toFixed(1) : 0
} }
const previewImage = (img) => { const previewImage = (img) => {
@ -175,21 +173,188 @@ const batchDownload = () => {
ElMessage.success(`正在下载 ${checkedCount.value} 张图片...`) ElMessage.success(`正在下载 ${checkedCount.value} 张图片...`)
} }
/** 获取行为类型列表 */
const fetchBehaviorTypes = async () => {
try {
const res = await getBehaviorTypes()
if (res && res.data) {
behaviorTypes.value = res.data
imageFilter.value = ''
}
} catch { /* 错误已在拦截器统一处理 */ }
}
/** 获取行为标记图片记录 */
const fetchRecords = async (page = 1) => {
try {
const params = {
current: page,
size: pagination.size,
courseName: filters.courseName || undefined,
teacherName: filters.teacherName || undefined,
attDate: filters.date || undefined,
behaviorTypeId: imageFilter.value || undefined
}
const res = await getBehaviorRecords(params)
if (res && res.data) {
imageRecords.value = (res.data.records || []).map(r => ({
...r,
checked: false
}))
pagination.current = res.data.current
pagination.total = res.data.total
pagination.size = res.data.size
}
} catch { /* 错误已在拦截器统一处理 */ }
}
/** 重置筛选条件 */
const handleReset = () => {
filters.courseName = ''
filters.teacherName = ''
filters.date = null
imageFilter.value = ''
pagination.current = 1
fetchData()
}
/** 查询按钮 */
const fetchData = async () => {
loading.value = true
pagination.current = 1
try {
const params = {
courseName: filters.courseName || undefined,
teacherName: filters.teacherName || undefined,
attDate: filters.date || undefined
}
const [res1, res2] = await Promise.all([
getBehaviorTypesWithStats(params),
getBehaviorTimePeriod(params)
])
//
if (res1 && res1.data) {
behaviors.value = res1.data.map(item => ({
id: item.id,
typeCode: item.typeCode,
name: item.typeName,
color: item.color,
count: item.count,
category: item.category,
description: item.description
}))
updatePieChart()
}
//
if (res2 && res2.data) {
updateBarChart(res2.data)
}
//
await fetchRecords(1)
} catch {
//
} finally {
loading.value = false
}
}
/** 筛选条件变化时重新查询 */
const onFilterChange = () => {
pagination.current = 1
fetchRecords(1)
}
/** 分页切换 */
const onPageChange = (page) => {
fetchRecords(page)
}
/** 更新饼图数据 */
const updatePieChart = () => {
if (!pieChart) return
const total = behaviors.value.reduce((sum, b) => sum + (b.count || 0), 0)
pieChart.setOption({
series: [{
data: behaviors.value.map(b => ({
value: b.count,
name: b.name,
itemStyle: { color: b.color }
}))
}],
tooltip: {
trigger: 'item',
formatter: (params) => {
const pct = total > 0 ? ((params.value / total) * 100).toFixed(1) : 0
return `${params.name}: ${params.value} (${pct}%)`
}
}
})
}
/** 更新柱状图数据 */
const updateBarChart = (data) => {
if (!barChart || !data.length) return
// x
const xData = data.map(d => d.timeSlot)
//
const nameSet = new Set()
const nameList = []
const colorMap = {}
data.forEach(slot => {
(slot.data || []).forEach(item => {
if (!nameSet.has(item.name)) {
nameSet.add(item.name)
nameList.push(item.name)
colorMap[item.name] = item.color
}
})
})
// series
const series = nameList.map((name, idx) => ({
name,
type: 'bar',
stack: 'total',
data: data.map(slot => {
const found = (slot.data || []).find(item => item.name === name)
return found ? found.value : 0
}),
color: colorMap[name],
itemStyle: idx === 0 ? { borderRadius: [4, 4, 0, 0] } : {}
}))
barChart.setOption({
grid: { bottom: 70 },
xAxis: { data: xData },
legend: {
data: nameList,
bottom: 0,
type: 'scroll'
},
series
})
}
const initCharts = () => { const initCharts = () => {
// //
if (pieChartRef.value) { if (pieChartRef.value) {
pieChart = echarts.init(pieChartRef.value) pieChart = echarts.init(pieChartRef.value)
pieChart.setOption({ pieChart.setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c}%' }, tooltip: { trigger: 'item' },
series: [{ series: [{
type: 'pie', type: 'pie',
radius: ['55%', '80%'], radius: ['45%', '80%'],
center: ['50%', '50%'], center: ['50%', '50%'],
avoidLabelOverlap: false, avoidLabelOverlap: false,
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 }, itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
label: { show: false }, label: { show: false },
emphasis: { label: { show: true, fontSize: 16, fontWeight: 'bold' } }, emphasis: { label: { show: true, fontSize: 16, fontWeight: 'bold' } },
data: behaviors.value.map(b => ({ value: b.percent, name: b.name, itemStyle: { color: b.color } })) data: []
}] }]
}) })
} }
@ -214,6 +379,8 @@ const initCharts = () => {
onMounted(() => { onMounted(() => {
initCharts() initCharts()
fetchBehaviorTypes()
fetchData()
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
pieChart?.resize() pieChart?.resize()
barChart?.resize() barChart?.resize()
@ -268,18 +435,19 @@ onUnmounted(() => {
.legend-dot { .legend-dot {
font-size: 12px; font-size: 12px;
color: #525252; color: #ffffff;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
padding: 2px 5px;
&::before { border-radius: 4px;
content: ''; // &::before {
width: 8px; // content: '';
height: 8px; // width: 8px;
border-radius: 50%; // height: 8px;
background: inherit; // border-radius: 50%;
} // background: inherit;
// }
} }
.image-section { .image-section {
@ -330,6 +498,13 @@ onUnmounted(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-bottom: 3px solid #d9d9d9; border-bottom: 3px solid #d9d9d9;
overflow: hidden;
.snapshot-img {
width: 100%;
height: 100%;
object-fit: cover;
}
} }
.image-info { .image-info {
@ -377,6 +552,13 @@ onUnmounted(() => {
gap: 8px; gap: 8px;
color: #bfbfbf; color: #bfbfbf;
font-size: 14px; font-size: 14px;
overflow: hidden;
.preview-img {
width: 100%;
height: 100%;
object-fit: contain;
}
} }
.preview-meta { .preview-meta {

@ -80,11 +80,16 @@ const initDetailChart = () => {
} }
const batchExport = () => ElMessage.success('批量导出成功') const batchExport = () => ElMessage.success('批量导出成功')
// tab
//
watch(() => props.modelValue, (val) => { watch(() => props.modelValue, (val) => {
if (val) { if (val) {
detailTab.value = 'absent' detailTab.value = 'absent'
}
})
// tab
watch(detailTab, (val) => {
if (val === 'stats') {
nextTick(() => initDetailChart()) nextTick(() => initDetailChart())
} }
}) })

@ -45,7 +45,7 @@
<el-table-column prop="courseName" label="课程名称" min-width="150" /> <el-table-column prop="courseName" label="课程名称" min-width="150" />
<el-table-column prop="teacher" label="授课教师" width="100" align="center" /> <el-table-column prop="teacher" label="授课教师" width="100" align="center" />
<el-table-column prop="classroom" label="教室" width="120" align="center" /> <el-table-column prop="classroom" label="教室" width="120" align="center" />
<el-table-column prop="time" label="上课时间" width="170" align="center" sortable> <el-table-column prop="time" label="上课时间" width="320" align="center" sortable>
<template #default="{ row }"> <template #default="{ row }">
<span v-html="row.time"></span> <span v-html="row.time"></span>
</template> </template>
@ -100,7 +100,7 @@ const mapRecord = (item) => ({
courseName: item.courseName, courseName: item.courseName,
teacher: item.teacherName, teacher: item.teacherName,
classroom: item.classroomName, classroom: item.classroomName,
time: `${item.startTime || ''}<br/>至<br/>${item.endTime || ''}`, time: `${item.startTime || ''}${item.endTime || ''}`,
total: item.totalCount, total: item.totalCount,
actual: item.actualCount, actual: item.actualCount,
absentCount: item.absentCount, absentCount: item.absentCount,

@ -57,7 +57,7 @@
<el-table :data="recentRecords" stripe> <el-table :data="recentRecords" stripe>
<el-table-column prop="courseName" label="课程名称" min-width="150" /> <el-table-column prop="courseName" label="课程名称" min-width="150" />
<el-table-column prop="classroom" label="教室" width="120" align="center" /> <el-table-column prop="classroom" label="教室" width="120" align="center" />
<el-table-column prop="time" label="上课时间" width="300" sortable align="center"> <el-table-column prop="time" label="上课时间" width="320" sortable align="center">
<template #default="{ row }"> <template #default="{ row }">
<span v-html="row.time"></span> <span v-html="row.time"></span>
</template> </template>

@ -15,244 +15,196 @@
size="default" size="default"
style="width: 200px" style="width: 200px"
/> />
<el-date-picker v-model="query.date" type="date" placeholder="选择日期" size="default" /> <el-date-picker v-model="query.date" type="date" placeholder="选择日期" size="default" value-format="YYYY-MM-DD" />
<el-time-select
v-model="query.startTime"
start="08:00"
step="00:30"
end="18:00"
placeholder="开始时间"
size="default"
/>
<el-time-select
v-model="query.endTime"
start="08:00"
step="00:30"
end="18:00"
placeholder="结束时间"
size="default"
/>
<el-select v-model="query.behaviorType" placeholder="行为类型" clearable size="default" style="width: 140px"> <el-select v-model="query.behaviorType" placeholder="行为类型" clearable size="default" style="width: 140px">
<el-option label="全部" value="" /> <el-option label="全部" value="" />
<el-option label="专注" value="focus" /> <el-option
<el-option label="举手" value="hand" /> v-for="bt in behaviorTypes"
<el-option label="低头" value="down" /> :key="bt.id"
<el-option label="交谈" value="talk" /> :label="bt.typeName"
:value="bt.id"
/>
</el-select> </el-select>
<el-button type="primary" :icon="Search" @click="doSearch"></el-button> <el-button type="primary" :icon="Search" :loading="loading" @click="doSearch"></el-button>
<el-button :icon="RefreshRight" @click="doReset"></el-button> <el-button :icon="RefreshRight" @click="doReset"></el-button>
</div> </div>
<!-- 时间轴工具栏 --> <!-- 查询结果 -->
<div class="timeline-toolbar">
<div class="timeline-slider">
<span class="slider-label">时间轴</span>
<el-slider v-model="timelinePosition" :max="100" :step="10" show-stops size="small" style="flex: 1" />
<span class="slider-time">{{ currentTimeline }}</span>
</div>
<el-button-group size="small">
<el-button :icon="VideoPlay" @click="toggleSlideshow">{{ playing ? '' : '' }}</el-button>
<el-button :icon="FullScreen" @click="fullscreenMode = true">全屏</el-button>
</el-button-group>
</div>
<!-- 图片宫格 -->
<div class="result-section"> <div class="result-section">
<div class="section-header"> <div class="section-header">
<h3>查询结果</h3> <h3>查询结果</h3>
<span class="result-count"> {{ filteredImages.length }} 条记录</span> <span class="result-count"> {{ pagination.total }} 条记录</span>
</div> </div>
<div v-loading="loading" class="image-masonry"> <div v-loading="loading" class="image-masonry">
<div <div
v-for="(img, index) in filteredImages" v-for="record in records"
:key="index" :key="record.id"
class="masonry-item" class="masonry-item"
@click="viewOriginal(img)" @click="viewDetail(record)"
> >
<div class="masonry-thumb"> <div class="masonry-thumb">
<div class="thumb-placeholder" :style="{ borderColor: getBorderColor(img.type) }"> <div class="thumb-placeholder" :style="{ borderColor: getBorderColor(record.attendanceRate) }">
<el-icon :size="28" :color="getBorderColor(img.type)"> <div class="attendance-stat">
<PictureFilled /> <span class="attendance-rate">{{ record.attendanceRate }}%</span>
</el-icon> <span class="attendance-label">出勤率</span>
</div> </div>
<div class="thumb-badge">
<el-tag :type="getTagType(img.type)" size="small" effect="dark">{{ img.type }}</el-tag>
</div> </div>
</div> </div>
<div class="masonry-info"> <div class="masonry-info">
<span class="masonry-time"> <span class="masonry-time">
<el-icon :size="12"><Clock /></el-icon> <el-icon :size="12"><Clock /></el-icon>
{{ img.time }} {{ record.attDate }}
</span>
<div class="masonry-stats">
<span>应到 {{ record.totalCount }}</span>
<el-divider direction="vertical" />
<span>实到 {{ record.actualCount }}</span>
</div>
<span class="masonry-absent" v-if="record.absentCount > 0">
缺勤 {{ record.absentCount }}
</span> </span>
<span class="masonry-course">{{ img.course }}</span>
</div> </div>
</div> </div>
</div> </div>
<el-pagination <div v-if="records.length" class="pagination-wrap">
v-model:current-page="pagination.current" <el-pagination
:total="filteredImages.length" v-model:current-page="pagination.current"
:page-size="pagination.pageSize" :total="pagination.total"
small :page-size="pagination.size"
background small
layout="prev, pager, next" background
style="justify-content: center; margin-top: 16px" layout="prev, pager, next"
/> @current-change="doSearch"
/>
</div>
<el-empty v-if="!loading && filteredImages.length === 0" description="暂无数据,请调整查询条件" /> <el-empty v-if="!loading && records.length === 0" description="暂无数据,请调整查询条件" />
</div> </div>
<!-- 原图预览 --> <!-- 详情弹窗 -->
<el-dialog v-model="previewVisible" title="原图预览" width="700px" destroy-on-close> <el-dialog v-model="detailVisible" title="考勤详情" width="520px" destroy-on-close>
<div class="original-preview"> <div class="detail-container" v-if="currentRecord">
<div class="preview-image-area"> <el-descriptions :column="2" border size="small">
<el-icon :size="80" color="#d9d9d9"><PictureFilled /></el-icon> <el-descriptions-item label="考勤日期">{{ currentRecord.attDate }}</el-descriptions-item>
</div> <el-descriptions-item label="课程ID">{{ currentRecord.courseId }}</el-descriptions-item>
<div class="preview-detail"> <el-descriptions-item label="开始时间">{{ currentRecord.startTime }}</el-descriptions-item>
<span>{{ currentImage?.time }}</span> <el-descriptions-item label="结束时间">{{ currentRecord.endTime }}</el-descriptions-item>
<el-divider direction="vertical" /> <el-descriptions-item label="应到人数">{{ currentRecord.totalCount }}</el-descriptions-item>
<el-tag :type="getTagType(currentImage?.type)" size="small">{{ currentImage?.type }}</el-tag> <el-descriptions-item label="实到人数">{{ currentRecord.actualCount }}</el-descriptions-item>
<el-divider direction="vertical" /> <el-descriptions-item label="缺勤人数">
<span>{{ currentImage?.course }}</span> <el-tag type="danger" size="small">{{ currentRecord.absentCount }}</el-tag>
</div> </el-descriptions-item>
</div> <el-descriptions-item label="迟到人数">{{ currentRecord.lateCount }}</el-descriptions-item>
</el-dialog> <el-descriptions-item label="早退人数">{{ currentRecord.leaveEarlyCount }}</el-descriptions-item>
<el-descriptions-item label="出勤率">
<!-- 全屏连续播放 --> <el-tag :type="getRateType(currentRecord.attendanceRate)" size="small">
<el-dialog v-model="fullscreenMode" title="连续播放" fullscreen destroy-on-close> {{ currentRecord.attendanceRate }}%
<div class="slideshow-container"> </el-tag>
<div class="slideshow-image"> </el-descriptions-item>
<el-icon :size="100" color="#d9d9d9"><VideoCamera /></el-icon> </el-descriptions>
<p class="slideshow-hint">连续播放模式 - 图片序列展示区域</p>
</div>
<div class="slideshow-controls">
<el-button-group>
<el-button :icon="DArrowLeft">上一张</el-button>
<el-button type="primary" @click="toggleSlideshow">
<el-icon><component :is="playing ? 'VideoPause' : 'VideoPlay'" /></el-icon>
{{ playing ? '暂停' : '播放' }}
</el-button>
<el-button :icon="DArrowRight">下一张</el-button>
</el-button-group>
<el-slider v-model="timelinePosition" style="width: 300px" size="small" />
</div>
</div> </div>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { getAttendanceHistory } from '@/api/history'
import { getBehaviorTypes } from '@/api/behavior'
const loading = ref(false) const loading = ref(false)
const playing = ref(false) const detailVisible = ref(false)
const previewVisible = ref(false) const currentRecord = ref(null)
const fullscreenMode = ref(false)
const currentImage = ref(null) const behaviorTypes = ref([])
const timelinePosition = ref(50)
const query = reactive({ const query = reactive({
courseName: '', courseName: '',
date: null, date: null,
startTime: '',
endTime: '',
behaviorType: '' behaviorType: ''
}) })
const pagination = reactive({ const pagination = reactive({
current: 1, current: 1,
pageSize: 20 size: 20,
total: 0
}) })
const images = ref([ const records = ref([])
{ time: '2024-06-01 08:15', type: '专注', course: '高等数学A' },
{ time: '2024-06-01 08:18', type: '举手', course: '高等数学A' },
{ time: '2024-06-01 08:22', type: '专注', course: '高等数学A' },
{ time: '2024-06-01 08:30', type: '低头', course: '高等数学A' },
{ time: '2024-06-01 10:05', type: '专注', course: '大学英语B' },
{ time: '2024-06-01 10:12', type: '交谈', course: '大学英语B' },
{ time: '2024-06-01 14:10', type: '专注', course: '计算机导论' },
{ time: '2024-06-01 14:20', type: '举手', course: '计算机导论' },
{ time: '2024-05-31 08:15', type: '专注', course: '线性代数' },
{ time: '2024-05-31 08:30', type: '低头', course: '线性代数' },
{ time: '2024-05-31 10:20', type: '专注', course: '马克思原理' },
{ time: '2024-05-31 10:40', type: '交谈', course: '马克思原理' }
])
const filteredImages = computed(() => images.value)
const currentTimeline = computed(() => {
const times = images.value.map(i => i.time)
return times[Math.floor(timelinePosition.value / 100 * (times.length - 1))] || ''
})
const getBorderColor = (type) => { /** 获取行为类型列表 */
const map = { '专注': '#52c41a', '举手': '#1890ff', '低头': '#722ed1', '交谈': '#faad14' } const fetchBehaviorTypes = async () => {
return map[type] || '#d9d9d9' try {
} const res = await getBehaviorTypes()
if (res && res.data) {
const getTagType = (type) => { behaviorTypes.value = res.data
const map = { '专注': 'success', '举手': '', '低头': 'info', '交谈': 'warning' } }
return map[type] || 'info' } catch { /* 错误已在拦截器统一处理 */ }
}
const viewOriginal = (img) => {
currentImage.value = img
previewVisible.value = true
} }
const doSearch = () => { /** 查询 */
const doSearch = async () => {
loading.value = true loading.value = true
setTimeout(() => { loading.value = false }, 500) try {
const params = {
current: pagination.current,
size: pagination.size,
courseName: query.courseName || undefined,
behaviorTypeId: query.behaviorType || 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 = () => { const doReset = () => {
query.courseName = '' query.courseName = ''
query.date = null query.date = null
query.startTime = ''
query.endTime = ''
query.behaviorType = '' query.behaviorType = ''
pagination.current = 1
doSearch()
} }
const toggleSlideshow = () => { /** 根据出勤率获取边框颜色 */
playing.value = !playing.value const getBorderColor = (rate) => {
if (rate >= 95) return '#52c41a'
if (rate >= 85) return '#faad14'
return '#ff4d4f'
} }
</script>
<style lang="scss" scoped> /** 根据出勤率获取 tag 类型 */
.timeline-toolbar { const getRateType = (rate) => {
background: #ffffff; if (rate >= 95) return 'success'
border-radius: 8px; if (rate >= 85) return 'warning'
padding: 12px 20px; return 'danger'
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
} }
.timeline-slider { /** 查看详情 */
flex: 1; const viewDetail = (record) => {
display: flex; currentRecord.value = record
align-items: center; detailVisible.value = true
gap: 12px;
.slider-label {
font-size: 12px;
color: #525252;
white-space: nowrap;
}
.slider-time {
font-size: 12px;
color: #bfbfbf;
font-family: monospace;
min-width: 80px;
}
} }
onMounted(() => {
fetchBehaviorTypes()
doSearch()
})
</script>
<style lang="scss" scoped>
.result-section { .result-section {
background: #ffffff; background: #ffffff;
border-radius: 8px; border-radius: 8px;
@ -311,10 +263,21 @@ const toggleSlideshow = () => {
border-bottom: 3px solid #d9d9d9; border-bottom: 3px solid #d9d9d9;
} }
.thumb-badge { .attendance-stat {
position: absolute; text-align: center;
top: 6px; }
left: 6px;
.attendance-rate {
display: block;
font-size: 36px;
font-weight: 700;
color: #262626;
line-height: 1.2;
}
.attendance-label {
font-size: 12px;
color: #bfbfbf;
} }
.masonry-info { .masonry-info {
@ -333,65 +296,28 @@ const toggleSlideshow = () => {
font-family: monospace; font-family: monospace;
} }
.masonry-course { .masonry-stats {
font-size: 12px; font-size: 12px;
color: #bfbfbf; color: #bfbfbf;
}
.original-preview {
text-align: center;
}
.preview-image-area {
height: 360px;
background: #f5f7fa;
border-radius: 8px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 4px;
margin-bottom: 12px;
}
.preview-detail {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 13px;
color: #525252;
} }
.slideshow-container { .masonry-absent {
display: flex; font-size: 12px;
flex-direction: column; color: #ff4d4f;
align-items: center;
justify-content: center;
height: 70vh;
} }
.slideshow-image { .pagination-wrap {
width: 80%;
max-width: 900px;
height: 500px;
background: #1a1a2e;
border-radius: 12px;
display: flex; display: flex;
flex-direction: column;
align-items: center;
justify-content: center; justify-content: center;
gap: 16px; margin-top: 16px;
color: #525252;
margin-bottom: 24px;
} }
.slideshow-hint { .detail-container {
font-size: 16px; .el-descriptions {
color: #d9d9d9; margin-top: 8px;
} }
.slideshow-controls {
display: flex;
align-items: center;
gap: 24px;
} }
</style> </style>

Loading…
Cancel
Save