# 通用报告块渲染引擎 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:** 构建通用报告块渲染引擎——解析器输出统一 `blocks` JSON 落库 `report_blocks` 表,前端按 `block.type` 通用渲染,覆盖肠道菌群/DAN 报告,修复 9 项评分圆环缺失 bug。 **Architecture:** 新增 `report_blocks` 表(`report_type` + `report_id` 复合唯一键 + `blocks` JSON)。`ReportBlockAssembler` 按类型组装 blocks(gut_flora 从 `ParsedReportPayload` 第二套键、dan 从 `DanParsedReport`/items JSON),在 confirmDraft / confirmUpload / updateReportFromPayload 三个挂点落库。`getReportDetail` 与 `DanReportUploadVO` 追加返回 blocks(旧字段保留)。前端新增通用渲染器 `report-blocks-renderer` + 统一详情页 `report-detail.vue`,入口(report-list / report-confirm / 维度页)切到新详情页。 **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 一致 - 实体用 MyBatis-Plus `@TableName` + `@TableId(type = IdType.AUTO)` - 迁移唯一入口 `DatabaseInitializer.runMigrations()`,最新编号 210 → 新迁移 = **迁移211**;同步 `schema.sql` - 迁移幂等(try-catch 忽略已存在) - 前端小程序:禁止可选链 `?.`(用 `&&`)、禁止 CSS Grid、禁止 `:key` 表达式、禁止 `new Date(string)`(用 `utils/format.js` 的 `parseDate()`) - 前端 Vue 2 Options API,禁止 Composition API - 后端验证:`mvn clean compile`(唯一验证方式);前端验证:`node --check` 语法校验(禁 `npm run build:*`,打包用 HBuilderX) - **评分键名**:组装器统一读**第二套键**(`balanceScore`/`diversityScore`/`beneficialScore`/`harmfulScore`/`coreGenusScore`);编辑 Map 用 `Payload.fromMap` 把第一套键(`gutBalanceScore`/`gutDiversityScore`/`beneficialBacteriaScore`/`harmfulBacteriaScore`/`coreSpeciesScore`)映射到第二套 - **风险等级分组**:重要风险 = {需注意, 注意, 高风险, 异常};其它风险 = {低风险, 其余} - 单元测试不带 `@SpringBootTest`(避免 MySQL 连接),纯 `new` 实例(参照 `DanReportParseServiceTest`) --- ### Task 1: 数据库迁移 — report_blocks 表 **Files:** - Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(runMigrations 末尾) - Modify: `cfc-backend/src/main/resources/schema.sql`(末尾追加 CREATE TABLE) **Interfaces:** - Produces: 数据库表 `report_blocks`(供 Task 2 的实体映射) - [ ] **Step 1: 在 DatabaseInitializer.runMigrations() 末尾追加迁移** ```java // 迁移211: 创建 report_blocks 表(通用报告展示块,按报告类型存储 blocks JSON) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_blocks (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "report_type VARCHAR(32) NOT NULL COMMENT 'gut_flora/dan/physical_exam/tongue', " + "report_id BIGINT NOT NULL COMMENT '各类型报告主键ID', " + "blocks JSON NOT NULL COMMENT '块数组 [{type,title,items,extra}]', " + "version INT DEFAULT 1, " + "created_at DATETIME, " + "updated_at DATETIME, " + "UNIQUE KEY uk_report_type_id (report_type, report_id)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告通用展示块'"); log.info("已创建report_blocks表"); } catch (Exception e) { // 表已存在,忽略错误 } ``` - [ ] **Step 2: schema.sql 末尾追加相同 CREATE TABLE 语句**(保持 schema.sql 为完整快照) - [ ] **Step 3: 编译验证** Run: `mvn clean compile`(在 `cfc-backend/` 目录) Expected: BUILD SUCCESS - [ ] **Step 4: Commit** ```bash git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql git commit -m "feat(backend): report_blocks 表迁移(通用报告展示块)" ``` --- ### Task 2: ReportBlock 实体 + Mapper + Service **Files:** - Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/ReportBlock.java` - Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/ReportBlockMapper.java` - Create: `cfc-backend/src/main/java/com/etotem/cfc/service/ReportBlockService.java` **Interfaces:** - Produces: `ReportBlockService.save(String reportType, Long reportId, List> blocks)`(upsert,Task 5 挂点调用) - Produces: `ReportBlockService.getBlocks(String reportType, Long reportId)` → `List>`(Task 6 接口调用) - [ ] **Step 1: 创建 ReportBlock 实体** ```java package com.etotem.cfc.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.io.Serializable; import java.util.Date; @Data @TableName("report_blocks") public class ReportBlock implements Serializable { @TableId(type = IdType.AUTO) private Long id; /** 报告类型: gut_flora/dan/physical_exam/tongue */ private String reportType; /** 各类型报告主键ID */ private Long reportId; /** 块数组 JSON 字符串 */ private String blocks; private Integer version; private Date createdAt; private Date updatedAt; } ``` - [ ] **Step 2: 创建 ReportBlockMapper** ```java package com.etotem.cfc.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.etotem.cfc.entity.ReportBlock; import org.apache.ibatis.annotations.Mapper; @Mapper public interface ReportBlockMapper extends BaseMapper { } ``` - [ ] **Step 3: 创建 ReportBlockService** ```java package com.etotem.cfc.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.etotem.cfc.entity.ReportBlock; import com.etotem.cfc.mapper.ReportBlockMapper; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Map; @Service @Slf4j public class ReportBlockService { @Resource private ReportBlockMapper reportBlockMapper; private final ObjectMapper objectMapper = new ObjectMapper(); /** 保存或覆盖 blocks(幂等 upsert) */ public void save(String reportType, Long reportId, List> blocks) { if (blocks == null || blocks.isEmpty()) return; try { ReportBlock existing = reportBlockMapper.selectOne( new LambdaQueryWrapper() .eq(ReportBlock::getReportType, reportType) .eq(ReportBlock::getReportId, reportId)); String json = objectMapper.writeValueAsString(blocks); Date now = new Date(); if (existing != null) { existing.setBlocks(json); existing.setVersion(existing.getVersion() == null ? 1 : existing.getVersion() + 1); existing.setUpdatedAt(now); reportBlockMapper.updateById(existing); } else { ReportBlock nb = new ReportBlock(); nb.setReportType(reportType); nb.setReportId(reportId); nb.setBlocks(json); nb.setVersion(1); nb.setCreatedAt(now); nb.setUpdatedAt(now); reportBlockMapper.insert(nb); } } catch (Exception e) { log.warn("保存report_blocks失败 type={} reportId={}: {}", reportType, reportId, e.getMessage()); } } /** 读取 blocks,无记录返回空列表 */ public List> getBlocks(String reportType, Long reportId) { try { ReportBlock existing = reportBlockMapper.selectOne( new LambdaQueryWrapper() .eq(ReportBlock::getReportType, reportType) .eq(ReportBlock::getReportId, reportId)); if (existing == null || existing.getBlocks() == null || existing.getBlocks().isEmpty()) { return new ArrayList<>(); } return objectMapper.readValue(existing.getBlocks(), new TypeReference>>() {}); } catch (Exception e) { log.warn("读取report_blocks失败 type={} reportId={}: {}", reportType, reportId, e.getMessage()); return new ArrayList<>(); } } } ``` - [ ] **Step 4: 编译验证** Run: `mvn clean compile` Expected: BUILD SUCCESS - [ ] **Step 5: Commit** ```bash git add cfc-backend/src/main/java/com/etotem/cfc/entity/ReportBlock.java cfc-backend/src/main/java/com/etotem/cfc/mapper/ReportBlockMapper.java cfc-backend/src/main/java/com/etotem/cfc/service/ReportBlockService.java git commit -m "feat(backend): ReportBlock 实体/Mapper/Service(blocks upsert + 读取)" ``` --- ### Task 3: ReportBlockAssembler — gut_flora 组装 + fromMap 键名转换 **Files:** - Create: `cfc-backend/src/main/java/com/etotem/cfc/service/ReportBlockAssembler.java` - Create: `cfc-backend/src/test/java/com/etotem/cfc/service/ReportBlockAssemblerTest.java` **Interfaces:** - Produces: `ReportBlockAssembler.assembleGutFlora(ParsedReportPayload.Payload payload)` → `List>`(Task 5 confirmDraft 挂点调用) - Produces: `ReportBlockAssembler.fromMap(Map payload)` → `ParsedReportPayload.Payload`(Task 5 编辑挂点调用,第一套键→第二套) - Produces: `ReportBlockAssembler.isImportantRisk(String level)` → boolean(风险等级分组,Task 4 复用) - Consumes: `ParsedReportPayload`(dto),`ParsedReportPayload.Payload/Summary/Indicator/Flora/DiseaseRisk` - [ ] **Step 1: 写失败测试** ```java package com.etotem.cfc.service; import com.etotem.cfc.dto.ParsedReportPayload; import org.junit.jupiter.api.Test; import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; class ReportBlockAssemblerTest { private final ReportBlockAssembler assembler = new ReportBlockAssembler(); private ParsedReportPayload.Payload samplePayload() { ParsedReportPayload.Payload p = new ParsedReportPayload.Payload(); ParsedReportPayload.Summary s = new ParsedReportPayload.Summary(); s.setOverallScore(57); s.setGutHealthScore(76); s.setChronicDiseaseScore(64); s.setNutritionScore(30); s.setBalanceScore(38); s.setDiversityScore(50); s.setBeneficialScore(22); s.setHarmfulScore(26); s.setCoreGenusScore(85); s.setGutAge("55.52"); s.setGutType("普雷沃氏菌型"); p.setSummary(s); ParsedReportPayload.DiseaseRisk r1 = new ParsedReportPayload.DiseaseRisk(); r1.setDiseaseName("心脑血管疾病"); r1.setRiskValue("0.37"); r1.setRiskLevel("注意"); ParsedReportPayload.DiseaseRisk r2 = new ParsedReportPayload.DiseaseRisk(); r2.setDiseaseName("抑郁症"); r2.setRiskValue("0.27"); r2.setRiskLevel("低风险"); ParsedReportPayload.DiseaseRisk r3 = new ParsedReportPayload.DiseaseRisk(); r3.setDiseaseName("炎症性肠炎"); r3.setRiskValue("0.24"); r3.setRiskLevel("低风险"); p.setDiseaseRisks(java.util.Arrays.asList(r1, r2, r3)); return p; } @Test void assembleGutFlora_shouldContainNineScoreItems() { List> blocks = assembler.assembleGutFlora(samplePayload()); Map scoreBlock = blocks.stream() .filter(b -> "score".equals(b.get("type"))).findFirst().orElse(null); assertNotNull(scoreBlock, "应有score块"); @SuppressWarnings("unchecked") List> items = (List>) scoreBlock.get("items"); assertEquals(9, items.size(), "9项评分应齐全"); assertEquals("多样性", items.get(5).get("label")); assertEquals(50, items.get(5).get("value")); } @Test void assembleGutFlora_shouldGroupRiskByLevel() { List> blocks = assembler.assembleGutFlora(samplePayload()); @SuppressWarnings("unchecked") List> riskBlocks = (List>) (List) blocks.stream() .filter(b -> "risk_group".equals(b.get("type"))).collect(java.util.stream.Collectors.toList()); assertEquals(2, riskBlocks.size(), "应分重要/其它两组"); assertEquals("重要风险", riskBlocks.get(0).get("title")); assertEquals("其它风险", riskBlocks.get(1).get("title")); } @Test void fromMap_shouldMapFirstSetKeysToSecondSet() { java.util.Map map = new java.util.HashMap<>(); map.put("overallScore", 57); map.put("gutBalanceScore", 38); map.put("gutDiversityScore", 50); map.put("beneficialBacteriaScore", 22); map.put("harmfulBacteriaScore", 26); map.put("coreSpeciesScore", 85); ParsedReportPayload.Payload p = assembler.fromMap(map); assertEquals(57, p.getSummary().getOverallScore()); assertEquals(38, p.getSummary().getBalanceScore()); assertEquals(50, p.getSummary().getDiversityScore()); assertEquals(22, p.getSummary().getBeneficialScore()); assertEquals(26, p.getSummary().getHarmfulScore()); assertEquals(85, p.getSummary().getCoreGenusScore()); } } ``` - [ ] **Step 2: 运行测试确认失败** Run: `mvn test -Dtest=ReportBlockAssemblerTest -DfailIfNoTests=false`(在 `cfc-backend/`) Expected: FAIL(编译错误:ReportBlockAssembler 不存在) - [ ] **Step 3: 创建 ReportBlockAssembler(核心实现)** ```java package com.etotem.cfc.service; import com.etotem.cfc.dto.ParsedReportPayload; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 报告通用展示块组装器 — 解析结果 → blocks JSON 结构 * 前端只消费 blocks,不接触实体字段名。 */ @Service public class ReportBlockAssembler { private static final java.util.Set IMPORTANT_LEVELS = new java.util.HashSet<>(java.util.Arrays.asList("需注意", "注意", "高风险", "异常")); /** 判断是否重要风险(需注意/注意/高风险/异常 → 重要;低风险/其余 → 其它) */ public boolean isImportantRisk(String level) { return level != null && IMPORTANT_LEVELS.contains(level); } /** 编辑链路:第一套键 Map → 标准 Payload(第二套键) */ public ParsedReportPayload.Payload fromMap(Map map) { ParsedReportPayload.Payload p = new ParsedReportPayload.Payload(); if (map == null || map.isEmpty()) return p; ParsedReportPayload.Summary s = new ParsedReportPayload.Summary(); s.setOverallScore(toInt(map.get("overallScore"))); s.setGutHealthScore(toInt(map.get("gutHealthScore"))); s.setChronicDiseaseScore(toInt(map.get("chronicDiseaseScore"))); s.setNutritionScore(toInt(map.get("nutritionScore"))); s.setBalanceScore(toInt(map.get("gutBalanceScore"))); s.setDiversityScore(toInt(map.get("gutDiversityScore"))); s.setBeneficialScore(toInt(map.get("beneficialBacteriaScore"))); s.setHarmfulScore(toInt(map.get("harmfulBacteriaScore"))); s.setCoreGenusScore(toInt(map.get("coreSpeciesScore"))); s.setGutAge(str(map.get("gutAge"))); s.setGutType(str(map.get("gutType"))); p.setSummary(s); return p; } /** 肠道菌群报告 → blocks */ public List> assembleGutFlora(ParsedReportPayload.Payload payload) { List> blocks = new ArrayList<>(); if (payload == null) return blocks; // 1. score 块:9 项评分(null 跳过) ParsedReportPayload.Summary s = payload.getSummary(); if (s != null) { List> items = new ArrayList<>(); addScore(items, "综合", s.getOverallScore()); addScore(items, "菌群健康", s.getGutHealthScore()); addScore(items, "慢病控制", s.getChronicDiseaseScore()); addScore(items, "营养均衡", s.getNutritionScore()); addScore(items, "平衡", s.getBalanceScore()); addScore(items, "多样性", s.getDiversityScore()); addScore(items, "有益菌", s.getBeneficialScore()); addScore(items, "有害菌", s.getHarmfulScore()); addScore(items, "核心菌属", s.getCoreGenusScore()); if (!items.isEmpty()) { Map block = new LinkedHashMap<>(); block.put("type", "score"); block.put("title", "健康评分"); block.put("items", items); blocks.add(block); } } // 2. risk_group 块:按风险等级分两组 List risks = payload.getDiseaseRisks(); if (risks != null && !risks.isEmpty()) { List> important = new ArrayList<>(); List> normal = new ArrayList<>(); for (ParsedReportPayload.DiseaseRisk r : risks) { Map m = new LinkedHashMap<>(); m.put("name", r.getDiseaseName()); m.put("value", r.getRiskValue()); m.put("level", r.getRiskLevel()); if (isImportantRisk(r.getRiskLevel())) important.add(m); else normal.add(m); } if (!important.isEmpty()) blocks.add(riskBlock("重要风险", important)); if (!normal.isEmpty()) blocks.add(riskBlock("其它风险", normal)); } // 3. indicator 块:indicators 按 category 分组 List inds = payload.getIndicators(); if (inds != null && !inds.isEmpty()) { Map>> byCategory = new LinkedHashMap<>(); for (ParsedReportPayload.Indicator ind : inds) { String cat = ind.getCategory() == null ? "其他指标" : ind.getCategory(); byCategory.computeIfAbsent(cat, k -> new ArrayList<>()).add(indicatorItem(ind)); } for (Map.Entry>> e : byCategory.entrySet()) { Map block = new LinkedHashMap<>(); block.put("type", "indicator"); block.put("title", e.getKey()); block.put("items", e.getValue()); blocks.add(block); } } // 4. list 块:菌种列表(gutFlora + 病原菌)与食物推荐 List> floraItems = new ArrayList<>(); addFloraItems(floraItems, payload.getGutFlora()); addFloraItems(floraItems, payload.getPathogenGenus()); addFloraItems(floraItems, payload.getPathogenDetection()); if (!floraItems.isEmpty()) { Map block = new LinkedHashMap<>(); block.put("type", "list"); block.put("title", "菌种详情"); block.put("columns", java.util.Arrays.asList( col("name", "菌种"), col("value", "数值"), col("range", "正常范围"), col("status", "状态"))); block.put("items", floraItems); blocks.add(block); } List foods = payload.getFoods(); if (foods != null && !foods.isEmpty()) { List> foodItems = new ArrayList<>(); for (ParsedReportPayload.FoodItem f : foods) { Map m = new LinkedHashMap<>(); m.put("name", f.getName()); m.put("category", f.getCategory()); m.put("score", f.getScore()); foodItems.add(m); } Map block = new LinkedHashMap<>(); block.put("type", "list"); block.put("title", "食物推荐"); block.put("columns", java.util.Arrays.asList( col("name", "食材"), col("category", "类别"), col("score", "推荐指数"))); block.put("items", foodItems); blocks.add(block); } return blocks; } private Map riskBlock(String title, List> items) { Map block = new LinkedHashMap<>(); block.put("type", "risk_group"); block.put("title", title); block.put("items", items); return block; } private Map indicatorItem(ParsedReportPayload.Indicator ind) { Map m = new LinkedHashMap<>(); m.put("name", ind.getIndicatorName()); m.put("value", ind.getIndicatorValue()); m.put("unit", ind.getUnit()); m.put("refRange", ind.getRefRange()); m.put("status", ind.getStatus()); return m; } private void addFloraItems(List> items, List list) { if (list == null) return; for (ParsedReportPayload.Flora f : list) { Map m = new LinkedHashMap<>(); m.put("name", f.getBacteriaName()); m.put("value", f.getBacteriaValue()); m.put("range", f.getNormalRange()); m.put("status", f.getPopulationLevel() != null ? f.getPopulationLevel() : f.getLevel()); items.add(m); } } private void addScore(List> items, String label, Integer value) { if (value == null) return; Map m = new LinkedHashMap<>(); m.put("label", label); m.put("value", value); m.put("max", 100); items.add(m); } private Map col(String key, String label) { Map c = new LinkedHashMap<>(); c.put("key", key); c.put("label", label); return c; } private Integer toInt(Object v) { if (v == null) return null; try { return Integer.valueOf(v.toString()); } catch (Exception e) { return null; } } private String str(Object v) { return v == null ? null : v.toString(); } } ``` - [ ] **Step 4: 运行测试确认通过** Run: `mvn test -Dtest=ReportBlockAssemblerTest -DfailIfNoTests=false` Expected: PASS(3 个测试全绿) - [ ] **Step 5: Commit** ```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): ReportBlockAssembler gut_flora 组装 + fromMap 键名转换" ``` --- ### Task 4: ReportBlockAssembler — DAN 组装 **Files:** - Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ReportBlockAssembler.java`(追加 assembleDanFromJson) - Modify: `cfc-backend/src/test/java/com/etotem/cfc/service/ReportBlockAssemblerTest.java`(追加测试) **Interfaces:** - Produces: `ReportBlockAssembler.assembleDanFromJson(String itemsJson, String summary, String suggestions)` → `List>`(Task 5 confirmUpload 挂点调用) - Consumes: `DanReportParseService.DataItem`(service 包内 public static class) - [ ] **Step 1: 追加失败测试** ```java @Test void assembleDan_shouldExtractScoreListAndText() { String itemsJson = "[{\"code\":\"total_score\",\"name\":\"总得分\",\"value\":\"112\",\"category\":\"\"}," + "{\"code\":\"percentile\",\"name\":\"百分位\",\"value\":\"79\",\"category\":\"\"}," + "{\"code\":\"perception_score\",\"name\":\"感知觉得分\",\"value\":\"41\",\"category\":\"认知维度\"}," + "{\"code\":\"attention_score\",\"name\":\"注意力得分\",\"value\":\"55\",\"category\":\"认知维度\"}]"; List> blocks = assembler.assembleDanFromJson(itemsJson, "综合评语", "成长建议"); Map scoreBlock = blocks.stream() .filter(b -> "score".equals(b.get("type"))).findFirst().orElse(null); assertNotNull(scoreBlock, "应有score块"); assertEquals("总得分", ((List>) scoreBlock.get("items")).get(0).get("label")); // text 块:summary + suggestions long textCount = blocks.stream().filter(b -> "text".equals(b.get("type"))).count(); assertEquals(2, textCount, "应有summary和suggestions两个text块"); // list 块:认知维度分组 Map listBlock = blocks.stream() .filter(b -> "list".equals(b.get("type"))).findFirst().orElse(null); assertNotNull(listBlock, "应有list块"); assertEquals(2, ((List>) listBlock.get("items")).size()); } ``` - [ ] **Step 2: 运行测试确认失败** Run: `mvn test -Dtest=ReportBlockAssemblerTest -DfailIfNoTests=false` Expected: FAIL(assembleDanFromJson 方法不存在) - [ ] **Step 3: 追加实现** ```java /** DAN 报告 → blocks(itemsJson 为 DanParsedReport.items 的 JSON 序列化) */ public List> assembleDanFromJson(String itemsJson, String summary, String suggestions) { List> blocks = new ArrayList<>(); List 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>() {}); } catch (Exception e) { // 解析失败按空处理 } } // score 块:total_score / percentile List> scoreItems = new ArrayList<>(); List> listItems = new ArrayList<>(); 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 { Map m = new LinkedHashMap<>(); m.put("name", item.getName()); m.put("value", item.getValue()); listItems.add(m); } } if (!scoreItems.isEmpty()) { Map block = new LinkedHashMap<>(); block.put("type", "score"); block.put("title", "测评结果"); block.put("items", scoreItems); blocks.add(block); } if (!listItems.isEmpty()) { Map block = new LinkedHashMap<>(); block.put("type", "list"); block.put("title", "维度得分"); block.put("columns", java.util.Arrays.asList(col("name", "项目"), col("value", "得分"))); block.put("items", listItems); 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; } private Map scoreItem(String label, String value) { Map m = new LinkedHashMap<>(); m.put("label", label); m.put("value", value); m.put("max", 100); return m; } private Map textBlock(String title, String content) { Map block = new LinkedHashMap<>(); block.put("type", "text"); block.put("title", title); block.put("content", content); return block; } ``` - [ ] **Step 4: 运行测试确认通过** Run: `mvn test -Dtest=ReportBlockAssemblerTest -DfailIfNoTests=false` Expected: PASS(4 个测试全绿) - [ ] **Step 5: Commit** ```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): ReportBlockAssembler DAN 组装(score/list/text)" ``` --- ### Task 5: 落库挂点(3 处) **Files:** - Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java`(confirmDraft 880-893 区域) - Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.java`(confirmUpload 190-205 区域) - Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java`(updateReportFromPayload 末尾) **Interfaces:** - Consumes: `ReportBlockService`(Task 2)、`ReportBlockAssembler`(Task 3/4) - Produces: `report_blocks` 表数据(Task 6 读取) - [ ] **Step 1: confirmDraft 挂点**(HealthReportController.java,在 `HealthReport created = healthReportService.createReport(...)` 之后追加) ```java // 落库通用展示块(blocks 组装失败不阻断确认) try { reportBlockService.save("gut_flora", created.getId(), reportBlockAssembler.assembleGutFlora(payload)); } catch (Exception e) { log.warn("保存gut_flora展示块失败 reportId={}: {}", created.getId(), e.getMessage()); } ``` 并在类顶部注入: ```java @Resource private ReportBlockService reportBlockService; @Resource private ReportBlockAssembler reportBlockAssembler; ``` (注:controller 层依赖 service 符合本项目现有模式——HealthReportController 已注入 healthReportService 等) - [ ] **Step 2: confirmUpload 挂点**(DanReportUploadService.java,在 `uploadMapper.updateById(upload);`(198 行)之后、syncFromDanReport 之前追加) ```java // 落库 DAN 展示块(优先 edited_*,否则 parsed_*,与创建 result 同源) try { String itemsJson = upload.getEditedItems() != null ? upload.getEditedItems() : upload.getParsedItems(); String summary = upload.getEditedSummary() != null ? upload.getEditedSummary() : upload.getParsedSummary(); String suggestions = upload.getEditedSuggestions() != null ? upload.getEditedSuggestions() : upload.getParsedSuggestions(); reportBlockService.save("dan", upload.getId(), reportBlockAssembler.assembleDanFromJson(itemsJson, summary, suggestions)); } catch (Exception e) { log.warn("保存dan展示块失败 uploadId={}: {}", uploadId, e.getMessage()); } ``` 并在类顶部注入(`@Resource private ReportBlockService reportBlockService;` 与 `@Resource private ReportBlockAssembler reportBlockAssembler;`,注意该类字段名需与 Bean Name 一致)。 - [ ] **Step 3: updateReportFromPayload 挂点**(HealthReportService.java,方法末尾、最后一行更新语句之后追加) ```java // 编辑后重新组装展示块(第一套键 Map → 标准 Payload → blocks) try { ParsedReportPayload.Payload editedPayload = reportBlockAssembler.fromMap(payload); reportBlockService.save("gut_flora", reportId, reportBlockAssembler.assembleGutFlora(editedPayload)); } catch (Exception e) { log.warn("编辑后刷新展示块失败 reportId={}: {}", reportId, e.getMessage()); } ``` 并在类顶部注入(注意:HealthReportService 已有大量 @Resource 字段,追加时保持字段名与 Bean Name 一致)。 - [ ] **Step 4: 编译验证** Run: `mvn clean compile` Expected: BUILD SUCCESS - [ ] **Step 5: Commit** ```bash git add cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java cfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.java cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java git commit -m "feat(backend): 三个落库挂点写入 report_blocks(confirmDraft/confirmUpload/编辑)" ``` --- ### Task 6: 接口返回 blocks **Files:** - Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java`(getReportDetail 312-330) - Modify: `cfc-backend/src/main/java/com/etotem/cfc/dto/DanReportUploadVO.java`(追加 blocks 字段) - Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.java`(getDetail 填充 blocks) **Interfaces:** - Consumes: `ReportBlockService.getBlocks`(Task 2) - Produces: `getReportDetail` 返回 `data.blocks`(肠道菌群详情);`DanReportController.detail` 返回 `data.blocks`(DAN 详情,经 VO) - [ ] **Step 1: getReportDetail 追加 blocks**(HealthReportService.java,`detail.put("diseaseRisks", diseaseRisks);` 之后) ```java // 通用展示块(blocks 为空时前端回退旧字段) detail.put("blocks", reportBlockService.getBlocks(report.getReportType(), reportId)); ``` 并在类顶部注入 `@Resource private ReportBlockService reportBlockService;`。 - [ ] **Step 2: DanReportUploadVO 追加 blocks 字段** ```java /** 通用展示块(从 report_blocks 表读取) */ private List> blocks; ``` (import `java.util.List` / `java.util.Map`;`fromEntity` 不负责填充 blocks,由 service 层填充) - [ ] **Step 3: getDetail 填充 blocks**(DanReportUploadService.getDetail 内,`DanReportUploadVO.fromEntity(upload)` 结果上) ```java DanReportUploadVO vo = DanReportUploadVO.fromEntity(upload); vo.setBlocks(reportBlockService.getBlocks("dan", upload.getId())); return Result.success(vo); ``` (若 getDetail 现有实现为 `return Result.success(DanReportUploadVO.fromEntity(upload));` 则改为上述两行;确认 getDetail 中 upload 非空判断) - [ ] **Step 4: 编译验证** Run: `mvn clean compile` Expected: BUILD SUCCESS - [ ] **Step 5: Commit** ```bash git add cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java cfc-backend/src/main/java/com/etotem/cfc/dto/DanReportUploadVO.java cfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.java git commit -m "feat(backend): getReportDetail/DAN detail 返回 blocks" ``` --- ### Task 7: 前端通用渲染器 report-blocks-renderer **Files:** - Create: `cfc-frontend/components/report-blocks-renderer.vue` **Interfaces:** - Consumes: `props: { blocks: Array }`(Task 8 详情页传入) - Produces: 按 `block.type` 渲染 6 类块(score/risk_group/indicator/list/text/chart) - [ ] **Step 1: 创建组件(template + script + style)** ```vue ``` (注意:list 块的 `v-for` 里用 `:style="'flex:' + (ci === 0 ? 2 : 1)"`——内联表达式无方法调用,符合小程序约束;`:key` 用索引拼接无运算符表达式) - [ ] **Step 2: 语法校验** Run: 提取 script 块执行 `node --check`(可先跑 `node -e "const s=require('fs').readFileSync('cfc-frontend/components/report-blocks-renderer.vue','utf8');const m=s.match(/ ``` - [ ] **Step 3: pages.json 分包注册** 在 `pages/health` 分包(`subPackages` 中 `root: "pages/health"` 的 `pages` 数组)追加: ```json { "path": "report-detail", "style": { "navigationBarTitleText": "报告详情" } } ``` - [ ] **Step 4: 语法/结构校验** Run: `node --check`(script 块)+ 目检 pages.json 格式(JSON 可 `node -e "JSON.parse(require('fs').readFileSync('cfc-frontend/pages.json','utf8'))"`) Expected: 无错误 - [ ] **Step 5: Commit** ```bash git add cfc-frontend/pages/health/report-detail.vue cfc-frontend/utils/api.js cfc-frontend/pages.json git commit -m "feat(frontend): 统一报告详情页 report-detail(blocks 渲染)" ``` --- ### Task 9: 入口切换(report-list / report-confirm / 维度页) **Files:** - Modify: `cfc-frontend/pages/health/report-list.vue`(goToDetail 79-87) - Modify: `cfc-frontend/pages/health/report-confirm.vue`(816-820 确认后跳转) - Modify: `cfc-frontend/pages/wisdom-detail/index.vue`(viewDanReport 370-372) - Modify: `cfc-frontend/pages/mind-detail/index.vue`(viewDanReport 同构位置) **Interfaces:** - Consumes: `pages/health/report-detail`(Task 8) - Produces: 用户从列表/确认/维度页进入新详情页 - [ ] **Step 1: report-list.vue goToDetail 切换** ```javascript goToDetail(reportId, reportType) { var detailPages = { 'gut_flora': '/pages/health/report-detail?reportType=gut_flora', 'dan': '/pages/health/report-detail?reportType=dan', 'physical_exam': '/pages/health/physical-exam-detail', 'tongue': '/pages/health/tongue-index' } var url = detailPages[reportType] || '/pages/body/health-report' var sep = url.indexOf('?') >= 0 ? '&' : '?' uni.navigateTo({ url: url + sep + 'reportId=' + reportId }) } ``` - [ ] **Step 2: report-confirm.vue 确认后跳转**(816-820) ```javascript if (reportType === 'gut_flora') { uni.redirectTo({ url: '/pages/health/report-detail?reportType=gut_flora&reportId=' + reportId }) } else { uni.redirectTo({ url: '/pages/body/health-report?reportId=' + reportId }) } ``` - [ ] **Step 3: 维度页 viewDanReport 切换**(wisdom-detail/index.vue 370-372 + mind-detail 同构) ```javascript viewDanReport: function(report) { var reportId = report.sourceReportId || report.id uni.navigateTo({ url: '/pages/health/report-detail?reportType=dan&reportId=' + reportId }) } ``` - [ ] **Step 4: 语法校验** Run: `node --check`(各修改文件 script 块) Expected: 无错误 - [ ] **Step 5: Commit** ```bash git add cfc-frontend/pages/health/report-list.vue cfc-frontend/pages/health/report-confirm.vue cfc-frontend/pages/wisdom-detail/index.vue cfc-frontend/pages/mind-detail/index.vue git commit -m "feat(frontend): 报告入口切换到统一详情页" ``` --- ### Task 10: 疾病风险内联编辑 + 全量验证 **Files:** - Modify: `cfc-frontend/pages/health/report-detail.vue`(追加风险编辑) - Verify: `cfc-backend` 全量编译 + 测试 **Interfaces:** - Consumes: `editHealthReport(reportId, payload, subjectId)`(api.js 已有,POST /api/health/report/edit) - Produces: 风险等级/值可编辑,保存后 blocks 刷新(后端 updateReportFromPayload 重新组装) - [ ] **Step 1: report-detail.vue 追加风险编辑能力** 在 data 中追加:`isEditing: false` / `editRisks: []`。nav-bar 追加"编辑/完成"入口: ```html {{ isEditing ? '完成' : '编辑' }} ``` 风险编辑表单(在 blocks 渲染区之后追加,`v-if="isEditing"`): ```html {{ risk.diseaseName }} 风险值: 等级: {{ risk.riskLevel || '请选择' }} ``` methods 追加: ```javascript hasRiskBlocks: function() { var blocks = this.blocks || [] for (var i = 0; i < blocks.length; i++) { if (blocks[i].type === 'risk_group' && blocks[i].items && blocks[i].items.length > 0) return true } return false }, toggleRiskEdit: function() { var self = this if (this.isEditing) { // 保存:调 editHealthReport 走现有结构化编辑接口,后端重新组装 blocks editHealthReport(this.reportId, { diseaseRisks: this.editRisks }, 0).then(function(res) { if (res.code === 200) { uni.showToast({ title: '已保存', icon: 'success' }) self.isEditing = false self.loadData() } else { uni.showToast({ title: res.message || '保存失败', icon: 'none' }) } }) } else { // 进入编辑:从 blocks 收集风险项(name/value/level → diseaseName/riskValue/riskLevel) var risks = [] var blocks = this.blocks || [] for (var i = 0; i < blocks.length; i++) { if (blocks[i].type === 'risk_group' && blocks[i].items) { for (var j = 0; j < blocks[i].items.length; j++) { risks.push({ diseaseName: blocks[i].items[j].name, riskValue: blocks[i].items[j].value, riskLevel: blocks[i].items[j].level }) } } } this.editRisks = JSON.parse(JSON.stringify(risks)) this.isEditing = true } }, riskLevelIndex: function(level) { var idx = this.riskLevels.indexOf(level) return idx >= 0 ? idx : 0 }, onRiskLevelChange: function(e, risk) { risk.riskLevel = this.riskLevels[e.detail.value] } ``` (data 中追加 `riskLevels: ['低风险', '需注意', '高风险', '异常']`;import 追加 `editHealthReport`。样式 `.edit-panel/.edit-card/.edit-row/.edit-label/.edit-input/.edit-picker` 参照 `gut-flora-risks-detail.vue` 现有编辑样式——白底圆角卡片 + flex 行布局。等级选项覆盖后端 `isImportantRisk` 集合,保证编辑后分组一致) - [ ] **Step 2: 后端全量编译 + 测试** Run: `mvn clean compile` 然后 `mvn test -Dtest=ReportBlockAssemblerTest -DfailIfNoTests=false` Expected: BUILD SUCCESS + 4 个测试 PASS - [ ] **Step 3: 前端语法校验** Run: `node --check`(report-detail.vue script 块) Expected: 无错误 - [ ] **Step 4: Commit** ```bash git add cfc-frontend/pages/health/report-detail.vue git commit -m "feat(frontend): 疾病风险内联编辑(保存后刷新 blocks)" ``` --- ## 验收标准(全部完成后人工/模拟器验证) 1. 上传并确认 501999942 报告(肠道菌群)→ 进入新详情页:9 项评分圆环全显示(含多样性 50) 2. 疾病风险直接展示在详情页(重要风险/其它风险两组),编辑等级/值保存后刷新 3. DAN A2/B4 报告在智/心维度页点击 → 新详情页渲染 score/list/text 块 4. 旧页面(gut-flora-detail / physical-exam-detail / tongue)不受影响 5. blocks 空时显示"暂无报告内容"且不报错