Files
railwaysystem/build_ui/docs/portrait-render-diagnosis-20261108.md
T

279 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 学员个人画像页面「新模块不渲染」诊断报告
- 诊断日期:2026-11-08
- 涉及页面:`src/views/train/portrait/index.vue`(个人画像 / 学员 360)
- 涉及接口:`GET /train/analysis/portrait/student-360/{studentId}`
- 涉及后端:`ruoyi-train` 模块(`StudentPortraitServiceImpl`、`TrainPortraitAnalyticsMapper`)
- 诊断方式:前端源码与编译产物核查、后端运行实例与编译产物时间比对、本地数据库(只读)表结构核对与 SQL 实测
---
## 一、结论摘要
1. **直接原因(P0)**:画像聚合第 9 条 SQL `selectDormContext` 使用了 V1 旧表结构字段(`train_dorm_auth.student_id / plan_id / dorm_id`),而当前库为 V2 结构,实测报错 `Unknown column 'da.dorm_id' in 'field list'`。
2. 该异常在 `TrainAnalysisServiceImpl.getStudent360()` 中被 `catch` 吞掉,仅记录 `log.warn`,导致返回体里 **没有 `portrait` 节点**。
3. 前端所有新增模块包在 `<div v-if="portrait">` 中,`portrait` 为 null 时整块不渲染,页面只剩老 360 内容 —— 即“旧信息仍在、新图表不出现”。
4. **次要原因(P1)**:`build_ui/dist` 为 2026-11-03 构建,产物中不存在任何新画像代码;若访问的是该静态站点,即使后端修好也只会看到旧页面。
5. **非缺陷项(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`
```html
<!-- A2. 个人画像看板:全部数据仅属于当前学员本人,不展示任何其他学员信息 -->
<div v-if="portrait" class="portrait-extras">
```
该容器内依次包含:个人画像结论(综合分环、四项结论、标签、口径与数据截止)、三张图表卡(雷达 / 活跃度 / 目标达成)、七维画像明细、周趋势与跨班期、风险预警。`portrait` 为 null 时全部不产出 DOM。
### 3.2 后端:异常被吞,画像节点直接缺失
`RuoYi-Vue/ruoyi-train/.../service/impl/TrainAnalysisServiceImpl.java:510-520`
```java
if (portraitService != null)
{
try
{
vo.setPortrait(portraitService.getPortrait(studentId));
}
catch (Exception e)
{
log.warn("学员画像扩展数据生成失败, studentId={}, reason={}", studentId, e.getMessage());
}
}
```
`StudentPortraitServiceImpl.getPortrait()` 内部按顺序执行:
```java
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`
```xml
<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 &lt;&gt; #{enrollmentId}
and a2.accommodation_status &lt;&gt; 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`):
```xml
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`
```java
// 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 必须满足(否则一定空白)
1. **修复门禁 SQL**:`selectDormContext` 改为 V2 关联方式,并按 `enrollmentId` 查询。
2. **后端生效**:修改 XML 后需重启 / 触发热加载(当前实例 22:39 启动)。
3. **前端最新**:
- 走 `:1026` dev server 时硬刷新(Ctrl+F5);
- 走 `dist` 静态站点时必须重新 `npm run build`。
### P1 强烈建议
4. **逐条容错**:在 `StudentPortraitServiceImpl` 的每个 `fillXxx` 外层加 try/catch,失败时置空并写入 `meta.warnings`,避免“一条 SQL 坏掉 → 整块画像消失”。
### P2 数据层面(决定“有图”还是“空态”)
5. 至少有 1 条参训记录(`train_enrollment`),否则返回空画像。
6. 有课次(`train_lesson`)→ 考勤/进度维度可用,雷达才可能凑够 3 轴。
7. 有学习时长(`train_study_duration`)→ 学习投入维度、周趋势学时柱有值。
8. 有成绩(`train_theory_exam_score`,`is_final = 1` 且 `del_flag = '0'`)→ 考核维度、考核均分虚线有值。
9. 有多期参训 → 跨班期对比有 ≥2 根柱(单期也可画,仅一根)。
### P3 渲染层面
10. echarts 容器需在可见状态下初始化;若被隐藏 tab / `v-show` 包裹,需重新触发 `resize`(组件已用 `ResizeObserver` 兜底)。
---
## 六、修复建议(未实施,待确认)
### 6.1 门禁 SQL 改为 V2 结构
```xml
<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 &lt;&gt; a.enrollment_id
and a2.accommodation_status &lt;&gt; 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 画像构建逐条容错(建议)
```java
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));` —— 保证单个维度失败只降级,不隐藏整块画像。
---
## 七、快速自检步骤
1. F12 → Network → `student-360` 响应中搜索 `"portrait"`:
- 无该字段 → 后端断点(本文根因);
- 有但维度全空 → 数据问题,见第五节 P2。
2. 后端日志搜索 `学员画像扩展数据生成失败`,若包含 `Unknown column 'da.dorm_id'`,即本文根因。
3. 观察页面检索框:候选列表版 = 新前端;否则看的是旧产物(`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,陈旧) |