Explorar o código

docs: 通用报告块渲染引擎实施计划

asus hai 1 mes
pai
achega
27cde959ca
Modificáronse 1 ficheiros con 1260 adicións e 0 borrados
  1. 1260 0
      docs/superpowers/plans/2026-08-10-report-blocks-renderer.md

+ 1260 - 0
docs/superpowers/plans/2026-08-10-report-blocks-renderer.md

@@ -0,0 +1,1260 @@
+# 通用报告块渲染引擎 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<Map<String, Object>> blocks)`(upsert,Task 5 挂点调用)
+- Produces: `ReportBlockService.getBlocks(String reportType, Long reportId)` → `List<Map<String, Object>>`(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<ReportBlock> {
+}
+```
+
+- [ ] **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<Map<String, Object>> blocks) {
+        if (blocks == null || blocks.isEmpty()) return;
+        try {
+            ReportBlock existing = reportBlockMapper.selectOne(
+                    new LambdaQueryWrapper<ReportBlock>()
+                            .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<Map<String, Object>> getBlocks(String reportType, Long reportId) {
+        try {
+            ReportBlock existing = reportBlockMapper.selectOne(
+                    new LambdaQueryWrapper<ReportBlock>()
+                            .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<List<Map<String, Object>>>() {});
+        } 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<Map<String, Object>>`(Task 5 confirmDraft 挂点调用)
+- Produces: `ReportBlockAssembler.fromMap(Map<String, Object> 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<Map<String, Object>> blocks = assembler.assembleGutFlora(samplePayload());
+        Map<String, Object> scoreBlock = blocks.stream()
+                .filter(b -> "score".equals(b.get("type"))).findFirst().orElse(null);
+        assertNotNull(scoreBlock, "应有score块");
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> items = (List<Map<String, Object>>) 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<Map<String, Object>> blocks = assembler.assembleGutFlora(samplePayload());
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> riskBlocks = (List<Map<String, Object>>) (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<String, Object> 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<String> 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<String, Object> 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<Map<String, Object>> assembleGutFlora(ParsedReportPayload.Payload payload) {
+        List<Map<String, Object>> blocks = new ArrayList<>();
+        if (payload == null) return blocks;
+
+        // 1. score 块:9 项评分(null 跳过)
+        ParsedReportPayload.Summary s = payload.getSummary();
+        if (s != null) {
+            List<Map<String, Object>> 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<String, Object> block = new LinkedHashMap<>();
+                block.put("type", "score");
+                block.put("title", "健康评分");
+                block.put("items", items);
+                blocks.add(block);
+            }
+        }
+
+        // 2. risk_group 块:按风险等级分两组
+        List<ParsedReportPayload.DiseaseRisk> risks = payload.getDiseaseRisks();
+        if (risks != null && !risks.isEmpty()) {
+            List<Map<String, Object>> important = new ArrayList<>();
+            List<Map<String, Object>> normal = new ArrayList<>();
+            for (ParsedReportPayload.DiseaseRisk r : risks) {
+                Map<String, Object> 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<ParsedReportPayload.Indicator> inds = payload.getIndicators();
+        if (inds != null && !inds.isEmpty()) {
+            Map<String, List<Map<String, Object>>> 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<String, List<Map<String, Object>>> e : byCategory.entrySet()) {
+                Map<String, Object> block = new LinkedHashMap<>();
+                block.put("type", "indicator");
+                block.put("title", e.getKey());
+                block.put("items", e.getValue());
+                blocks.add(block);
+            }
+        }
+
+        // 4. list 块:菌种列表(gutFlora + 病原菌)与食物推荐
+        List<Map<String, Object>> floraItems = new ArrayList<>();
+        addFloraItems(floraItems, payload.getGutFlora());
+        addFloraItems(floraItems, payload.getPathogenGenus());
+        addFloraItems(floraItems, payload.getPathogenDetection());
+        if (!floraItems.isEmpty()) {
+            Map<String, Object> 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<ParsedReportPayload.FoodItem> foods = payload.getFoods();
+        if (foods != null && !foods.isEmpty()) {
+            List<Map<String, Object>> foodItems = new ArrayList<>();
+            for (ParsedReportPayload.FoodItem f : foods) {
+                Map<String, Object> m = new LinkedHashMap<>();
+                m.put("name", f.getName());
+                m.put("category", f.getCategory());
+                m.put("score", f.getScore());
+                foodItems.add(m);
+            }
+            Map<String, Object> 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<String, Object> riskBlock(String title, List<Map<String, Object>> items) {
+        Map<String, Object> block = new LinkedHashMap<>();
+        block.put("type", "risk_group");
+        block.put("title", title);
+        block.put("items", items);
+        return block;
+    }
+
+    private Map<String, Object> indicatorItem(ParsedReportPayload.Indicator ind) {
+        Map<String, Object> 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<Map<String, Object>> items, List<ParsedReportPayload.Flora> list) {
+        if (list == null) return;
+        for (ParsedReportPayload.Flora f : list) {
+            Map<String, Object> 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<Map<String, Object>> items, String label, Integer value) {
+        if (value == null) return;
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("label", label);
+        m.put("value", value);
+        m.put("max", 100);
+        items.add(m);
+    }
+
+    private Map<String, Object> col(String key, String label) {
+        Map<String, Object> 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<Map<String, Object>>`(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<Map<String, Object>> blocks = assembler.assembleDanFromJson(itemsJson, "综合评语", "成长建议");
+    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"));
+    // text 块:summary + suggestions
+    long textCount = blocks.stream().filter(b -> "text".equals(b.get("type"))).count();
+    assertEquals(2, textCount, "应有summary和suggestions两个text块");
+    // list 块:认知维度分组
+    Map<String, Object> listBlock = blocks.stream()
+            .filter(b -> "list".equals(b.get("type"))).findFirst().orElse(null);
+    assertNotNull(listBlock, "应有list块");
+    assertEquals(2, ((List<Map<String, Object>>) 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<Map<String, Object>> assembleDanFromJson(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) {
+                // 解析失败按空处理
+            }
+        }
+        // score 块:total_score / percentile
+        List<Map<String, Object>> scoreItems = new ArrayList<>();
+        List<Map<String, Object>> 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<String, Object> m = new LinkedHashMap<>();
+                m.put("name", item.getName());
+                m.put("value", item.getValue());
+                listItems.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);
+        }
+        if (!listItems.isEmpty()) {
+            Map<String, Object> 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<String, Object> scoreItem(String label, String value) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("label", label);
+        m.put("value", value);
+        m.put("max", 100);
+        return m;
+    }
+
+    private Map<String, Object> textBlock(String title, String content) {
+        Map<String, Object> 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<Map<String, Object>> 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
+<template>
+  <view class="blocks-renderer">
+    <view v-for="(block, bi) in blocks" :key="'b' + bi" class="block-wrap">
+      <!-- score: 评分圆环组 -->
+      <view v-if="block.type === 'score'" class="block score-block">
+        <view class="block-title">{{ block.title }}</view>
+        <view class="score-grid">
+          <view class="score-item" v-for="(item, si) in block.items" :key="'s' + si">
+            <view class="score-circle" :style="'border-color:' + (item.color || '#4A9BD7')">
+              <text class="score-value">{{ item.value }}</text>
+            </view>
+            <text class="score-label">{{ item.label }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- risk_group: 风险分组(按等级徽章) -->
+      <view v-else-if="block.type === 'risk_group'" class="block risk-block">
+        <view class="block-title">{{ block.title }}</view>
+        <view class="risk-card" v-for="(item, ri) in block.items" :key="'r' + ri"
+              :class="'risk-level-' + riskCss(item.level)">
+          <text class="risk-name">{{ item.name }}</text>
+          <text class="risk-value">风险值: {{ item.value || '--' }}</text>
+          <text class="risk-badge" :class="'badge-' + riskCss(item.level)">{{ riskText(item.level) }}</text>
+        </view>
+      </view>
+
+      <!-- indicator: 指标明细 -->
+      <view v-else-if="block.type === 'indicator'" class="block indicator-block">
+        <view class="block-title">{{ block.title }}</view>
+        <view class="indicator-item" v-for="(item, ii) in block.items" :key="'i' + ii">
+          <text class="ind-name">{{ item.name }}</text>
+          <text class="ind-value">{{ item.value }} {{ item.unit }}</text>
+          <text class="ind-status" :class="'status-' + statusCss(item.status)">{{ item.status }}</text>
+        </view>
+      </view>
+
+      <!-- list: 通用列表(columns 驱动) -->
+      <view v-else-if="block.type === 'list'" class="block list-block">
+        <view class="block-title">{{ block.title }}</view>
+        <view class="list-head" v-if="block.columns">
+          <text class="list-cell head-cell" v-for="(colItem, ci) in block.columns" :key="'c' + ci"
+                :style="'flex:' + (ci === 0 ? 2 : 1)">{{ colItem.label }}</text>
+        </view>
+        <view class="list-row" v-for="(item, li) in block.items" :key="'l' + li">
+          <text class="list-cell" :style="'flex:' + (ci === 0 ? 2 : 1)"
+                v-for="(colItem, ci) in block.columns" :key="'lc' + ci">{{ item[colItem.key] || '--' }}</text>
+        </view>
+      </view>
+
+      <!-- text: 文本段落 -->
+      <view v-else-if="block.type === 'text'" class="block text-block">
+        <view class="block-title">{{ block.title }}</view>
+        <text class="text-content">{{ block.content }}</text>
+      </view>
+
+      <!-- chart: 预留 -->
+      <view v-else class="block chart-block">
+        <view class="block-title">{{ block.title }}</view>
+        <text class="chart-placeholder">图表组件开发中</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'ReportBlocksRenderer',
+  props: {
+    blocks: { type: Array, default: function() { return [] } }
+  },
+  methods: {
+    riskCss: function(level) {
+      var important = ['需注意', '注意', '高风险', '异常']
+      return important.indexOf(level) >= 0 ? 'warning' : 'low'
+    },
+    riskText: function(level) {
+      var map = { '低风险': '✓ 低', '需注意': '⚠ 注意', '注意': '⚠ 注意', '高风险': '⚠ 高', '异常': '⚠ 异常' }
+      return map[level] || level || ''
+    },
+    statusCss: function(status) {
+      var map = { '偏高': 'high', '偏低': 'low', '缺乏': 'low', '不足': 'low', '过多': 'high', '异常': 'high' }
+      return map[status] || 'normal'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.blocks-renderer { width: 100%; }
+.block { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
+.block-title { font-size: 30rpx; font-weight: 600; color: #333; margin-bottom: 20rpx; }
+/* score */
+.score-grid { display: flex; flex-wrap: wrap; }
+.score-item { width: 33.33%; display: flex; flex-direction: column; align-items: center; margin-bottom: 24rpx; }
+.score-circle { width: 96rpx; height: 96rpx; border-radius: 50%; border: 8rpx solid; display: flex; align-items: center; justify-content: center; }
+.score-value { font-size: 30rpx; font-weight: 600; color: #333; }
+.score-label { font-size: 24rpx; color: #666; margin-top: 8rpx; }
+/* risk */
+.risk-card { display: flex; align-items: center; padding: 16rpx 0; border-bottom: 1rpx solid #f0f0f0; }
+.risk-card:last-child { border-bottom: none; }
+.risk-name { flex: 1; font-size: 28rpx; color: #333; }
+.risk-value { font-size: 24rpx; color: #999; margin-right: 16rpx; }
+.risk-badge { padding: 4rpx 14rpx; border-radius: 20rpx; font-size: 22rpx; }
+.badge-warning { background: #FFF3E0; color: #E65100; }
+.badge-low { background: #E8F5E9; color: #2E7D32; }
+/* indicator */
+.indicator-item { display: flex; align-items: center; padding: 14rpx 0; border-bottom: 1rpx solid #f0f0f0; }
+.indicator-item:last-child { border-bottom: none; }
+.ind-name { flex: 1; font-size: 26rpx; color: #333; }
+.ind-value { font-size: 26rpx; color: #666; margin-right: 16rpx; }
+.ind-status { font-size: 22rpx; padding: 2rpx 12rpx; border-radius: 16rpx; }
+.status-high { background: #FFEBEE; color: #C62828; }
+.status-low { background: #FFF3E0; color: #E65100; }
+.status-normal { background: #E8F5E9; color: #2E7D32; }
+/* list */
+.list-head { display: flex; padding: 12rpx 0; border-bottom: 2rpx solid #eee; }
+.list-row { display: flex; padding: 12rpx 0; border-bottom: 1rpx solid #f5f5f5; }
+.list-cell { font-size: 24rpx; color: #333; }
+.head-cell { font-size: 24rpx; color: #999; font-weight: 600; }
+/* text */
+.text-content { font-size: 26rpx; color: #555; line-height: 1.7; white-space: pre-wrap; }
+.chart-placeholder { font-size: 24rpx; color: #ccc; }
+</style>
+```
+
+(注意: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(/<script>([\s\S]*)<\/script>/);require('child_process').execSync('node --check',{input:m[1]})"` 或直接目检)
+Expected: 无语法错误
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-frontend/components/report-blocks-renderer.vue
+git commit -m "feat(frontend): 通用报告块渲染器 report-blocks-renderer"
+```
+
+---
+
+### Task 8: 前端统一详情页 report-detail.vue
+
+**Files:**
+- Create: `cfc-frontend/pages/health/report-detail.vue`
+- Modify: `cfc-frontend/utils/api.js`(追加 getDanReportDetail)
+- Modify: `cfc-frontend/pages.json`(分包注册新页面,pages/health 分包内追加)
+
+**Interfaces:**
+- Consumes: `getReportDetail`(已有,返回含 blocks)、`getDanReportDetail`(本 Task 新增)、`ReportBlocksRenderer`(Task 7)
+- Produces: `pages/health/report-detail`(Task 9 入口跳转目标)
+
+- [ ] **Step 1: api.js 追加 DAN 详情接口**
+
+```javascript
+// DAN 报告详情(含 blocks)
+export const getDanReportDetail = (id) => request('/api/dan-report/' + id, 'POST', {})
+```
+
+- [ ] **Step 2: 创建 report-detail.vue**
+
+```vue
+<template>
+  <view class="page">
+    <view class="nav-bar">
+      <view class="nav-back" @tap="goBack"><text class="back-text">‹ 返回</text></view>
+      <text class="nav-title">{{ typeLabel }}</text>
+    </view>
+    <scroll-view class="content" scroll-y>
+      <!-- 报告头 -->
+      <view class="report-head" v-if="headInfo">
+        <view class="head-name">{{ headInfo.personName || headInfo.childName || '健康报告' }}</view>
+        <view class="head-meta">
+          <text class="type-badge">{{ typeLabel }}</text>
+          <text class="head-date">{{ headInfo.reportDateText || '' }}</text>
+        </view>
+      </view>
+      <!-- blocks 渲染 -->
+      <report-blocks-renderer v-if="blocks && blocks.length > 0" :blocks="blocks" />
+      <view class="empty-hint" v-else>暂无报告内容</view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+import ReportBlocksRenderer from '../../components/report-blocks-renderer.vue'
+import { getReportDetail, getDanReportDetail } from '../../utils/api.js'
+import { parseDate } from '../../utils/format.js'
+
+export default {
+  components: { ReportBlocksRenderer },
+  data() {
+    return {
+      reportType: '',
+      reportId: null,
+      blocks: [],
+      headInfo: null
+    }
+  },
+  computed: {
+    typeLabel: function() {
+      var map = { 'gut_flora': '肠道菌群报告', 'dan': 'DAN测评报告', 'physical_exam': '体检报告', 'tongue': '舌诊报告' }
+      return map[this.reportType] || '报告详情'
+    }
+  },
+  onLoad: function(options) {
+    this.reportType = options.reportType || 'gut_flora'
+    this.reportId = options.reportId ? parseInt(options.reportId) : null
+    if (this.reportId) this.loadData()
+  },
+  methods: {
+    goBack: function() { uni.navigateBack() },
+    loadData: function() {
+      var self = this
+      var loadFn = self.reportType === 'dan' ? getDanReportDetail : getReportDetail
+      loadFn(self.reportId).then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.blocks = res.data.blocks || []
+          var report = res.data.report || res.data
+          var head = {}
+          if (self.reportType === 'dan') {
+            head.childName = report.childName
+            head.reportDateText = report.assessmentDate || ''
+          } else {
+            head.personName = report.personName
+            var d = parseDate(report.reportDate)
+            head.reportDateText = d ? (d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate()) : ''
+          }
+          self.headInfo = head
+        }
+      }).catch(function() {
+        uni.showToast({ title: '加载失败', icon: 'none' })
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page { min-height: 100vh; background: #f8f8f8; }
+.nav-bar { display: flex; align-items: center; padding: 20rpx 30rpx; background: #fff; position: relative; }
+.nav-back { padding: 10rpx 0; }
+.back-text { font-size: 28rpx; color: #4A9BD7; }
+.nav-title { flex: 1; text-align: center; font-size: 32rpx; font-weight: 600; }
+.content { padding: 20rpx 30rpx; height: calc(100vh - 100rpx); }
+.report-head { background: #fff; border-radius: 16rpx; padding: 28rpx; margin-bottom: 20rpx; }
+.head-name { font-size: 34rpx; font-weight: 600; color: #333; }
+.head-meta { display: flex; align-items: center; margin-top: 12rpx; }
+.type-badge { font-size: 22rpx; color: #2E7D32; background: #E8F5E9; padding: 4rpx 16rpx; border-radius: 20rpx; margin-right: 16rpx; }
+.head-date { font-size: 24rpx; color: #999; }
+.empty-hint { text-align: center; color: #ccc; padding: 80rpx 0; font-size: 28rpx; }
+</style>
+```
+
+- [ ] **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
+<view class="nav-edit" v-if="hasRiskBlocks" @tap="toggleRiskEdit">
+  <text>{{ isEditing ? '完成' : '编辑' }}</text>
+</view>
+```
+
+风险编辑表单(在 blocks 渲染区之后追加,`v-if="isEditing"`):
+
+```html
+<view class="edit-panel" v-if="isEditing">
+  <view class="edit-card" v-for="(risk, ei) in editRisks" :key="'e' + ei">
+    <text class="edit-name">{{ risk.diseaseName }}</text>
+    <view class="edit-row">
+      <text class="edit-label">风险值:</text>
+      <input class="edit-input" type="text" v-model="risk.riskValue" />
+      <text class="edit-label">等级:</text>
+      <picker :value="riskLevelIndex(risk.riskLevel)" :range="riskLevels" @change="onRiskLevelChange($event, risk)">
+        <text class="edit-picker">{{ risk.riskLevel || '请选择' }}</text>
+      </picker>
+    </view>
+  </view>
+</view>
+```
+
+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 空时显示"暂无报告内容"且不报错