瀏覽代碼

docs: DAN报告按类型分别处理实施计划

E2E Test Bot 2 周之前
父節點
當前提交
4772646bfe
共有 1 個文件被更改,包括 418 次插入0 次删除
  1. 418 0
      docs/superpowers/plans/2026-08-30-dan-report-type-specific-blocks.md

+ 418 - 0
docs/superpowers/plans/2026-08-30-dan-report-type-specific-blocks.md

@@ -0,0 +1,418 @@
+# DAN 报告按类型分别处理展示 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 修复 DAN 报告详情页 `report-result.vue` 对 A1/A2/B2/B3/B4/B5/B6/C1 八种报告类型硬编码展示、指标字段对不上的问题,改为后端按报告类型分组组装 blocks、前端复用通用渲染器展示。
+
+**Architecture:** 后端 `DanReportParseService` 已按类型解析 items 且每个 `DataItem` 带 `category` 标签(如 `bigFive`/`selfConcept`/`cognitive`/`hollandInterest`/`sleep` 等)。当前 `ReportBlockAssembler.assembleDanFromJson()` 无视 category 把所有指标平铺成一个 list 块,导致所有类型展示相同。方案:
+1. `ReportBlockAssembler` 新增 `assembleDanByType()`:把 items 按 `category` 分组,每组生成一个 `indicator` 块(标题=该组中文名),外加 `score` 块(总分/百分位)与 `text` 块(评语/建议)——天然按类型区分。
+2. `DanReportUploadService.confirmUpload` 改用新组装;`getDetail` 对 DAN 报告始终从存储的 items 重算 blocks(保证历史数据也按类型展示,不依赖历史持久化块结构)。
+3. 前端 `report-result.vue` 删除硬编码的三段维度(认知雷达图/EMI 心理/大五人格),改用已有 `report-blocks-renderer` 渲染 blocks;`dimensionLabel` 补全 8 种类型。
+
+**Tech Stack:** Spring Boot 2.7.18 + MyBatis-Plus + Java 8;uni-app Vue 2 Options API 小程序。
+
+## Global Constraints
+
+- 接口统一 `@PostMapping`,禁止 GET/PUT/DELETE
+- DI 用 `@Resource`,字段名与类型默认 Bean Name 一致
+- 后端验证:`mvn clean compile`(唯一验证方式);测试:`mvn test`(当前 2 个相关测试类 `DanReportParseServiceTest`、`ReportBlockAssemblerTest`,均无 `@SpringBootTest`,纯 `new` 实例)
+- 前端小程序:禁止可选链 `?.`(用 `&&`)、禁止 CSS Grid、禁止 `:key` 表达式、禁止 `new Date(string)`(用 `utils/format.js` 的 `parseDate()`)
+- 前端 Vue 2 Options API,禁止 Composition API
+- 前端验证:`node --check` 语法校验 script 块(禁 `npm run build:*`,打包用 HBuilderX)
+- **本计划无需数据库迁移**:`report_blocks` 表已存在(迁移211),DAN 报告的数据源是 `dan_report_uploads.parsed_items/edited_items/parsed_summary/parsed_suggestions`(已落库)
+- 新接口无需新增(复用 `POST /api/dan-report/{id}`)
+- 单元测试不带 `@SpringBootTest`(避免 MySQL 连接),纯 `new` 实例(参照 `ReportBlockAssemblerTest`)
+
+---
+
+### Task 1: 后端 — ReportBlockAssembler 新增按类型分组组装 `assembleDanByType`
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ReportBlockAssembler.java`(在 `assembleDanFromJson` 附近新增方法)
+- Test: `cfc-backend/src/test/java/com/etotem/cfc/service/ReportBlockAssemblerTest.java`(追加测试)
+
+**Interfaces:**
+- Produces: `ReportBlockAssembler.assembleDanByType(String itemsJson, String summary, String suggestions)` → `List<Map<String, Object>>`
+  - blocks 结构:`score` 块(`total_score`/`percentile` → 标签"总得分"/"百分位",max=100)+ 若干 `indicator` 块(按 `category` 分组,`title`=该组中文名,`items=[{name,value}]`)+ `text` 块(summary → "报告评语",suggestions → "成长建议")
+  - 旧方法 `assembleDanFromJson` 保留不删(仍被测试引用,避免破坏既有调用)
+- Consumes: `DanReportParseService.DataItem`(getCode/getName/getValue/getCategory)
+
+- [ ] **Step 1: 写失败测试**
+
+在 `ReportBlockAssemblerTest.java` 追加(验证按 category 分组、A1 认知组、score/text 块、空组跳过):
+
+```java
+@Test
+void assembleDanByType_shouldGroupItemsByCategory() {
+    String itemsJson = "[{\"code\":\"total_score\",\"name\":\"总得分\",\"value\":\"112\",\"category\":\"overall\"},"
+            + "{\"code\":\"percentile\",\"name\":\"百分位\",\"value\":\"79\",\"category\":\"overall\"},"
+            + "{\"code\":\"perception_pct\",\"name\":\"感知觉百分位\",\"value\":\"14\",\"category\":\"cognitive\"},"
+            + "{\"code\":\"attention_pct\",\"name\":\"注意力百分位\",\"value\":\"28\",\"category\":\"cognitive\"},"
+            + "{\"code\":\"openness\",\"name\":\"开放性\",\"value\":\"7.5\",\"category\":\"bigFive\"}]";
+    List<Map<String, Object>> blocks = assembler.assembleDanByType(itemsJson, "综合评语", "成长建议");
+    // score 块
+    Map<String, Object> scoreBlock = blocks.stream()
+            .filter(b -> "score".equals(b.get("type"))).findFirst().orElse(null);
+    assertNotNull(scoreBlock, "应有score块");
+    assertEquals("总得分", ((List<Map<String, Object>>) scoreBlock.get("items")).get(0).get("label"));
+    // 按 category 分组为 indicator 块:cognitive 组 2 项、bigFive 组 1 项
+    @SuppressWarnings("unchecked")
+    List<Map<String, Object>> indBlocks = (List<Map<String, Object>>)(List<?>) blocks.stream()
+            .filter(b -> "indicator".equals(b.get("type"))).collect(java.util.stream.Collectors.toList());
+    assertEquals(2, indBlocks.size(), "应有 cognitive/bigFive 两个 indicator 块");
+    // DAN_CATEGORY_TITLES 插入顺序:bigFive 在前、cognitive 在后 → 固定输出顺序
+    assertEquals("大五人格", indBlocks.get(0).get("title"));
+    assertEquals(1, ((List<Map<String, Object>>) indBlocks.get(0).get("items")).size());
+    assertEquals("开放性", ((List<Map<String, Object>>) indBlocks.get(0).get("items")).get(0).get("name"));
+    assertEquals("认知能力", indBlocks.get(1).get("title"));
+    assertEquals(2, ((List<Map<String, Object>>) indBlocks.get(1).get("items")).size());
+    // text 块
+    long textCount = blocks.stream().filter(b -> "text".equals(b.get("type"))).count();
+    assertEquals(2, textCount, "应有summary和suggestions两个text块");
+}
+```
+
+- [ ] **Step 2: 运行测试确认失败**
+
+Run: `mvn test -Dtest=ReportBlockAssemblerTest`(在 `cfc-backend/`)
+Expected: FAIL(编译错误:`assembleDanByType` 方法不存在)
+
+- [ ] **Step 3: 实现 `assembleDanByType`**
+
+在 `ReportBlockAssembler.java` 中新增方法(放在 `assembleDanFromJson` 之后):
+
+```java
+/** category → 中文组标题 映射(DAN 各报告类型的指标分组) */
+private static final Map<String, String> DAN_CATEGORY_TITLES = buildDanCategoryTitles();
+
+private static Map<String, String> buildDanCategoryTitles() {
+    Map<String, String> m = new LinkedHashMap<>();
+    m.put("bigFive", "大五人格");
+    m.put("social", "社会关系");
+    m.put("emotion", "情绪状态");
+    m.put("ability", "综合能力");
+    m.put("selfConcept", "自我概念");
+    m.put("growthMindset", "成长型思维");
+    m.put("selfDriving", "自驱力");
+    m.put("cognitive", "认知能力");
+    m.put("executiveFunction", "执行功能");
+    m.put("learningMotivation", "学习动机");
+    m.put("learningStrategy", "学习策略");
+    m.put("behavior", "行为表现");
+    m.put("family", "家庭环境");
+    m.put("hollandInterest", "职业兴趣");
+    m.put("multipleIntelligence", "多元智能");
+    m.put("careerValue", "职业价值观");
+    m.put("emotionRegulation", "情绪调节");
+    m.put("academicStress", "学业压力");
+    m.put("interpersonal", "人际关系");
+    m.put("socialAbility", "社交能力");
+    m.put("sleep", "睡眠状况");
+    m.put("exercise", "运动状况");
+    m.put("internet", "网络使用");
+    m.put("compass", "人际指南针");
+    m.put("overall", "综合评估");
+    m.put("auto", "其他指标");
+    return m;
+}
+
+/**
+ * DAN 报告 → blocks(按 category 分组)。
+ * score 块(总分/百分位)+ indicator 块(每个 category 一组)+ text 块(评语/建议)。
+ * 不同报告类型(A1/A2/B2/B3/B4/B5/B6/C1)的 category 集合不同,天然分别展示。
+ */
+public List<Map<String, Object>> assembleDanByType(String itemsJson, String summary, String suggestions) {
+    List<Map<String, Object>> blocks = new ArrayList<>();
+    List<DanReportParseService.DataItem> items = new ArrayList<>();
+    if (itemsJson != null && !itemsJson.isEmpty()) {
+        try {
+            items = new com.fasterxml.jackson.databind.ObjectMapper().readValue(
+                    itemsJson,
+                    new com.fasterxml.jackson.core.type.TypeReference<List<DanReportParseService.DataItem>>() {});
+        } catch (Exception e) {
+            // 解析失败按空处理
+        }
+    }
+    // 1. score 块:total_score / percentile
+    List<Map<String, Object>> scoreItems = new ArrayList<>();
+    // 2. indicator 块:按 category 分组
+    Map<String, List<Map<String, Object>>> byCategory = new LinkedHashMap<>();
+    for (DanReportParseService.DataItem item : items) {
+        if (item.getValue() == null || item.getValue().isEmpty()) continue;
+        if ("total_score".equals(item.getCode())) {
+            scoreItems.add(scoreItem("总得分", item.getValue()));
+        } else if ("percentile".equals(item.getCode())) {
+            scoreItems.add(scoreItem("百分位", item.getValue()));
+        } else {
+            String cat = item.getCategory() == null || item.getCategory().isEmpty() ? "auto" : item.getCategory();
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("name", item.getName());
+            m.put("value", item.getValue());
+            byCategory.computeIfAbsent(cat, k -> new ArrayList<>()).add(m);
+        }
+    }
+    if (!scoreItems.isEmpty()) {
+        Map<String, Object> block = new LinkedHashMap<>();
+        block.put("type", "score");
+        block.put("title", "测评结果");
+        block.put("items", scoreItems);
+        blocks.add(block);
+    }
+    // 按固定 category 顺序输出(保证同类型报告展示顺序稳定)
+    for (Map.Entry<String, String> e : DAN_CATEGORY_TITLES.entrySet()) {
+        List<Map<String, Object>> items2 = byCategory.get(e.getKey());
+        if (items2 != null && !items2.isEmpty()) {
+            Map<String, Object> block = new LinkedHashMap<>();
+            block.put("type", "indicator");
+            block.put("title", e.getValue());
+            block.put("items", items2);
+            blocks.add(block);
+        }
+    }
+    // text 块:summary / suggestions
+    if (summary != null && !summary.isEmpty()) blocks.add(textBlock("报告评语", summary));
+    if (suggestions != null && !suggestions.isEmpty()) blocks.add(textBlock("成长建议", suggestions));
+    return blocks;
+}
+```
+
+- [ ] **Step 4: 运行测试确认通过**
+
+Run: `mvn test -Dtest=ReportBlockAssemblerTest`(在 `cfc-backend/`)
+Expected: PASS(含既有 `assembleDan_shouldExtractScoreListAndText` 与新测试)
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ReportBlockAssembler.java cfc-backend/src/test/java/com/etotem/cfc/service/ReportBlockAssemblerTest.java
+git commit -m "feat(backend): DAN报告按category分组组装blocks(类型分别处理)"
+```
+
+---
+
+### Task 2: 后端 — DanReportUploadService 改用新组装 + getDetail 重算 blocks
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.java`
+  - `confirmUpload` 第221-222行:`assembleDanFromJson` → `assembleDanByType`
+  - `getDetail` 第336行:DAN 报告 blocks 改为从存储的 items 重算(保证历史数据也按类型展示)
+
+**Interfaces:**
+- Consumes: `ReportBlockAssembler.assembleDanByType(String, String, String)`(Task 1)
+- Produces: `getDetail` 对 `linkedResultId != null` 的 DAN 上传记录,返回按类型组装的新 blocks
+
+- [ ] **Step 1: `confirmUpload` 改用新组装**
+
+修改 `DanReportUploadService.java` 第221-222行:
+
+```java
+// 从
+reportBlockService.save("dan", upload.getId(),
+        reportBlockAssembler.assembleDanFromJson(itemsJson, summary, suggestions));
+// 改为
+reportBlockService.save("dan", upload.getId(),
+        reportBlockAssembler.assembleDanByType(itemsJson, summary, suggestions));
+```
+
+- [ ] **Step 2: `getDetail` 重算 DAN blocks**
+
+修改 `getDetail`(第336行附近),将直接读取 `reportBlockService.getBlocks` 改为:优先用上传记录中编辑/解析后的 items + summary + suggestions 重算;无 items 时回退到存储的 blocks:
+
+```java
+DanReportUploadVO vo = DanReportUploadVO.fromEntity(upload);
+// DAN 报告始终按类型重算 blocks(历史数据也生效),数据源 = edited_*/parsed_*
+String itemsJson = upload.getEditedItems() != null ? upload.getEditedItems() : upload.getParsedItems();
+if (itemsJson != null && !itemsJson.isEmpty()) {
+    String summary = upload.getEditedSummary() != null ? upload.getEditedSummary() : upload.getParsedSummary();
+    String suggestions = upload.getEditedSuggestions() != null
+            ? upload.getEditedSuggestions() : upload.getParsedSuggestions();
+    vo.setBlocks(reportBlockAssembler.assembleDanByType(itemsJson, summary, suggestions));
+} else {
+    vo.setBlocks(reportBlockService.getBlocks("dan", upload.getId()));
+}
+```
+
+注意:原第336行的 `vo.setBlocks(reportBlockService.getBlocks("dan", upload.getId()));` 替换为上述逻辑(保留 `reportBlockService` 注入不变,仅作为无 items 时的回退)。
+
+- [ ] **Step 3: 编译验证**
+
+Run: `mvn clean compile`(在 `cfc-backend/`)
+Expected: BUILD SUCCESS
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.java
+git commit -m "feat(backend): DAN报告详情接口按类型重算blocks(历史数据生效)"
+```
+
+---
+
+### Task 3: 前端 — report-result.vue 复用 report-blocks-renderer + 全类型标签
+
+**Files:**
+- Modify: `cfc-frontend/pages/dan-assessment/report-result.vue`
+
+**Interfaces:**
+- Consumes: 后端 `POST /api/dan-report/{id}` 返回的 `blocks`(Task 1/2)、`dimension` 字段
+- Consumes: 组件 `components/report-blocks-renderer.vue`(已存在,props: `blocks`)
+- Produces: 报告详情页按类型展示 blocks;`dimensionLabel` 覆盖 8 种类型
+
+- [ ] **Step 1: 模板替换硬编码维度区**
+
+将 `report-result.vue` 模板中第14-95行的 `v-else-if="result"` 区块整体替换为:
+
+```html
+<template v-else-if="blocks && blocks.length > 0">
+  <!-- 报告内容块(后端按类型组装,前端通用渲染) -->
+  <report-blocks-renderer :blocks="blocks" />
+</template>
+```
+
+同时:
+- 删除模板中不再使用的:`overview-card`、`radar-section`、`score-row` 循环、`bigfive-total`、`suggestion-card`、旧 `block-item` 手动渲染段
+- 模板最外层的 `headInfo` 区块(第4-8行)保留
+- 综合得分不再单独渲染卡片:`report-blocks-renderer` 的 `score` 块(后端输出"测评结果/总得分/百分位"圆环)已覆盖,删除 `overview-card` 避免重复
+
+- [ ] **Step 2: script 重构**
+
+将 `<script>` 段替换为:
+
+```html
+<script>
+import ReportBlocksRenderer from '../../components/report-blocks-renderer.vue'
+import { getDanReportDetail } from '../../utils/api.js'
+
+export default {
+  components: { ReportBlocksRenderer },
+  data() {
+    return {
+      reportId: null,
+      loading: true,
+      headInfo: null,
+      blocks: []
+    }
+  },
+  computed: {
+    dimensionLabel() {
+      if (!this.headInfo || !this.headInfo.dimension) return ''
+      var map = {
+        mind: '心维度 (A2)',
+        wisdom: '智维度 (B4)',
+        cognition: '认知维度 (A1)',
+        learning: '学习能力 (B3)',
+        behavior: '行为教养 (B2)',
+        career: '职业发展 (B6)',
+        campus: '校园标准 (C1)',
+        adolescent: '青春期挑战 (B5)'
+      }
+      return map[this.headInfo.dimension] || ''
+    }
+  },
+  onLoad(options) {
+    this.reportId = options.reportId ? parseInt(options.reportId) : null
+    if (this.reportId) this.loadData()
+  },
+  methods: {
+    loadData() {
+      var self = this
+      this.loading = true
+      getDanReportDetail(this.reportId).then(function(res) {
+        self.loading = false
+        if (res.code !== 200 || !res.data) {
+          uni.showToast({ title: res.message || '加载失败', icon: 'none' })
+          return
+        }
+        var data = res.data
+        self.headInfo = {
+          personName: (data.result && data.result.childName) || data.childName || '',
+          dateText: data.confirmedAt ? data.confirmedAt.substring(0, 10) : '',
+          dimension: data.dimension
+        }
+        self.blocks = (data.blocks || []).filter(function(b) { return b && (b.items && b.items.length || b.content) })
+      }).catch(function(e) {
+        self.loading = false
+        uni.showToast({ title: '加载异常', icon: 'none' })
+      })
+    }
+  }
+}
+</script>
+```
+
+注意:
+- 删除 `RadarChart` 组件引入(模板不再使用)
+- 删除 `cognitiveDimensions`/`emiDimensions`/`bigFiveDimensions` 三组 data
+- `headInfo.personName` 从 `data.result.childName` 取(后端 getDetail 把 childName 放进 result Map,第342行 `rm.put("childName", ...)`)
+- 禁止可选链:`data.result && data.result.childName`(已用 `&&`)
+- `dateText` 用 `data.confirmedAt`(VO 有 confirmedAt 字段;上传报告确认后一定有值,草稿状态用空字符串兜底)
+
+- [ ] **Step 3: 清理样式**
+
+删除 `<style scoped>` 中不再使用的:`.overview-card`、`.overview-header`、`.overview-title`、`.overview-score`、`.overview-desc`、`.radar-section`、`.score-row`、`.score-label`、`.score-bar-bg`、`.score-bar-fill`、`.score-num`、`.bigfive-total`、`.bigfive-val`、`.suggestion-card`、`.suggestion-text`、`.block-item`、`.block-title`、`.block-content`
+保留:`.container`、`.loading-wrap`、`.loading-text`、`.empty-wrap`、`.empty-text`、`.report-head`、`.head-name`、`.head-date`、`.head-dim`、`.section` 若仍被 head 使用、`.bottom-spacer`
+
+- [ ] **Step 4: 语法校验**
+
+用 `node --check` 校验提取出的 script 块(仅语法校验,不打包):
+
+Run: 提取 `<script>` 内容写入临时文件后 `node --check`,确认无语法错误
+Expected: 无输出(通过)
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add cfc-frontend/pages/dan-assessment/report-result.vue
+git commit -m "feat(frontend): DAN报告详情页复用blocks渲染器,按类型分别展示"
+```
+
+---
+
+### Task 4: 验证 + 文档同步
+
+**Files:**
+- Modify: `docs/superpowers/PROJECT-OVERVIEW.md`(在"计划与设计文档"表格追加一行)
+
+- [ ] **Step 1: 运行后端全量相关测试**
+
+Run: `mvn test -Dtest=DanReportParseServiceTest,ReportBlockAssemblerTest`(在 `cfc-backend/`)
+Expected: 全部 PASS(解析层 8 类型不受影响,组装层新逻辑通过)
+
+- [ ] **Step 2: 编译验证**
+
+Run: `mvn clean compile`(在 `cfc-backend/`)
+Expected: BUILD SUCCESS
+
+- [ ] **Step 3: 更新 PROJECT-OVERVIEW.md**
+
+在计划表格(`plans/` 列表末尾)追加一行:
+
+```
+| `2026-08-30-dan-report-type-specific-blocks.md` | 🟢 已实施(4 Tasks:assembleDanByType分组/详情重算blocks/前端复用渲染器/文档) | DAN 报告按类型分别处理展示实施计划 |
+```
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add docs/superpowers/PROJECT-OVERVIEW.md
+git commit -m "docs: 记录DAN报告按类型分别处理实施计划"
+```
+
+---
+
+## Self-Review
+
+**1. Spec 覆盖(对照用户需求 "DAN报告每种类型的指标不同,需要分别处理"):**
+- ✅ 后端解析层已区分 8 类型(无需改动,Task 4 回归验证)
+- ✅ 后端组装层按 category 分组(Task 1)→ 每种类型展示各自的指标组
+- ✅ 后端详情接口重算 blocks 使历史数据也生效(Task 2)
+- ✅ 前端删除硬编码维度、复用通用渲染器(Task 3)
+- ✅ dimensionLabel 覆盖 8 类型(Task 3 Step 2)
+
+**2. Placeholder 扫描:** 无 TBD/TODO;所有步骤含具体代码/命令/验收标准。
+
+**3. 类型一致性:**
+- `assembleDanByType(String, String, String)` 在 Task 1 定义,Task 2 Step 1/2 调用,签名一致
+- `DAN_CATEGORY_TITLES` 中 category 键与 `DanReportParseService` 各 parse 方法的 category 值("bigFive"/"social"/"emotion"/"ability"/"selfConcept"/"growthMindset"/"selfDriving"/"cognitive"/"executiveFunction"/"learningMotivation"/"learningStrategy"/"behavior"/"family"/"hollandInterest"/"multipleIntelligence"/"careerValue"/"emotionRegulation"/"academicStress"/"interpersonal"/"socialAbility"/"sleep"/"exercise"/"internet"/"compass"/"overall"/"auto")逐一对齐
+- 前端 `report-blocks-renderer` props 名为 `blocks`,Task 3 模板 `:blocks="blocks"` 一致
+- `headInfo.dimension` 来自后端 VO `dimension` 字段(`data.dimension`),Task 3 computed 中 `map` 键与后端 dimension 常量(mind/wisdom/cognition/learning/behavior/career/campus/adolescent)一致