系列目录计科智伴——基于 Spring Boot uni-app 的 AI 个性化学习平台开发实录上一篇把错题诊断 Agent、AI 学习计划生成、前端 SSE 打字机都做完了。本期核心任务两件建出学情画报页面让用户直观看到学情分析结果改造 ReportController让报告接口真正支持有意义的周期对比。两个 commit改了约 20 个文件新增有效代码约 1,400 行。一、问题背景数据有了但用户看不见前五周做的是把数据采集和 AI 分析能力建起来但采集完之后那些数据存在哪、用户怎么看到一直没做。具体来说有三个缺口UserProfile 里的knowledgeMastery存了每个知识点的掌握度0~1 的浮点数weakPoints存了薄弱知识点列表——这两个字段是诊断 Agent 回写进去的但没有页面展示它们。/api/report接口虽然存在但只返回本周答题数、正确数、学习时长三个字段没有上期对比没有知识点维度没有 AI 分析前端报告页渲染出来内容基本是空的。另外重新生成学习计划的入口也缺失用户画像更新后只能靠问卷重来。本周先解决可见性问题——数据展示出来再谈正确性问题。二、学情画报页面profile-report.vue2.1 三区块结构页面从上到下分三个区块。画像摘要卡片展示学习目标备战考试/准备面试/考研复习/系统提升、目标分数仅备战考试时有效、在学课程列表、日均学习时长从 timePref 解析。数据来源是GET /users/profile一次拿完所有字段。知识掌握热力图从GET /api/home/heatmap拉[{knowledgeName, score, category}]用 uCharts 渲染柱状图颜色按三档映射——mastered≥ 0.8绿色、learning0.5~0.8蓝色、weak 0.5红色。首次进入无数据时展示空状态卡不渲染空坐标系。AI 学情诊断报告点击生成 AI 分析按钮触发同步接口等待期间展示骨架屏结果回来后展示约 300 字的分析文案。2.2 timePref 解析的边界处理后端 UserProfile 存的是08:00-09:00,20:00-21:00这样的字符串前端摘要卡要展示日均可学习 X 分钟。原本想让后端直接返回分钟数但 timePref 是核心字段改格式会影响其他逻辑比如逾期任务顺延时读 timePref 计算 maxPerDay所以前端自己解析function parseStudyMinutes(timePref) { if (!timePref) return 0 return timePref.split(,).reduce((total, seg) { const [start, end] seg.trim().split(-) if (!start || !end) return total const [sh, sm] start.split(:).map(Number) const [eh, em] end.split(:).map(Number) return total (eh * 60 em) - (sh * 60 sm) }, 0) }边界情况包括逗号前后有空格、只有一个时段、时段格式不完整。if (!start || !end) return total做空值守卫格式异常时静默跳过而不是整个函数报错。2.3 空数据的设计决策首次进入的用户 heatmapData 是空数组uCharts 渲染空数据会出一个只有坐标轴没有柱子的空图看起来像 bug。改成v-ifheatmapData.length 0时展示空状态文案v-else时才渲染图表组件。这个宁可不显示也不显示空图的原则后来在知识点掌握度详情页kp-mastery.vue里也复用了。三、学情报告接口重构ReportController3.1 从三个数字到完整报告原来的/api/report返回内容大概就是totalQuestions、correctCount、studyMinutes三个字段。改造后的接口仍然是GET /api/report?periodweek|month|semester返回五块内容overview本期概览、weeklyComparison上期对比、subjectPerformance按课程错题分布、knowledgeMastery知识掌握度、aiSuggestions规则建议。前两块是本次重点。3.2 weeklyComparison时间窗口的边界细节这是本次改动里最容易写错的一处。核心逻辑本期是[now - days, now]上期是[now - 2*days, now - days]两个窗口严格相邻、不重叠LocalDateTime since LocalDateTime.now().minusDays(days); // 本期起点 // 上期终点 本期起点起点 本期起点再往前 days 天 LocalDateTime prevEnd since; LocalDateTime prevStart prevEnd.minusDays(days); ListUserQuestionRecord prevQrs userQuestionRecordMapper.selectList( new LambdaQueryWrapperUserQuestionRecord() .eq(UserQuestionRecord::getUserId, userId) .ge(UserQuestionRecord::getSubmitTime, prevStart) .lt(UserQuestionRecord::getSubmitTime, prevEnd)); // 严格小于第一版把prevEnd写成了LocalDateTime.now()上期和本期完全重叠对比数据永远是 0 变化。调试时在返回值里临时加了debugPrevStart和debugPrevEnd字段才发现窗口对不上。3.3 subjectPerformance三级关联聚合错题到课程需要三级跳WrongQuestion → Question通过 qId→ KnowledgePoint通过 kpId→ Course通过 courseId。项目里基本没写 XML Mapper 全用 LambdaQueryWrapper三表 join 要写原生 SQL。考虑到一个用户在一个周期内通常不超过 100 条错题选择在 Java 里循环聚合MapLong, long[] courseStats new LinkedHashMap(); // courseId → [total, resolved] MapLong, String courseNameCache new HashMap(); for (WrongQuestion wq : wrongList) { if (wq.getQId() null) continue; Question q questionMapper.selectById(wq.getQId()); if (q null || q.getKpId() null) continue; KnowledgePoint kp knowledgePointMapper.selectById(q.getKpId()); if (kp null || kp.getCourseId() null) continue; Long cid kp.getCourseId(); courseStats.computeIfAbsent(cid, k - new long[]{0, 0}); courseStats.get(cid)[0]; if (Boolean.TRUE.equals(wq.getIsResolved())) courseStats.get(cid)[1]; courseNameCache.computeIfAbsent(cid, k - { Course c courseService.getById(k); return c ! null ? c.getCourseName() : 课程 k; }); }N1 问题确实存在但单期错题量小代价可接受后期量上来再批量优化。本期无错题时用UserProfile.currentCourses展示0 错题空行让前端有东西可渲染。四、同步 AI 建议生成4.1 接口设计新增POST /api/report/ai-suggest前端在学情画报页点击按钮时调用同步等待大模型返回。提示词注入本期答题数与正确率、用户学习目标与目标分数、最近 3 个薄弱知识点名称让模型产出 200~300 字的分析。4.2 降级兜底模型超时或调用失败时不能一直 loading改成规则降级try { suggestion chatClient.prompt().user(prompt).call().content(); } catch (Exception e) { log.warn(AI 建议生成失败降级为规则建议: {}, e.getMessage()); suggestion buildRuleSuggestion(totalQuestions, accuracy, unresolvedMistakes); } private String buildRuleSuggestion(int total, double acc, long mistakes) { if (total 0) return 本期还没有答题记录先从一个知识点开始练习吧。; if (acc 0.6) return 正确率偏低建议先回到课本巩固基础概念不要急于追求题量。; if (mistakes 5) return 错题本里还有 mistakes 道未消化建议优先把错题过一遍。; if (acc 0.9) return 正确率很高可以尝试更难的题目或扩展到新知识点。; return 学习情况稳定保持节奏持续巩固薄弱知识点。; }覆盖没做题/正确率低/错题多/正确率高/正常五种情况确保按钮点下去永远有反馈。五、踩坑复盘坑一AnswerDTO 字段名反序列化失败。前端发的是questionIdDTO 里字段名叫qIdJackson 默认按字段名匹配导致永远是 null。选择直接改字段名为questionId并全局替换而不是加JsonProperty注解打补丁。坑二weeklyComparison 时间窗口重叠。上面 3.2 节已详述根因是prevEnd用了now而不是since。坑三UserProfile 快照导致 PUT 后 GET 返回旧值。UserHolder 里存的是登录时的 ThreadLocal 快照对象数据库改了但内存里的对象没变。修复为 GET 接口按 userId 重查数据库UserHolder 只取 userId 用于鉴权GetMapping(/profile) public Result getProfile() { Long userId UserHolder.getUser().getUserId(); UserProfileDTO profile userProfileService.getUserProfile(userId); return Result.ok(profile); }六、本期小结指标数据profile-report.vue 行数776 行ReportController 新增/改写方法4 个weeklyComparison 覆盖周期week / month / semester7/30/90 天AI 建议降级规则5 条踩坑修复3 处下一步重点knowledgeGrowth 目前始终返回空数组需要按日期×知识点做真实聚合AI 建议的同步接口耗时约 3 秒后续评估改异步轮询减少等待感。
计科智伴开发日志(七)|学情画报从零到 776 行、学情报告接口重构与 AI 建议落地
系列目录计科智伴——基于 Spring Boot uni-app 的 AI 个性化学习平台开发实录上一篇把错题诊断 Agent、AI 学习计划生成、前端 SSE 打字机都做完了。本期核心任务两件建出学情画报页面让用户直观看到学情分析结果改造 ReportController让报告接口真正支持有意义的周期对比。两个 commit改了约 20 个文件新增有效代码约 1,400 行。一、问题背景数据有了但用户看不见前五周做的是把数据采集和 AI 分析能力建起来但采集完之后那些数据存在哪、用户怎么看到一直没做。具体来说有三个缺口UserProfile 里的knowledgeMastery存了每个知识点的掌握度0~1 的浮点数weakPoints存了薄弱知识点列表——这两个字段是诊断 Agent 回写进去的但没有页面展示它们。/api/report接口虽然存在但只返回本周答题数、正确数、学习时长三个字段没有上期对比没有知识点维度没有 AI 分析前端报告页渲染出来内容基本是空的。另外重新生成学习计划的入口也缺失用户画像更新后只能靠问卷重来。本周先解决可见性问题——数据展示出来再谈正确性问题。二、学情画报页面profile-report.vue2.1 三区块结构页面从上到下分三个区块。画像摘要卡片展示学习目标备战考试/准备面试/考研复习/系统提升、目标分数仅备战考试时有效、在学课程列表、日均学习时长从 timePref 解析。数据来源是GET /users/profile一次拿完所有字段。知识掌握热力图从GET /api/home/heatmap拉[{knowledgeName, score, category}]用 uCharts 渲染柱状图颜色按三档映射——mastered≥ 0.8绿色、learning0.5~0.8蓝色、weak 0.5红色。首次进入无数据时展示空状态卡不渲染空坐标系。AI 学情诊断报告点击生成 AI 分析按钮触发同步接口等待期间展示骨架屏结果回来后展示约 300 字的分析文案。2.2 timePref 解析的边界处理后端 UserProfile 存的是08:00-09:00,20:00-21:00这样的字符串前端摘要卡要展示日均可学习 X 分钟。原本想让后端直接返回分钟数但 timePref 是核心字段改格式会影响其他逻辑比如逾期任务顺延时读 timePref 计算 maxPerDay所以前端自己解析function parseStudyMinutes(timePref) { if (!timePref) return 0 return timePref.split(,).reduce((total, seg) { const [start, end] seg.trim().split(-) if (!start || !end) return total const [sh, sm] start.split(:).map(Number) const [eh, em] end.split(:).map(Number) return total (eh * 60 em) - (sh * 60 sm) }, 0) }边界情况包括逗号前后有空格、只有一个时段、时段格式不完整。if (!start || !end) return total做空值守卫格式异常时静默跳过而不是整个函数报错。2.3 空数据的设计决策首次进入的用户 heatmapData 是空数组uCharts 渲染空数据会出一个只有坐标轴没有柱子的空图看起来像 bug。改成v-ifheatmapData.length 0时展示空状态文案v-else时才渲染图表组件。这个宁可不显示也不显示空图的原则后来在知识点掌握度详情页kp-mastery.vue里也复用了。三、学情报告接口重构ReportController3.1 从三个数字到完整报告原来的/api/report返回内容大概就是totalQuestions、correctCount、studyMinutes三个字段。改造后的接口仍然是GET /api/report?periodweek|month|semester返回五块内容overview本期概览、weeklyComparison上期对比、subjectPerformance按课程错题分布、knowledgeMastery知识掌握度、aiSuggestions规则建议。前两块是本次重点。3.2 weeklyComparison时间窗口的边界细节这是本次改动里最容易写错的一处。核心逻辑本期是[now - days, now]上期是[now - 2*days, now - days]两个窗口严格相邻、不重叠LocalDateTime since LocalDateTime.now().minusDays(days); // 本期起点 // 上期终点 本期起点起点 本期起点再往前 days 天 LocalDateTime prevEnd since; LocalDateTime prevStart prevEnd.minusDays(days); ListUserQuestionRecord prevQrs userQuestionRecordMapper.selectList( new LambdaQueryWrapperUserQuestionRecord() .eq(UserQuestionRecord::getUserId, userId) .ge(UserQuestionRecord::getSubmitTime, prevStart) .lt(UserQuestionRecord::getSubmitTime, prevEnd)); // 严格小于第一版把prevEnd写成了LocalDateTime.now()上期和本期完全重叠对比数据永远是 0 变化。调试时在返回值里临时加了debugPrevStart和debugPrevEnd字段才发现窗口对不上。3.3 subjectPerformance三级关联聚合错题到课程需要三级跳WrongQuestion → Question通过 qId→ KnowledgePoint通过 kpId→ Course通过 courseId。项目里基本没写 XML Mapper 全用 LambdaQueryWrapper三表 join 要写原生 SQL。考虑到一个用户在一个周期内通常不超过 100 条错题选择在 Java 里循环聚合MapLong, long[] courseStats new LinkedHashMap(); // courseId → [total, resolved] MapLong, String courseNameCache new HashMap(); for (WrongQuestion wq : wrongList) { if (wq.getQId() null) continue; Question q questionMapper.selectById(wq.getQId()); if (q null || q.getKpId() null) continue; KnowledgePoint kp knowledgePointMapper.selectById(q.getKpId()); if (kp null || kp.getCourseId() null) continue; Long cid kp.getCourseId(); courseStats.computeIfAbsent(cid, k - new long[]{0, 0}); courseStats.get(cid)[0]; if (Boolean.TRUE.equals(wq.getIsResolved())) courseStats.get(cid)[1]; courseNameCache.computeIfAbsent(cid, k - { Course c courseService.getById(k); return c ! null ? c.getCourseName() : 课程 k; }); }N1 问题确实存在但单期错题量小代价可接受后期量上来再批量优化。本期无错题时用UserProfile.currentCourses展示0 错题空行让前端有东西可渲染。四、同步 AI 建议生成4.1 接口设计新增POST /api/report/ai-suggest前端在学情画报页点击按钮时调用同步等待大模型返回。提示词注入本期答题数与正确率、用户学习目标与目标分数、最近 3 个薄弱知识点名称让模型产出 200~300 字的分析。4.2 降级兜底模型超时或调用失败时不能一直 loading改成规则降级try { suggestion chatClient.prompt().user(prompt).call().content(); } catch (Exception e) { log.warn(AI 建议生成失败降级为规则建议: {}, e.getMessage()); suggestion buildRuleSuggestion(totalQuestions, accuracy, unresolvedMistakes); } private String buildRuleSuggestion(int total, double acc, long mistakes) { if (total 0) return 本期还没有答题记录先从一个知识点开始练习吧。; if (acc 0.6) return 正确率偏低建议先回到课本巩固基础概念不要急于追求题量。; if (mistakes 5) return 错题本里还有 mistakes 道未消化建议优先把错题过一遍。; if (acc 0.9) return 正确率很高可以尝试更难的题目或扩展到新知识点。; return 学习情况稳定保持节奏持续巩固薄弱知识点。; }覆盖没做题/正确率低/错题多/正确率高/正常五种情况确保按钮点下去永远有反馈。五、踩坑复盘坑一AnswerDTO 字段名反序列化失败。前端发的是questionIdDTO 里字段名叫qIdJackson 默认按字段名匹配导致永远是 null。选择直接改字段名为questionId并全局替换而不是加JsonProperty注解打补丁。坑二weeklyComparison 时间窗口重叠。上面 3.2 节已详述根因是prevEnd用了now而不是since。坑三UserProfile 快照导致 PUT 后 GET 返回旧值。UserHolder 里存的是登录时的 ThreadLocal 快照对象数据库改了但内存里的对象没变。修复为 GET 接口按 userId 重查数据库UserHolder 只取 userId 用于鉴权GetMapping(/profile) public Result getProfile() { Long userId UserHolder.getUser().getUserId(); UserProfileDTO profile userProfileService.getUserProfile(userId); return Result.ok(profile); }六、本期小结指标数据profile-report.vue 行数776 行ReportController 新增/改写方法4 个weeklyComparison 覆盖周期week / month / semester7/30/90 天AI 建议降级规则5 条踩坑修复3 处下一步重点knowledgeGrowth 目前始终返回空数组需要按日期×知识点做真实聚合AI 建议的同步接口耗时约 3 秒后续评估改异步轮询减少等待感。