feat(student): 新增学生词汇掌握详情及热力图展示功能

- 新增FindStudentMasteryDetailReqVO和FindStudentMasteryDetailRspVO数据类
- 学生接口新增/ student/mastery/detail,用于查询学生词汇掌握详情
- StudentService及实现类添加查询词汇掌握详情的方法
- WordMasteryLogDOMapper新增selectAllByStudentId方法支持查询
- SaTokenConfigure增加对新接口的免认证配置
- 前端api新增getStudentWordMastery方法
- 学生页面新增WordMasteryHeatmap组件并展示词汇掌握热力图
- 创建WordMasteryHeatmap组件,支持动态请求数据及Echarts热力图渲染
- 热力图按记忆强度排序,提供丰富的鼠标悬停提示信息
This commit is contained in:
lbw
2025-12-24 16:26:22 +08:00
parent 15e909c318
commit aff862d161
11 changed files with 311 additions and 0 deletions

View File

@@ -34,6 +34,7 @@ public class SaTokenConfigure implements WebMvcConfigurer {
.notMatch("/studentLessonPlans/list")
.notMatch("/studentLessonPlans/history")
.notMatch("/student/analyze")
.notMatch("/student/mastery/detail")
.notMatch("/unit/list")
.notMatch("/vocabulary/list")
.notMatch("/plan/download")

View File

@@ -4,6 +4,8 @@ package com.yinlihupo.enlish.service.controller;
import com.yinlihupo.enlish.service.domain.dataobject.ClassDO;
import com.yinlihupo.enlish.service.domain.dataobject.GradeDO;
import com.yinlihupo.enlish.service.domain.dataobject.StudentDO;
import com.yinlihupo.enlish.service.model.bo.StudentDetail;
import com.yinlihupo.enlish.service.model.bo.exam.WordMasteryDetail;
import com.yinlihupo.enlish.service.model.vo.student.*;
import com.yinlihupo.enlish.service.service.ClassService;
import com.yinlihupo.enlish.service.service.GradeService;
@@ -92,4 +94,21 @@ public class StudentController {
String analyzeStudentStudy = studentService.analyzeStudentStudy(analyzeStudentStudyReqVO.getStudentId());
return Response.success(analyzeStudentStudy);
}
@PostMapping("mastery/detail")
@ApiOperationLog(description = "查询学生单词掌握详情")
public Response<List<FindStudentMasteryDetailRspVO>> findStudentMasteryDetail(@RequestBody FindStudentMasteryDetailReqVO findStudentMasteryDetailReqVO) {
Integer studentId = findStudentMasteryDetailReqVO.getStudentId();
List<WordMasteryDetail> studentWordMasteryDetail = studentService.findStudentWordMasteryDetail(studentId);
List<FindStudentMasteryDetailRspVO> list = studentWordMasteryDetail.stream().map(wordMasteryDetail -> FindStudentMasteryDetailRspVO.builder()
.word(wordMasteryDetail.getWord())
.reviewCount(wordMasteryDetail.getReviewCount())
.memoryStrength(wordMasteryDetail.getMemoryStrength())
.updateTime(wordMasteryDetail.getUpdate_time())
.build()
).toList();
return Response.success(list);
}
}

View File

@@ -18,4 +18,6 @@ public interface WordMasteryLogDOMapper {
int selectStudentStrengthCount(@Param("studentId") Integer studentId);
List<WordMasteryLogDO> selectByStudentIdAndLimitTime(@Param("studentId") Integer studentId);
List<WordMasteryLogDO> selectAllByStudentId(@Param("studentId") Integer studentId);
}

View File

@@ -0,0 +1,15 @@
package com.yinlihupo.enlish.service.model.vo.student;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class FindStudentMasteryDetailReqVO {
private Integer studentId;
}

View File

@@ -0,0 +1,22 @@
package com.yinlihupo.enlish.service.model.vo.student;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@AllArgsConstructor
@NoArgsConstructor
@Data
@Builder
public class FindStudentMasteryDetailRspVO {
private String word;
private Integer reviewCount;
private Double memoryStrength;
private LocalDateTime updateTime;
}

View File

@@ -3,6 +3,7 @@ package com.yinlihupo.enlish.service.service;
import com.yinlihupo.enlish.service.domain.dataobject.StudentDO;
import com.yinlihupo.enlish.service.model.bo.StudentDetail;
import com.yinlihupo.enlish.service.model.bo.exam.WordMasteryDetail;
import com.yinlihupo.enlish.service.model.vo.student.AddStudentReqVO;
import java.util.List;
@@ -22,4 +23,6 @@ public interface StudentService {
void deleteStudent(Integer studentId);
String analyzeStudentStudy(Integer studentId);
List<WordMasteryDetail> findStudentWordMasteryDetail(Integer studentId);
}

View File

@@ -167,4 +167,22 @@ public class StudentServiceImpl implements StudentService {
}
}
@Override
public List<WordMasteryDetail> findStudentWordMasteryDetail(Integer studentId) {
List<WordMasteryLogDO> wordMasteryLogDOS = wordMasteryLogDOMapper.selectAllByStudentId(studentId);
List<VocabularyBankDO> masteredWords = vocabularyBankMapper.selectVocabularyBankDOListByIds(wordMasteryLogDOS.stream().map(WordMasteryLogDO::getWordId).toList());
Map<Integer, VocabularyBankDO> id2MasteryWord = masteredWords.stream().collect(Collectors.toMap(VocabularyBankDO::getId, vocabularyBankDO -> vocabularyBankDO));
List<WordMasteryDetail> wordMasteryDetails = new ArrayList<>();
for (WordMasteryLogDO wordMasteryLogDO : wordMasteryLogDOS) {
wordMasteryDetails.add(WordMasteryDetail.builder()
.word(id2MasteryWord.get(wordMasteryLogDO.getWordId()).getWord())
.reviewCount(wordMasteryLogDO.getReviewCount())
.memoryStrength(wordMasteryLogDO.getMemoryStrength())
.update_time(wordMasteryLogDO.getUpdate_time())
.build());
}
return wordMasteryDetails;
}
}

View File

@@ -58,4 +58,9 @@
where student_id = #{studentId}
and update_time between date_sub(now(), interval 7 day) and now()
</select>
<select id="selectAllByStudentId" resultMap="BaseResultMap">
select *
from word_mastery_log
where student_id = #{studentId}
</select>
</mapper>

View File

@@ -31,3 +31,9 @@ export function getStudentStudyAnalyze(data) {
timeout: 20000
})
}
export function getStudentWordMastery(id) {
return axios.post('/student/mastery/detail', {
studentId: id
})
}

View 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>

View File

@@ -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)