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