feat(student): 新增学生词汇掌握详情及热力图展示功能
- 新增FindStudentMasteryDetailReqVO和FindStudentMasteryDetailRspVO数据类 - 学生接口新增/ student/mastery/detail,用于查询学生词汇掌握详情 - StudentService及实现类添加查询词汇掌握详情的方法 - WordMasteryLogDOMapper新增selectAllByStudentId方法支持查询 - SaTokenConfigure增加对新接口的免认证配置 - 前端api新增getStudentWordMastery方法 - 学生页面新增WordMasteryHeatmap组件并展示词汇掌握热力图 - 创建WordMasteryHeatmap组件,支持动态请求数据及Echarts热力图渲染 - 热力图按记忆强度排序,提供丰富的鼠标悬停提示信息
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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