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">
<el-select v-model="filters.course" placeholder="选择课程" size="default" style="width: 200px">
<el-option label="高等数学A" value="math_a" />
<el-option label="计算机导论" value="cs_intro" />
<el-option label="大学英语B" value="eng_b" />
</el-select>
<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>
<el-input v-model="filters.courseName" placeholder="搜索课程" clearable size="default" style="width: 200px" />
<el-input v-model="filters.teacherName" placeholder="搜索教师" clearable size="default" style="width: 180px" />
<el-date-picker v-model="filters.date" type="date" placeholder="选择日期" size="default" value-format="YYYY-MM-DD" />
<el-button type="primary" :icon="Search" :loading="loading" @click="fetchData"></el-button>
<el-button :icon="RefreshRight" @click="handleReset"></el-button>
</div>
<!-- 行为统计图表 -->
@ -32,7 +25,7 @@
<div ref="pieChartRef" class="chart-body"></div>
<div class="behavior-legend">
<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>
</div>
</div>
@ -50,39 +43,52 @@
<div class="image-section">
<div class="section-header">
<h3>行为标记图片</h3>
<el-radio-group v-model="imageFilter" size="small">
<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 value="低头">低头</el-radio-button>
<el-radio-button value="交谈">交谈</el-radio-button>
<el-radio-group v-model="imageFilter" size="small" @change="onFilterChange">
<el-radio-button value="">全部</el-radio-button>
<el-radio-button
v-for="bt in behaviorTypes"
:key="bt.id"
:value="bt.id"
>{{ bt.typeName }}</el-radio-button>
</el-radio-group>
</div>
<div class="image-grid">
<div v-if="imageRecords.length" class="image-grid">
<div
v-for="(img, index) in filteredImages"
:key="index"
v-for="(img, index) in imageRecords"
:key="img.id || index"
class="image-card"
@click="previewImage(img)"
>
<div class="image-placeholder" :style="{ borderColor: getBehaviorColor(img.type) }">
<el-icon :size="32" :color="getBehaviorColor(img.type)">
<component :is="getBehaviorIcon(img.type)" />
<div class="image-placeholder" :style="{ borderColor: getTypeColor(img.behaviorTypeId) }">
<img v-if="img.snapshotUrl" :src="img.snapshotUrl" alt="" class="snapshot-img" />
<el-icon v-else :size="32" :color="getTypeColor(img.behaviorTypeId)">
<PictureFilled />
</el-icon>
</div>
<div class="image-info">
<span class="image-time">{{ img.time }}</span>
<el-tag :type="getBehaviorTagType(img.type)" size="small">{{ img.type }}</el-tag>
<span class="image-time">{{ img.behaviorTime }}</span>
<el-tag :color="getTypeColor(img.behaviorTypeId)" size="small" effect="dark">
{{ img.behaviorTypeName }}
</el-tag>
</div>
<div class="image-check" @click.stop>
<el-checkbox v-model="img.checked" />
</div>
</div>
</div>
<div class="image-actions">
<el-pagination small background layout="prev, pager, next" :total="40" />
<el-empty v-else description="暂无数据" :image-size="80" />
<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">
批量下载 ({{ checkedCount }})
</el-button>
@ -93,9 +99,12 @@
<el-dialog v-model="previewVisible" title="图片预览" width="600px">
<div class="preview-container">
<div class="preview-placeholder">
<el-icon :size="64" color="#d9d9d9"><PictureFilled /></el-icon>
<p>图片预览区域</p>
<span class="preview-meta">{{ currentPreview?.time }} | {{ currentPreview?.type }}</span>
<img v-if="currentPreview?.snapshotUrl" :src="currentPreview.snapshotUrl" alt="" class="preview-img" />
<template v-else>
<el-icon :size="64" color="#d9d9d9"><PictureFilled /></el-icon>
<p>暂无图片</p>
</template>
<span class="preview-meta">{{ currentPreview?.behaviorTime }} | {{ currentPreview?.behaviorTypeName }}</span>
</div>
</div>
</el-dialog>
@ -106,64 +115,53 @@
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import * as echarts from 'echarts'
import { getBehaviorTypesWithStats, getBehaviorTimePeriod, getBehaviorRecords, getBehaviorTypes } from '@/api/behavior'
const filters = reactive({
course: 'math_a',
teacher: '',
courseName: '',
teacherName: '',
date: null
})
const imageFilter = ref('all')
const imageFilter = ref('')
//
const behaviorTypes = ref([])
const behaviors = ref([
{ name: '专注听讲', percent: 65, color: '#52c41a' },
{ name: '举手互动', percent: 15, color: '#1890ff' },
{ name: '低头书写', percent: 12, color: '#722ed1' },
{ name: '交谈讨论', percent: 5, color: '#faad14' },
{ name: '其他', percent: 3, color: '#bfbfbf' }
])
//
const behaviors = ref([])
const loading = ref(false)
const pieChartRef = ref(null)
const barChartRef = ref(null)
let pieChart = null
let barChart = null
//
const images = ref([
{ time: '08:15:32', type: '专注', checked: false },
{ time: '08:18:05', type: '举手', checked: false },
{ time: '08:22:41', type: '专注', checked: false },
{ time: '08:30:18', type: '低头', checked: false },
{ 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 imageRecords = ref([])
const pagination = reactive({
current: 1,
size: 10,
total: 0
})
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 previewVisible = ref(false)
const currentPreview = ref(null)
const getBehaviorColor = (type) => {
const map = { '专注': '#52c41a', '举手': '#1890ff', '低头': '#722ed1', '交谈': '#faad14' }
return map[type] || '#bfbfbf'
}
const getBehaviorTagType = (type) => {
const map = { '专注': 'success', '举手': '', '低头': 'info', '交谈': 'warning' }
return map[type] || 'info'
/** 根据行为类型 ID 获取对应颜色 */
const getTypeColor = (behaviorTypeId) => {
const bt = behaviorTypes.value.find(t => t.id === behaviorTypeId)
return bt ? bt.color : '#bfbfbf'
}
const getBehaviorIcon = (type) => {
const map = { '专注': 'View', '举手': 'Pointer', '低头': 'Reading', '交谈': 'ChatDotRound' }
return map[type] || 'QuestionFilled'
/** 计算单个行为占比 */
const behaviorPercent = (b) => {
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) => {
@ -175,21 +173,188 @@ const batchDownload = () => {
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 = () => {
//
if (pieChartRef.value) {
pieChart = echarts.init(pieChartRef.value)
pieChart.setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c}%' },
tooltip: { trigger: 'item' },
series: [{
type: 'pie',
radius: ['55%', '80%'],
radius: ['45%', '80%'],
center: ['50%', '50%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
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(() => {
initCharts()
fetchBehaviorTypes()
fetchData()
window.addEventListener('resize', () => {
pieChart?.resize()
barChart?.resize()
@ -268,18 +435,19 @@ onUnmounted(() => {
.legend-dot {
font-size: 12px;
color: #525252;
color: #ffffff;
display: flex;
align-items: center;
gap: 4px;
&::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: inherit;
}
padding: 2px 5px;
border-radius: 4px;
// &::before {
// content: '';
// width: 8px;
// height: 8px;
// border-radius: 50%;
// background: inherit;
// }
}
.image-section {
@ -330,6 +498,13 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
border-bottom: 3px solid #d9d9d9;
overflow: hidden;
.snapshot-img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.image-info {
@ -377,6 +552,13 @@ onUnmounted(() => {
gap: 8px;
color: #bfbfbf;
font-size: 14px;
overflow: hidden;
.preview-img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.preview-meta {

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

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

@ -57,7 +57,7 @@
<el-table :data="recentRecords" stripe>
<el-table-column prop="courseName" label="课程名称" min-width="150" />
<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 }">
<span v-html="row.time"></span>
</template>

@ -15,244 +15,196 @@
size="default"
style="width: 200px"
/>
<el-date-picker v-model="query.date" type="date" placeholder="选择日期" size="default" />
<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-date-picker v-model="query.date" type="date" placeholder="选择日期" size="default" value-format="YYYY-MM-DD" />
<el-select v-model="query.behaviorType" placeholder="行为类型" clearable size="default" style="width: 140px">
<el-option label="全部" value="" />
<el-option label="专注" value="focus" />
<el-option label="举手" value="hand" />
<el-option label="低头" value="down" />
<el-option label="交谈" value="talk" />
<el-option
v-for="bt in behaviorTypes"
:key="bt.id"
:label="bt.typeName"
:value="bt.id"
/>
</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>
</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="section-header">
<h3>查询结果</h3>
<span class="result-count"> {{ filteredImages.length }} 条记录</span>
<span class="result-count"> {{ pagination.total }} 条记录</span>
</div>
<div v-loading="loading" class="image-masonry">
<div
v-for="(img, index) in filteredImages"
:key="index"
v-for="record in records"
:key="record.id"
class="masonry-item"
@click="viewOriginal(img)"
@click="viewDetail(record)"
>
<div class="masonry-thumb">
<div class="thumb-placeholder" :style="{ borderColor: getBorderColor(img.type) }">
<el-icon :size="28" :color="getBorderColor(img.type)">
<PictureFilled />
</el-icon>
</div>
<div class="thumb-badge">
<el-tag :type="getTagType(img.type)" size="small" effect="dark">{{ img.type }}</el-tag>
<div class="thumb-placeholder" :style="{ borderColor: getBorderColor(record.attendanceRate) }">
<div class="attendance-stat">
<span class="attendance-rate">{{ record.attendanceRate }}%</span>
<span class="attendance-label">出勤率</span>
</div>
</div>
</div>
<div class="masonry-info">
<span class="masonry-time">
<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 class="masonry-course">{{ img.course }}</span>
</div>
</div>
</div>
<el-pagination
v-model:current-page="pagination.current"
:total="filteredImages.length"
:page-size="pagination.pageSize"
small
background
layout="prev, pager, next"
style="justify-content: center; margin-top: 16px"
/>
<div v-if="records.length" class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.current"
:total="pagination.total"
:page-size="pagination.size"
small
background
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>
<!-- 原图预览 -->
<el-dialog v-model="previewVisible" title="原图预览" width="700px" destroy-on-close>
<div class="original-preview">
<div class="preview-image-area">
<el-icon :size="80" color="#d9d9d9"><PictureFilled /></el-icon>
</div>
<div class="preview-detail">
<span>{{ currentImage?.time }}</span>
<el-divider direction="vertical" />
<el-tag :type="getTagType(currentImage?.type)" size="small">{{ currentImage?.type }}</el-tag>
<el-divider direction="vertical" />
<span>{{ currentImage?.course }}</span>
</div>
</div>
</el-dialog>
<!-- 全屏连续播放 -->
<el-dialog v-model="fullscreenMode" title="连续播放" fullscreen destroy-on-close>
<div class="slideshow-container">
<div class="slideshow-image">
<el-icon :size="100" color="#d9d9d9"><VideoCamera /></el-icon>
<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>
<!-- 详情弹窗 -->
<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, computed } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { getAttendanceHistory } from '@/api/history'
import { getBehaviorTypes } from '@/api/behavior'
const loading = ref(false)
const playing = ref(false)
const previewVisible = ref(false)
const fullscreenMode = ref(false)
const currentImage = ref(null)
const timelinePosition = ref(50)
const detailVisible = ref(false)
const currentRecord = ref(null)
const behaviorTypes = ref([])
const query = reactive({
courseName: '',
date: null,
startTime: '',
endTime: '',
behaviorType: ''
})
const pagination = reactive({
current: 1,
pageSize: 20
size: 20,
total: 0
})
const images = 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 records = ref([])
const getBorderColor = (type) => {
const map = { '专注': '#52c41a', '举手': '#1890ff', '低头': '#722ed1', '交谈': '#faad14' }
return map[type] || '#d9d9d9'
}
const getTagType = (type) => {
const map = { '专注': 'success', '举手': '', '低头': 'info', '交谈': 'warning' }
return map[type] || 'info'
}
const viewOriginal = (img) => {
currentImage.value = img
previewVisible.value = true
/** 获取行为类型列表 */
const fetchBehaviorTypes = async () => {
try {
const res = await getBehaviorTypes()
if (res && res.data) {
behaviorTypes.value = res.data
}
} catch { /* 错误已在拦截器统一处理 */ }
}
const doSearch = () => {
/** 查询 */
const doSearch = async () => {
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 = () => {
query.courseName = ''
query.date = null
query.startTime = ''
query.endTime = ''
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>
.timeline-toolbar {
background: #ffffff;
border-radius: 8px;
padding: 12px 20px;
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;
/** 根据出勤率获取 tag 类型 */
const getRateType = (rate) => {
if (rate >= 95) return 'success'
if (rate >= 85) return 'warning'
return 'danger'
}
.timeline-slider {
flex: 1;
display: flex;
align-items: center;
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;
}
/** 查看详情 */
const viewDetail = (record) => {
currentRecord.value = record
detailVisible.value = true
}
onMounted(() => {
fetchBehaviorTypes()
doSearch()
})
</script>
<style lang="scss" scoped>
.result-section {
background: #ffffff;
border-radius: 8px;
@ -311,10 +263,21 @@ const toggleSlideshow = () => {
border-bottom: 3px solid #d9d9d9;
}
.thumb-badge {
position: absolute;
top: 6px;
left: 6px;
.attendance-stat {
text-align: center;
}
.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 {
@ -333,65 +296,28 @@ const toggleSlideshow = () => {
font-family: monospace;
}
.masonry-course {
.masonry-stats {
font-size: 12px;
color: #bfbfbf;
}
.original-preview {
text-align: center;
}
.preview-image-area {
height: 360px;
background: #f5f7fa;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.preview-detail {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 13px;
color: #525252;
gap: 4px;
}
.slideshow-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 70vh;
.masonry-absent {
font-size: 12px;
color: #ff4d4f;
}
.slideshow-image {
width: 80%;
max-width: 900px;
height: 500px;
background: #1a1a2e;
border-radius: 12px;
.pagination-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
color: #525252;
margin-bottom: 24px;
margin-top: 16px;
}
.slideshow-hint {
font-size: 16px;
color: #d9d9d9;
}
.slideshow-controls {
display: flex;
align-items: center;
gap: 24px;
.detail-container {
.el-descriptions {
margin-top: 8px;
}
}
</style>

Loading…
Cancel
Save