13 KiB
学员个人画像页面「新模块不渲染」诊断报告
- 诊断日期:2026-11-08
- 涉及页面:
src/views/train/portrait/index.vue(个人画像 / 学员 360) - 涉及接口:
GET /train/analysis/portrait/student-360/{studentId} - 涉及后端:
ruoyi-train模块(StudentPortraitServiceImpl、TrainPortraitAnalyticsMapper) - 诊断方式:前端源码与编译产物核查、后端运行实例与编译产物时间比对、本地数据库(只读)表结构核对与 SQL 实测
一、结论摘要
- 直接原因(P0):画像聚合第 9 条 SQL
selectDormContext使用了 V1 旧表结构字段(train_dorm_auth.student_id / plan_id / dorm_id),而当前库为 V2 结构,实测报错Unknown column 'da.dorm_id' in 'field list'。 - 该异常在
TrainAnalysisServiceImpl.getStudent360()中被catch吞掉,仅记录log.warn,导致返回体里 没有portrait节点。 - 前端所有新增模块包在
<div v-if="portrait">中,portrait为 null 时整块不渲染,页面只剩老 360 内容 —— 即“旧信息仍在、新图表不出现”。 - 次要原因(P1):
build_ui/dist为 2026-11-03 构建,产物中不存在任何新画像代码;若访问的是该静态站点,即使后端修好也只会看到旧页面。 - 非缺陷项(P2):即使
portrait正常返回,各图表自身还有数据门槛(雷达需 ≥3 个可用维度等),数据不足时会显示空态文案,属预期降级。
二、从组件引入到实际渲染的完整链路
源码 build_ui/src/views/train/portrait/* ✅ 新代码存在(render 树校验通过)
↓ 构建
dev server :1026(vue-cli-service serve) ✅ 从源码编译,随 HMR 更新
dist/index.html(2026-11-03 构建) ❌ 早于本次改动,产物中搜不到
"个人画像结论 / 个人能力雷达 / portrait-radar"
↓ 路由(sys_menu → train/portrait/index) ✅ 页面可打开(旧内容可见)
↓ 接口 getStudent360
后端 :8096(2026-11-08 22:39:52 启动,class 22:39) ✅ 新代码已加载
↓ StudentPortraitServiceImpl.getPortrait()
第 9 条 SQL selectDormContext ❌ Unknown column 'da.dorm_id'
↓ catch (Exception) 仅 log.warn ❌ 静默降级,portrait = null
↓ JSON 响应无 portrait 字段
前端 <div v-if="portrait"> ❌ 结论 / 三图 / 趋势 / 风险全部不渲染
↓ 各图表自身数据门槛 ⚠️ 数据不足时只显示空态
↓ echarts 容器宽高 ⚠️ 隐藏容器中初始化为 0×0
链路断点唯一且确定:后端画像聚合 SQL 抛错 → 异常被吞 → portrait 缺失 → 前端整块 v-if 不成立。
三、断点定位与证据
3.1 前端:所有新模块挂在 v-if="portrait" 之下
src/views/train/portrait/index.vue:99
<!-- A2. 个人画像看板:全部数据仅属于当前学员本人,不展示任何其他学员信息 -->
<div v-if="portrait" class="portrait-extras">
该容器内依次包含:个人画像结论(综合分环、四项结论、标签、口径与数据截止)、三张图表卡(雷达 / 活跃度 / 目标达成)、七维画像明细、周趋势与跨班期、风险预警。portrait 为 null 时全部不产出 DOM。
3.2 后端:异常被吞,画像节点直接缺失
RuoYi-Vue/ruoyi-train/.../service/impl/TrainAnalysisServiceImpl.java:510-520
if (portraitService != null)
{
try
{
vo.setPortrait(portraitService.getPortrait(studentId));
}
catch (Exception e)
{
log.warn("学员画像扩展数据生成失败, studentId={}, reason={}", studentId, e.getMessage());
}
}
StudentPortraitServiceImpl.getPortrait() 内部按顺序执行:
fillAttendance(raw); fillStudy(raw); fillScores(raw, studentId);
fillExamCompletion(raw); fillDorm(raw, studentId);
fillClassBaseline(raw); fillTrend(raw);
StudentPortraitVo portrait = PortraitCalculator.build(raw);
任一 fillXxx 抛错都会使整个画像构建失败,当前只有 saveSnapshot 做了单条容错。
3.3 触发异常的具体 SQL
RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainPortraitAnalyticsMapper.xml:137-150
<select id="selectDormContext" resultType="java.util.HashMap">
select da.face_sync_status as face_sync_status,
da.dorm_id as dorm_id,
(select count(1)
from train_accommodation a2
where a2.dorm_id = da.dorm_id
and a2.enrollment_id <> #{enrollmentId}
and a2.accommodation_status <> 2) as dorm_mates,
(select max(a3.accommodation_status)
from train_accommodation a3
where a3.enrollment_id = #{enrollmentId}) as accommodation_status
from train_dorm_auth da
where da.student_id = #{studentId}
and da.plan_id = #{planId}
limit 1
</select>
这是 V1 旧结构写法。V2 中门禁授权通过 accommodation_id 关联(可参考旧 mapper 的既有正确写法 TrainAnalysisMapper.xml:395-413):
FROM train_accommodation a
JOIN train_enrollment e ON e.enrollment_id = a.enrollment_id
LEFT JOIN train_dormitory_info d ON d.dorm_id = a.dorm_id
LEFT JOIN train_dorm_auth auth ON auth.accommodation_id = a.accommodation_id
3.4 数据库层实证(本地库,只读)
逐表逐列核对画像 SQL 依赖,结论如下:
| 检查项 | 结果 |
|---|---|
train_theory_exam_score(is_final/plan_course_id/enrollment_id 等) |
存在 |
train_student.extract_status、card_no、source_type、id_card_last4 |
存在 |
train_study_duration.resource_id、sync_time |
存在 |
train_student_checkin.checkin_way、manual_flag |
存在 |
train_course.c_duration、train_course_resource.c_id |
存在 |
train_student_portrait(快照表) |
存在 |
train_dorm_auth.student_id / plan_id / dorm_id |
不存在 |
train_dorm_auth 实际列为:
auth_id, accommodation_id, credential_type, credential_no, auth_status, retry_count, last_sync_time, last_error, face_sync_status, create_time, update_time
直接执行该 SQL 的结果:
DORM_SQL_FAIL: Unknown column 'da.dorm_id' in 'field list'
即:每打开一次画像页必然失败一次,与学员数据多少无关。
3.5 前端产物陈旧(次要原因)
| 检查项 | 结果 |
|---|---|
build_ui/dist/index.html 修改时间 |
2026-11-03(早于本次改动) |
dist 中是否包含 个人画像结论 / 个人能力雷达 / portrait-radar |
均未命中 |
| 前端 dev server | 存在,端口 1026,vue-cli-service serve |
| 端口 80 | 为 WAMP/Apache 默认站点,非本项目前端 |
若浏览器访问的是 dist 打包站点,则新组件根本不在产物中,必须重新 npm run build。
3.6 旧信息为何仍在
- 基本信息、考勤记录/明细、培训履历、成绩、住宿门禁由另一条链路(
analysisMapper老查询)生成,与portrait无关,因此不受影响、始终有内容。 - 老代码中“自主学习学时”本就是硬编码:
TrainAnalysisServiceImpl.java:463-465
// 5. 自主学习状态 (严格不造假)
vo.setHasStudyData(false);
vo.setStudyStatusDesc("暂无自主学习记录 / 待外部平台数据同步");
新卡片读取的是 portrait.dimensions[study];portrait 为 null 时新逻辑不执行,于是“旧文案仍在、新指标未出现”。
四、各组件显示条件
| 组件 / 区块 | 渲染条件 | 不满足时的表现 |
|---|---|---|
| 整个画像看板 | profile.portrait != null |
整块消失(当前状态) |
| 综合分环形 + 四项结论 | 同上 | 同上 |
| 标签行 | visibleTags.length > 0(已剔除 advantage 组) |
不显示标签行 |
个人能力雷达 PortraitRadar |
维度中 available === true 且 score != null 的 ≥3 个 |
“暂无可用的维度评分” |
活跃度环形 PortraitActivity |
activity.parts 中 included === true 且 score != null 的 ≥1 个 |
“暂无活跃度分项数据” |
| 个人目标达成 | goalItems.length > 0(学时/课次/考核完成度/出勤率任一有值) |
“暂无可对照的计划目标” |
周趋势 PortraitWeekly |
trend.weekly.length > 0 |
“暂无周维度学习与出勤记录” |
跨班期对比 PortraitPeriods |
trend.periods.length > 0 |
“暂无跨班期记录” |
| 风险预警 | riskRules.length > 0 |
卡片整体不渲染 |
雷达最易凑不够 3 轴:无课次→考勤不可用;无学习记录→学习投入不可用;无成绩→考核能力不可用;无课次→进度不可用。此时仅剩“身份与数据质量”“综合画像”,雷达退化为空态。属正常降级,不是渲染故障。
五、让新图表正常渲染的前提条件
P0 必须满足(否则一定空白)
- 修复门禁 SQL:
selectDormContext改为 V2 关联方式,并按enrollmentId查询。 - 后端生效:修改 XML 后需重启 / 触发热加载(当前实例 22:39 启动)。
- 前端最新:
- 走
:1026dev server 时硬刷新(Ctrl+F5); - 走
dist静态站点时必须重新npm run build。
- 走
P1 强烈建议
- 逐条容错:在
StudentPortraitServiceImpl的每个fillXxx外层加 try/catch,失败时置空并写入meta.warnings,避免“一条 SQL 坏掉 → 整块画像消失”。
P2 数据层面(决定“有图”还是“空态”)
- 至少有 1 条参训记录(
train_enrollment),否则返回空画像。 - 有课次(
train_lesson)→ 考勤/进度维度可用,雷达才可能凑够 3 轴。 - 有学习时长(
train_study_duration)→ 学习投入维度、周趋势学时柱有值。 - 有成绩(
train_theory_exam_score,is_final = 1且del_flag = '0')→ 考核维度、考核均分虚线有值。 - 有多期参训 → 跨班期对比有 ≥2 根柱(单期也可画,仅一根)。
P3 渲染层面
- echarts 容器需在可见状态下初始化;若被隐藏 tab /
v-show包裹,需重新触发resize(组件已用ResizeObserver兜底)。
六、修复建议(未实施,待确认)
6.1 门禁 SQL 改为 V2 结构
<select id="selectDormContext" resultType="java.util.HashMap">
select da.face_sync_status as face_sync_status,
a.dorm_id as dorm_id,
(select count(1)
from train_accommodation a2
where a2.dorm_id = a.dorm_id
and a2.enrollment_id <> a.enrollment_id
and a2.accommodation_status <> 2) as dorm_mates,
a.accommodation_status as accommodation_status
from train_accommodation a
left join train_dorm_auth da on da.accommodation_id = a.accommodation_id
where a.enrollment_id = #{enrollmentId}
limit 1
</select>
同步调整:
TrainPortraitAnalyticsMapper.selectDormContext(...)入参改为@Param("enrollmentId") Long enrollmentId;StudentPortraitServiceImpl.fillDorm(...)改为传入raw.enrollmentId。
6.2 画像构建逐条容错(建议)
private void safeFill(String name, PortraitRawData raw, Runnable action) {
try { action.run(); }
catch (Exception e) { raw.warnings.add(name + " 数据获取失败,已按数据不足处理"); log.warn(...); }
}
调用侧:safeFill("住宿门禁", raw, () -> fillDorm(raw, raw.enrollmentId)); —— 保证单个维度失败只降级,不隐藏整块画像。
七、快速自检步骤
- F12 → Network →
student-360响应中搜索"portrait":- 无该字段 → 后端断点(本文根因);
- 有但维度全空 → 数据问题,见第五节 P2。
- 后端日志搜索
学员画像扩展数据生成失败,若包含Unknown column 'da.dorm_id',即本文根因。 - 观察页面检索框:候选列表版 = 新前端;否则看的是旧产物(
dist)。
八、附:本次核查使用的关键路径
| 用途 | 路径 |
|---|---|
| 画像页面 | build_ui/src/views/train/portrait/index.vue |
| 图表组件 | build_ui/src/views/train/portrait/components/Portrait*.vue、chartMixin.js |
| 画像接口 | build_ui/src/api/train/analysis.js(getStudent360) |
| 画像组装 | RuoYi-Vue/ruoyi-train/.../service/impl/TrainAnalysisServiceImpl.java |
| 画像计算 | RuoYi-Vue/ruoyi-train/.../service/impl/StudentPortraitServiceImpl.java、PortraitCalculator.java |
| 画像 SQL | RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainPortraitAnalyticsMapper.xml |
| 正确住宿/门禁关联参考 | RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainAnalysisMapper.xml |
| 前端产物 | build_ui/dist(2026-11-03,陈旧) |