feat(student): 新增学生词汇掌握详情及热力图展示功能
- 新增FindStudentMasteryDetailReqVO和FindStudentMasteryDetailRspVO数据类 - 学生接口新增/ student/mastery/detail,用于查询学生词汇掌握详情 - StudentService及实现类添加查询词汇掌握详情的方法 - WordMasteryLogDOMapper新增selectAllByStudentId方法支持查询 - SaTokenConfigure增加对新接口的免认证配置 - 前端api新增getStudentWordMastery方法 - 学生页面新增WordMasteryHeatmap组件并展示词汇掌握热力图 - 创建WordMasteryHeatmap组件,支持动态请求数据及Echarts热力图渲染 - 热力图按记忆强度排序,提供丰富的鼠标悬停提示信息
This commit is contained in:
@@ -31,3 +31,9 @@ export function getStudentStudyAnalyze(data) {
|
||||
timeout: 20000
|
||||
})
|
||||
}
|
||||
|
||||
export function getStudentWordMastery(id) {
|
||||
return axios.post('/student/mastery/detail', {
|
||||
studentId: id
|
||||
})
|
||||
}
|
||||
|
||||
215
enlish-vue/src/layouts/components/student/WordMasteryHeatmap.vue
Normal file
215
enlish-vue/src/layouts/components/student/WordMasteryHeatmap.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<template v-if="rows.length > 0">
|
||||
<div :style="{ width: '100%', height: containerHeight + 'px' }">
|
||||
<div ref="elRef" style="width: 100%; height: 100%;"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-empty description="暂无词汇数据或接口未返回" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||
import { getStudentWordMastery } from '@/api/student'
|
||||
|
||||
const props = defineProps({
|
||||
studentId: { type: [Number, String], required: true },
|
||||
columns: { type: Number, default: 50 }
|
||||
})
|
||||
|
||||
const elRef = ref(null)
|
||||
let chart = null
|
||||
let echartsLib = null
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const containerHeight = computed(() => {
|
||||
const count = rows.value.length
|
||||
const cols = Math.max(1, Number(props.columns) || 50)
|
||||
const r = Math.ceil(count / cols)
|
||||
const cell = 12
|
||||
const padding = 60
|
||||
return Math.max(180, r * cell + padding)
|
||||
})
|
||||
|
||||
function toGrid(data, columns) {
|
||||
const arr = Array.isArray(data) ? data.slice() : []
|
||||
arr.sort((a, b) => {
|
||||
const av = Number(a.memoryStrength) || 0
|
||||
const bv = Number(b.memoryStrength) || 0
|
||||
// 高分在前,让上方更绿
|
||||
return bv - av
|
||||
})
|
||||
const grid = []
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const row = Math.floor(i / columns)
|
||||
const col = i % columns
|
||||
const it = arr[i]
|
||||
grid.push([col, row, Number(it.memoryStrength) || 0, it.word, Number(it.reviewCount) || 0, it.updateTime || ''])
|
||||
}
|
||||
return grid
|
||||
}
|
||||
|
||||
function getMinMax(data) {
|
||||
let min = Infinity
|
||||
let max = -Infinity
|
||||
for (const it of data) {
|
||||
const v = Number(it.memoryStrength) || 0
|
||||
if (v < min) min = v
|
||||
if (v > max) max = v
|
||||
}
|
||||
if (!isFinite(min)) min = 0
|
||||
if (!isFinite(max)) max = 0
|
||||
const absMax = Math.max(Math.abs(min), Math.abs(max), 1)
|
||||
return { min: -absMax, max: absMax }
|
||||
}
|
||||
|
||||
function buildOption(gridData, min, max, columns) {
|
||||
const rowCount = gridData.reduce((acc, cur) => Math.max(acc, cur[1]), 0) + 1
|
||||
const xCats = Array.from({ length: columns }, (_, i) => String(i + 1))
|
||||
const yCats = Array.from({ length: rowCount }, (_, i) => String(i + 1))
|
||||
return {
|
||||
tooltip: {
|
||||
formatter: (p) => {
|
||||
const [x, y, val, word, review, time] = p.data || []
|
||||
return [
|
||||
`词汇:${word || ''}`,
|
||||
`记忆强度:${val}`,
|
||||
`复习次数:${review}`,
|
||||
time ? `更新时间:${time}` : ''
|
||||
].filter(Boolean).join('<br/>')
|
||||
}
|
||||
},
|
||||
grid: { left: 20, right: 20, top: 20, bottom: 80 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: xCats,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { show: false }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: yCats,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { show: false }
|
||||
},
|
||||
visualMap: {
|
||||
min,
|
||||
max,
|
||||
dimension: 2,
|
||||
orient: 'horizontal',
|
||||
left: 'center',
|
||||
bottom: 0,
|
||||
text: ['熟悉', '生疏'],
|
||||
inRange: {
|
||||
color: ['#d64f4f', '#f59e0b', '#c0c0c0', '#86efac', '#22c55e']
|
||||
},
|
||||
calculable: true
|
||||
},
|
||||
series: [{
|
||||
name: '掌握度',
|
||||
type: 'heatmap',
|
||||
data: gridData,
|
||||
encode: { x: 0, y: 1, value: 2 },
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
borderColor: '#111827',
|
||||
borderWidth: 1.2,
|
||||
shadowBlur: 6,
|
||||
shadowColor: 'rgba(0,0,0,0.15)'
|
||||
}
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: '#e5e7eb',
|
||||
borderWidth: 1,
|
||||
opacity: 0.95
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
function resize() {
|
||||
if (chart) chart.resize()
|
||||
}
|
||||
|
||||
async function ensureEcharts() {
|
||||
try {
|
||||
const mod = await import('echarts')
|
||||
echartsLib = mod
|
||||
} catch (e) {
|
||||
if (window.echarts) {
|
||||
echartsLib = window.echarts
|
||||
} else {
|
||||
await new Promise(resolve => {
|
||||
const s = document.createElement('script')
|
||||
s.src = 'https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js'
|
||||
s.onload = resolve
|
||||
document.head.appendChild(s)
|
||||
})
|
||||
echartsLib = window.echarts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function render() {
|
||||
if (!elRef.value) return
|
||||
await ensureEcharts()
|
||||
if (!echartsLib) return
|
||||
if (!chart) {
|
||||
chart = echartsLib.init(elRef.value)
|
||||
window.addEventListener('resize', resize)
|
||||
}
|
||||
const cols = Math.max(1, Number(props.columns) || 50)
|
||||
const gridData = toGrid(rows.value, cols)
|
||||
const { min, max } = getMinMax(rows.value)
|
||||
const option = buildOption(gridData, min, max, cols)
|
||||
chart.setOption(option)
|
||||
}
|
||||
|
||||
async function fetch() {
|
||||
if (props.studentId === undefined || props.studentId === null) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getStudentWordMastery(Number(props.studentId))
|
||||
const d = res?.data?.data ?? []
|
||||
rows.value = Array.isArray(d) ? d : []
|
||||
} catch (e) {
|
||||
error.value = e?.message || '加载失败'
|
||||
rows.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetch()
|
||||
await render()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', resize)
|
||||
if (chart) {
|
||||
chart.dispose()
|
||||
chart = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.studentId, async () => {
|
||||
await fetch()
|
||||
await render()
|
||||
})
|
||||
|
||||
watch(rows, async () => {
|
||||
if (rows.value.length > 0) {
|
||||
await render()
|
||||
} else {
|
||||
if (chart) chart.clear()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -32,6 +32,10 @@
|
||||
<div class="text-md font-semibold mb-3">学生学案记录</div>
|
||||
<PlanHistoryChart :student-id="route.params.id" />
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div class="text-md font-semibold mb-3">词汇掌握热力图</div>
|
||||
<WordMasteryHeatmap :student-id="route.params.id" :columns="50" />
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="text-md font-semibold">学习分析</div>
|
||||
@@ -61,6 +65,7 @@ import { getStudentDetail, getStudentStudyAnalyze } from '@/api/student'
|
||||
import { getStudentExamHistory } from '@/api/exam'
|
||||
import ExamHistoryChart from '@/layouts/components/student/ExamHistoryChart.vue'
|
||||
import PlanHistoryChart from '@/layouts/components/student/PlanHistoryChart.vue'
|
||||
import WordMasteryHeatmap from '@/layouts/components/student/WordMasteryHeatmap.vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
Reference in New Issue
Block a user