2026-08-10-report-blocks-renderer.md 52 KB

通用报告块渲染引擎 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 三个挂点落库。getReportDetailDanReportUploadVO 追加返回 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.jsparseDate()
  • 前端 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() 末尾追加迁移

    // 迁移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

    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 实体

    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

    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

    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

    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: 写失败测试

    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(核心实现)

    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

    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: 追加失败测试

    @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: 追加实现

    /** 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

    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(...) 之后追加)

    // 落库通用展示块(blocks 组装失败不阻断确认)
    try {
    reportBlockService.save("gut_flora", created.getId(),
            reportBlockAssembler.assembleGutFlora(payload));
    } catch (Exception e) {
    log.warn("保存gut_flora展示块失败 reportId={}: {}", created.getId(), e.getMessage());
    }
    

并在类顶部注入:

    @Resource
    private ReportBlockService reportBlockService;

    @Resource
    private ReportBlockAssembler reportBlockAssembler;

(注:controller 层依赖 service 符合本项目现有模式——HealthReportController 已注入 healthReportService 等)

  • [ ] Step 2: confirmUpload 挂点(DanReportUploadService.java,在 uploadMapper.updateById(upload);(198 行)之后、syncFromDanReport 之前追加)

    // 落库 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,方法末尾、最后一行更新语句之后追加)

    // 编辑后重新组装展示块(第一套键 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

    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); 之后)

    // 通用展示块(blocks 为空时前端回退旧字段)
    detail.put("blocks", reportBlockService.getBlocks(report.getReportType(), reportId));
    

并在类顶部注入 @Resource private ReportBlockService reportBlockService;

  • [ ] Step 2: DanReportUploadVO 追加 blocks 字段

    /** 通用展示块(从 report_blocks 表读取) */
    private List<Map<String, Object>> blocks;
    

(import java.util.List / java.util.MapfromEntity 不负责填充 blocks,由 service 层填充)

  • [ ] Step 3: getDetail 填充 blocks(DanReportUploadService.getDetail 内,DanReportUploadVO.fromEntity(upload) 结果上)

    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

    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)

    <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

    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 详情接口

    // DAN 报告详情(含 blocks)
    export const getDanReportDetail = (id) => request('/api/dan-report/' + id, 'POST', {})
    
  • [ ] Step 2: 创建 report-detail.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 分包(subPackagesroot: "pages/health"pages 数组)追加:

{ "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

    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 切换

    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)

    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 同构)

    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

    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 追加"编辑/完成"入口:

<view class="nav-edit" v-if="hasRiskBlocks" @tap="toggleRiskEdit">
  <text>{{ isEditing ? '完成' : '编辑' }}</text>
</view>

风险编辑表单(在 blocks 渲染区之后追加,v-if="isEditing"):

<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 追加:

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

    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 空时显示"暂无报告内容"且不报错