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 小程序。
@PostMapping,禁止 GET/PUT/DELETE@Resource,字段名与类型默认 Bean Name 一致@TableName + @TableId(type = IdType.AUTO)DatabaseInitializer.runMigrations(),最新编号 210 → 新迁移 = 迁移211;同步 schema.sql?.(用 &&)、禁止 CSS Grid、禁止 :key 表达式、禁止 new Date(string)(用 utils/format.js 的 parseDate())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)Files:
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java(runMigrations 末尾)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 表迁移(通用报告展示块)"
Files:
cfc-backend/src/main/java/com/etotem/cfc/entity/ReportBlock.javacfc-backend/src/main/java/com/etotem/cfc/mapper/ReportBlockMapper.javacfc-backend/src/main/java/com/etotem/cfc/service/ReportBlockService.javaInterfaces:
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 + 读取)"
Files:
cfc-backend/src/main/java/com/etotem/cfc/service/ReportBlockAssembler.javacfc-backend/src/test/java/com/etotem/cfc/service/ReportBlockAssemblerTest.javaInterfaces:
ReportBlockAssembler.assembleGutFlora(ParsedReportPayload.Payload payload) → List<Map<String, Object>>(Task 5 confirmDraft 挂点调用)ReportBlockAssembler.fromMap(Map<String, Object> payload) → ParsedReportPayload.Payload(Task 5 编辑挂点调用,第一套键→第二套)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 键名转换"
Files:
cfc-backend/src/main/java/com/etotem/cfc/service/ReportBlockAssembler.java(追加 assembleDanFromJson)cfc-backend/src/test/java/com/etotem/cfc/service/ReportBlockAssemblerTest.java(追加测试)Interfaces:
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)"
Files:
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java(confirmDraft 880-893 区域)cfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.java(confirmUpload 190-205 区域)cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java(updateReportFromPayload 末尾)Interfaces:
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 一致)。
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/编辑)"
Files:
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java(getReportDetail 312-330)cfc-backend/src/main/java/com/etotem/cfc/dto/DanReportUploadVO.java(追加 blocks 字段)cfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.java(getDetail 填充 blocks)Interfaces:
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.Map;fromEntity 不负责填充 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 非空判断)
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"
Files:
cfc-frontend/components/report-blocks-renderer.vueInterfaces:
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 用索引拼接无运算符表达式)
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"
Files:
cfc-frontend/pages/health/report-detail.vuecfc-frontend/utils/api.js(追加 getDanReportDetail)cfc-frontend/pages.json(分包注册新页面,pages/health 分包内追加)Interfaces:
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 分包(subPackages 中 root: "pages/health" 的 pages 数组)追加:
{ "path": "report-detail", "style": { "navigationBarTitleText": "报告详情" } }
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 渲染)"
Files:
cfc-frontend/pages/health/report-list.vue(goToDetail 79-87)cfc-frontend/pages/health/report-confirm.vue(816-820 确认后跳转)cfc-frontend/pages/wisdom-detail/index.vue(viewDanReport 370-372)cfc-frontend/pages/mind-detail/index.vue(viewDanReport 同构位置)Interfaces:
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): 报告入口切换到统一详情页"
Files:
cfc-frontend/pages/health/report-detail.vue(追加风险编辑)cfc-backend 全量编译 + 测试Interfaces:
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 集合,保证编辑后分组一致)
Run: mvn clean compile 然后 mvn test -Dtest=ReportBlockAssemblerTest -DfailIfNoTests=false
Expected: BUILD SUCCESS + 4 个测试 PASS
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)"