From 7ff83d303c6b4cff16c8cbc796b765baeaa23fd3 Mon Sep 17 00:00:00 2001 From: zhoulexin Date: Wed, 3 Jun 2026 17:26:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E8=AF=BE=E5=A0=82=E8=A1=8C=E4=B8=BA?= =?UTF-8?q?=E5=88=86=E6=9E=90=E3=80=81=E5=8E=86=E5=8F=B2=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E6=8E=A5=E5=8F=A3=E5=AF=B9=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/behavior.js | 21 + src/api/history.js | 6 + src/views/behavior/index.vue | 346 ++++++++++++---- .../dashboard/components/AttendanceDetail.vue | 9 +- .../dashboard/components/AttendanceManage.vue | 4 +- src/views/dashboard/index.vue | 2 +- src/views/history/index.vue | 374 +++++++----------- 7 files changed, 451 insertions(+), 311 deletions(-) create mode 100644 src/api/behavior.js create mode 100644 src/api/history.js diff --git a/src/api/behavior.js b/src/api/behavior.js new file mode 100644 index 0000000..e69da7d --- /dev/null +++ b/src/api/behavior.js @@ -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') +} diff --git a/src/api/history.js b/src/api/history.js new file mode 100644 index 0000000..899dffb --- /dev/null +++ b/src/api/history.js @@ -0,0 +1,6 @@ +import request from '@/utils/request' + +/** 获取考勤历史记录(分页) */ +export const getAttendanceHistory = (params) => { + return request.get('/attendance/history', { params }) +} diff --git a/src/views/behavior/index.vue b/src/views/behavior/index.vue index 0407098..c65831d 100644 --- a/src/views/behavior/index.vue +++ b/src/views/behavior/index.vue @@ -7,18 +7,11 @@
- - - - - - - - - - - - 查询 + + + + 查询 + 重置
@@ -32,7 +25,7 @@
- {{ b.name }} {{ b.percent }}% + {{ b.name }} {{ behaviorPercent(b) }}%
@@ -50,39 +43,52 @@

行为标记图片

- - 全部 - 专注 - 举手 - 低头 - 交谈 + + 全部 + {{ bt.typeName }}
-
+
-
- - +
+ + +
- {{ img.time }} - {{ img.type }} + {{ img.behaviorTime }} + + {{ img.behaviorTypeName }} +
- -
- + + +
+ 批量下载 ({{ checkedCount }}张) @@ -93,9 +99,12 @@
- -

图片预览区域

- {{ currentPreview?.time }} | {{ currentPreview?.type }} + + + {{ currentPreview?.behaviorTime }} | {{ currentPreview?.behaviorTypeName }}
@@ -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 { diff --git a/src/views/dashboard/components/AttendanceDetail.vue b/src/views/dashboard/components/AttendanceDetail.vue index fb58996..5994fc5 100644 --- a/src/views/dashboard/components/AttendanceDetail.vue +++ b/src/views/dashboard/components/AttendanceDetail.vue @@ -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()) } }) diff --git a/src/views/dashboard/components/AttendanceManage.vue b/src/views/dashboard/components/AttendanceManage.vue index d42a678..5a0604e 100644 --- a/src/views/dashboard/components/AttendanceManage.vue +++ b/src/views/dashboard/components/AttendanceManage.vue @@ -45,7 +45,7 @@ - + @@ -100,7 +100,7 @@ const mapRecord = (item) => ({ courseName: item.courseName, teacher: item.teacherName, classroom: item.classroomName, - time: `${item.startTime || ''}

${item.endTime || ''}`, + time: `${item.startTime || ''} 至 ${item.endTime || ''}`, total: item.totalCount, actual: item.actualCount, absentCount: item.absentCount, diff --git a/src/views/dashboard/index.vue b/src/views/dashboard/index.vue index 6be552c..0f2d385 100644 --- a/src/views/dashboard/index.vue +++ b/src/views/dashboard/index.vue @@ -57,7 +57,7 @@ - + diff --git a/src/views/history/index.vue b/src/views/history/index.vue index 92daea5..b94a1cf 100644 --- a/src/views/history/index.vue +++ b/src/views/history/index.vue @@ -15,244 +15,196 @@ size="default" style="width: 200px" /> - - - + - - - - + - 查询 + 查询 重置
- -
-
- 时间轴 - - {{ currentTimeline }} -
- - {{ playing ? '暂停' : '连续播放' }} - 全屏 - -
- - +

查询结果

- 共 {{ filteredImages.length }} 条记录 + 共 {{ pagination.total }} 条记录
-
- - - -
-
- {{ img.type }} +
+
+ {{ record.attendanceRate }}% + 出勤率 +
- {{ img.time }} + {{ record.attDate }} + +
+ 应到 {{ record.totalCount }} + + 实到 {{ record.actualCount }} +
+ + 缺勤 {{ record.absentCount }} 人 - {{ img.course }}
- +
+ +
- +
- - -
-
- -
-
- {{ currentImage?.time }} - - {{ currentImage?.type }} - - {{ currentImage?.course }} -
-
-
- - - -
-
- -

连续播放模式 - 图片序列展示区域

-
-
- - 上一张 - - - {{ playing ? '暂停' : '播放' }} - - 下一张 - - -
+ + +
+ + {{ currentRecord.attDate }} + {{ currentRecord.courseId }} + {{ currentRecord.startTime }} + {{ currentRecord.endTime }} + {{ currentRecord.totalCount }} + {{ currentRecord.actualCount }} + + {{ currentRecord.absentCount }} + + {{ currentRecord.lateCount }} + {{ currentRecord.leaveEarlyCount }} + + + {{ currentRecord.attendanceRate }}% + + +
-