2026-07-17-flora-microbiome-report-implementation.md 83 KB

菌群报告页面重构 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 重构肠道菌群报告详情页,全覆盖参考 JSON 数据(200+ 菌属、50+ 指标、224 食物),支持查看/编辑双模式,独立知识库弹出框,异常值颜色+箭头。

Architecture: 4 页架构(主页 + 菌群详情 + 食物推荐 + 疾病风险),后端新增 health_knowledge_base 表 + 编辑 API,前端新增通用知识库弹出框组件。

Tech Stack: Java 8 + Spring Boot 2.7.18 + MyBatis-Plus / uni-app (Vue 2 Options API) / WeChat Mini-Program / Playwright E2E

✅ 实施状态(2026-08-22 回填核实): 全部任务已完成并通过代码核实(后端 entity/mapper/service/controller、迁移98、/report/edit、前端组件与页面、E2E spec 均在仓库中)。初版存在的 1 处落地路径偏差(食物推荐页)已于同日修复归位,详见文末「附录:实施偏差记录」。文中代码块为计划原稿,实际代码以仓库为准。


文件结构总览

后端(cfc-backend)

文件 操作 职责
entity/HealthKnowledgeBase.java NEW 知识库实体
mapper/HealthKnowledgeBaseMapper.java NEW MyBatis-Plus Mapper
service/HealthKnowledgeBaseService.java NEW 知识库查询(单条+批量)
controller/HealthKnowledgeBaseController.java NEW POST /api/health/knowledge/query
controller/HealthReportController.java MODIFY 新增 POST /api/health/report/edit
service/HealthReportService.java MODIFY 新增 updateReportFromPayload
config/DatabaseInitializer.java MODIFY 迁移93: 创建 health_knowledge_base 表
resources/schema.sql MODIFY 追加 health_knowledge_base DDL

前端(cfc-frontend)

文件 操作 职责
components/health-knowledge-popup.vue NEW 通用知识库弹出框组件
pages/health/gut-flora-detail.vue REWRITE 主页(评分+手风琴+指标+编辑)
pages/health/gut-flora-species-detail.vue NEW 菌群详情(12 Tab + 卡片)
pages/health/gut-flora-foods-detail.vue NEW ✅ 食物推荐(筛选+卡片+编辑)。2026-08-22 已自 pages/growth/foods-detail.vue 迁移归位(附录偏差①已修复)
pages/health/gut-flora-risks-detail.vue NEW 疾病风险(分类+卡片+编辑)
pages.json MODIFY 注册 gut-flora-species-detail / gut-flora-risks-detail / gut-flora-foods-detail,4 页架构齐备
utils/api.js MODIFY 新增 editHealthReport、queryKnowledgeBase、batchQueryKnowledgeBase

测试

文件 操作 职责
tests/e2e/flora-microbiome-flow.spec.js MODIFY 验证知识库交互+新选择器

Task 1: 后端 — HealthKnowledgeBase 实体类

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/entity/HealthKnowledgeBase.java

  • [x] Step 1: 创建实体类

    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;
    
    /**
    * 健康知识库 — 菌属/营养素/指标标准说明
    * 独立于报告数据存储,通过 itemType + itemName 精确匹配
    */
    @Data
    @TableName("health_knowledge_base")
    public class HealthKnowledgeBase implements Serializable {
    
    @TableId(type = IdType.AUTO)
    private Long id;
    
    /** 类型: bacteria/nutrient/indicator/vitamin/amino_acid/disease */
    private String itemType;
    
    /** 项目名称(精确匹配,如 "双歧杆菌属 Bifidobacterium") */
    private String itemName;
    
    /** 分类(如 有益菌/有害菌) */
    private String category;
    
    /** 正常范围参考(如 "0.19-14.59") */
    private String normalRange;
    
    /** 详细说明文字 */
    private String description;
    
    /** 相关建议(如补充来源) */
    private String suggestion;
    
    /** 数据来源 */
    private String source;
    
    private Date createdAt;
    
    private Date updatedAt;
    }
    
  • [x] Step 2: 编译验证

Run: cd /sc-data/cfc/cfc-backend && mvn clean compile Expected: BUILD SUCCESS

  • [x] Step 3: Commit

    cd /sc-data/cfc
    git add cfc-backend/src/main/java/com/etotem/cfc/entity/HealthKnowledgeBase.java
    git commit -m "feat(backend): add HealthKnowledgeBase entity"
    

Task 2: 后端 — HealthKnowledgeBase Mapper

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthKnowledgeBaseMapper.java

  • [x] Step 1: 创建 Mapper 接口

    package com.etotem.cfc.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.etotem.cfc.entity.HealthKnowledgeBase;
    
    public interface HealthKnowledgeBaseMapper extends BaseMapper<HealthKnowledgeBase> {
    }
    
  • [x] Step 2: 编译验证

Run: cd /sc-data/cfc/cfc-backend && mvn clean compile Expected: BUILD SUCCESS

  • [x] Step 3: Commit

    cd /sc-data/cfc
    git add cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthKnowledgeBaseMapper.java
    git commit -m "feat(backend): add HealthKnowledgeBaseMapper"
    

Task 3: 后端 — HealthKnowledgeBase Service

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/service/HealthKnowledgeBaseService.java

  • [x] Step 1: 创建 Service 类

    package com.etotem.cfc.service;
    
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.etotem.cfc.entity.HealthKnowledgeBase;
    import com.etotem.cfc.mapper.HealthKnowledgeBaseMapper;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Service;
    
    import javax.annotation.Resource;
    import java.util.HashMap;
    import java.util.List;
    import java.util.Map;
    
    @Slf4j
    @Service
    public class HealthKnowledgeBaseService {
    
    @Resource
    private HealthKnowledgeBaseMapper healthKnowledgeBaseMapper;
    
    /**
     * 单条查询
     */
    public HealthKnowledgeBase query(String itemType, String itemName) {
        return healthKnowledgeBaseMapper.selectOne(
                new LambdaQueryWrapper<HealthKnowledgeBase>()
                        .eq(HealthKnowledgeBase::getItemType, itemType)
                        .eq(HealthKnowledgeBase::getItemName, itemName)
        );
    }
    
    /**
     * 批量查询 — queries 为 [{itemType, itemName}, ...]
     * 返回 Map.key = "itemType:itemName"
     */
    public Map<String, HealthKnowledgeBase> batchQuery(List<Map<String, String>> queries) {
        Map<String, HealthKnowledgeBase> result = new HashMap<>();
        for (Map<String, String> q : queries) {
            String itemType = q.get("itemType");
            String itemName = q.get("itemName");
            if (itemType == null || itemName == null) {
                continue;
            }
            String key = itemType + ":" + itemName;
            HealthKnowledgeBase entry = query(itemType, itemName);
            if (entry != null) {
                result.put(key, entry);
            }
        }
        return result;
    }
    
    /**
     * 插入单条(用于数据导入)
     */
    public void insert(HealthKnowledgeBase entry) {
        healthKnowledgeBaseMapper.insert(entry);
    }
    }
    
  • [x] Step 2: 编译验证

Run: cd /sc-data/cfc/cfc-backend && mvn clean compile Expected: BUILD SUCCESS

  • [x] Step 3: Commit

    cd /sc-data/cfc
    git add cfc-backend/src/main/java/com/etotem/cfc/service/HealthKnowledgeBaseService.java
    git commit -m "feat(backend): add HealthKnowledgeBaseService (query + batchQuery)"
    

Task 4: 后端 — HealthKnowledgeBase Controller

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/controller/HealthKnowledgeBaseController.java
  • Bean 名冲突检查: grep -rn 'class HealthKnowledgeBaseController' src/main/java/com/etotem/cfc/controller/ 确保无重名

  • [x] Step 1: 检查路由重复

Run: cd /sc-data/cfc/cfc-backend && grep -rn '@Mapping' src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u Expected: /api/health/knowledge/query 未出现

  • [x] Step 2: 创建 Controller

    package com.etotem.cfc.controller;
    
    import com.etotem.cfc.common.Result;
    import com.etotem.cfc.entity.HealthKnowledgeBase;
    import com.etotem.cfc.service.HealthKnowledgeBaseService;
    import io.swagger.v3.oas.annotations.Operation;
    import io.swagger.v3.oas.annotations.tags.Tag;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import javax.annotation.Resource;
    import java.util.List;
    import java.util.Map;
    
    @Slf4j
    @Tag(name = "健康知识库")
    @RestController
    @RequestMapping("/api/health/knowledge")
    public class HealthKnowledgeBaseController {
    
    @Resource
    private HealthKnowledgeBaseService healthKnowledgeBaseService;
    
    @Operation(summary = "查询知识库条目")
    @PostMapping("/query")
    public Result<HealthKnowledgeBase> query(@RequestBody Map<String, String> params) {
        String itemType = params.get("itemType");
        String itemName = params.get("itemName");
        if (itemType == null || itemName == null) {
            return Result.error("itemType和itemName不能为空");
        }
        HealthKnowledgeBase entry = healthKnowledgeBaseService.query(itemType, itemName);
        return Result.success(entry);
    }
    
    @Operation(summary = "批量查询知识库")
    @PostMapping("/batch-query")
    public Result<Map<String, HealthKnowledgeBase>> batchQuery(@RequestBody Map<String, Object> params) {
        @SuppressWarnings("unchecked")
        List<Map<String, String>> queries = (List<Map<String, String>>) params.get("queries");
        if (queries == null || queries.isEmpty()) {
            return Result.error("queries不能为空");
        }
        Map<String, HealthKnowledgeBase> result = healthKnowledgeBaseService.batchQuery(queries);
        return Result.success(result);
    }
    }
    
  • [x] Step 3: 编译验证

Run: cd /sc-data/cfc/cfc-backend && mvn clean compile Expected: BUILD SUCCESS

  • [x] Step 4: Commit

    cd /sc-data/cfc
    git add cfc-backend/src/main/java/com/etotem/cfc/controller/HealthKnowledgeBaseController.java
    git commit -m "feat(backend): add HealthKnowledgeBaseController (query + batch-query)"
    

Task 5: 后端 — 数据库迁移(创建 health_knowledge_base 表)

Files:

  • Modify: cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  • Modify: cfc-backend/src/main/resources/schema.sql

  • [x] Step 1: 在 DatabaseInitializer 添加迁移 93

runMigrations() 末尾(// 迁移92 之后)追加:

        // 迁移93: 创建 health_knowledge_base 表(菌群/营养知识库)
        try {
            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `health_knowledge_base` (" +
                    "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, " +
                    "`item_type` VARCHAR(50) NOT NULL COMMENT '类型: bacteria/nutrient/indicator/vitamin/amino_acid/disease', " +
                    "`item_name` VARCHAR(200) NOT NULL COMMENT '项目名称(精确匹配)', " +
                    "`category` VARCHAR(100) COMMENT '分类(如 有益菌/有害菌)', " +
                    "`normal_range` VARCHAR(200) COMMENT '正常范围参考', " +
                    "`description` TEXT COMMENT '详细说明', " +
                    "`suggestion` TEXT COMMENT '相关建议', " +
                    "`source` VARCHAR(100) COMMENT '数据来源', " +
                    "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, " +
                    "`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
                    "UNIQUE KEY `uk_item_type_name` (`item_type`, `item_name`), " +
                    "KEY `idx_item_type` (`item_type`)" +
                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康知识库 - 菌属/营养素/指标说明'");
            log.info("已创建health_knowledge_base表");
        } catch (Exception e) {
            // 表已存在,忽略错误
        }

Location detail: 在 DatabaseInitializer.java 末尾的 runMigrations() 中,最后一个 try-catch 之后、} 闭合之前插入。当前第 5884 行附近。

  • [x] Step 2: 在 schema.sql 末尾追加 DDL

    -- 健康知识库 — 菌属/营养素/指标说明
    CREATE TABLE IF NOT EXISTS `health_knowledge_base` (
    `id` BIGINT AUTO_INCREMENT PRIMARY KEY,
    `item_type` VARCHAR(50) NOT NULL COMMENT '类型: bacteria/nutrient/indicator/vitamin/amino_acid/disease',
    `item_name` VARCHAR(200) NOT NULL COMMENT '项目名称(精确匹配)',
    `category` VARCHAR(100) COMMENT '分类(如 有益菌/有害菌)',
    `normal_range` VARCHAR(200) COMMENT '正常范围参考',
    `description` TEXT COMMENT '详细说明',
    `suggestion` TEXT COMMENT '相关建议',
    `source` VARCHAR(100) COMMENT '数据来源',
    `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
    `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY `uk_item_type_name` (`item_type`, `item_name`),
    KEY `idx_item_type` (`item_type`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康知识库 - 菌属/营养素/指标说明';
    
  • [x] Step 3: 编译验证

Run: cd /sc-data/cfc/cfc-backend && mvn clean compile Expected: BUILD SUCCESS

  • [x] Step 4: Commit

    cd /sc-data/cfc
    git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
    git add cfc-backend/src/main/resources/schema.sql
    git commit -m "feat(backend): add health_knowledge_base table (migration 93)"
    

Task 6: 后端 — 报告编辑 API(POST /api/health/report/edit)

Files:

  • Modify: cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  • Modify: cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

  • [x] Step 1: 在 Controller 中新增编辑端点

HealthReportController 中(getReportDetail 方法之后)追加:

    /**
     * 编辑已发布报告 — 修正 AI 解析错误的值
     * 只修改已有条目(不增删),保存后刷新 7 维健康评分
     */
    @Operation(summary = "编辑已发布报告数据")
    @PostMapping("/report/edit")
    public Result<String> editReport(
            @RequestBody Map<String, Object> params,
            @RequestAttribute("userId") Long userId) {

        Long reportId = params.get("reportId") != null
                ? Long.valueOf(params.get("reportId").toString()) : null;
        if (reportId == null) {
            return Result.error("reportId不能为空");
        }
        Long subjectId = params.get("subjectId") != null
                ? Long.valueOf(params.get("subjectId").toString()) : null;
        if (subjectId == null) {
            return Result.error("subjectId不能为空");
        }

        @SuppressWarnings("unchecked")
        Map<String, Object> payload = (Map<String, Object>) params.get("payload");
        if (payload == null) {
            return Result.error("payload不能为空");
        }

        healthReportService.updateReportFromPayload(reportId, payload, subjectId);
        return Result.success("已保存");
    }
  • [x] Step 2: 在 Service 中新增 updateReportFromPayload

    /**
     * 更新已发布报告的编辑数据
     * 逻辑: 只更新已有字段的值,不增删条目
     */
    @Transactional(rollbackFor = Exception.class)
    public void updateReportFromPayload(Long reportId, Map<String, Object> payload, Long subjectId) {
        // 1. 更新 health_report 评分数值
        HealthReport report = healthReportMapper.selectById(reportId);
        if (report == null) {
            throw new RuntimeException("报告不存在: " + reportId);
        }
        if (payload.get("overallScore") != null) {
            report.setOverallScore(Integer.valueOf(payload.get("overallScore").toString()));
        }
        if (payload.get("gutHealthScore") != null) {
            report.setGutHealthScore(Integer.valueOf(payload.get("gutHealthScore").toString()));
        }
        if (payload.get("chronicDiseaseScore") != null) {
            report.setChronicDiseaseScore(Integer.valueOf(payload.get("chronicDiseaseScore").toString()));
        }
        if (payload.get("nutritionScore") != null) {
            report.setNutritionScore(Integer.valueOf(payload.get("nutritionScore").toString()));
        }
        if (payload.get("gutBalanceScore") != null) {
            report.setGutBalanceScore(Integer.valueOf(payload.get("gutBalanceScore").toString()));
        }
        if (payload.get("gutDiversityScore") != null) {
            report.setGutDiversityScore(Integer.valueOf(payload.get("gutDiversityScore").toString()));
        }
        if (payload.get("beneficialBacteriaScore") != null) {
            report.setBeneficialBacteriaScore(Integer.valueOf(payload.get("beneficialBacteriaScore").toString()));
        }
        if (payload.get("harmfulBacteriaScore") != null) {
            report.setHarmfulBacteriaScore(Integer.valueOf(payload.get("harmfulBacteriaScore").toString()));
        }
        if (payload.get("coreSpeciesScore") != null) {
            report.setCoreSpeciesScore(Integer.valueOf(payload.get("coreSpeciesScore").toString()));
        }
        healthReportMapper.updateById(report);
    
        // 2. 更新 indicators(删除旧→插入新)
        @SuppressWarnings("unchecked")
        List<Map<String, Object>> indicators = (List<Map<String, Object>>) payload.get("indicators");
        if (indicators != null) {
            healthIndicatorMapper.delete(new LambdaQueryWrapper<HealthIndicator>()
                    .eq(HealthIndicator::getReportId, reportId));
            for (int i = 0; i < indicators.size(); i++) {
                Map<String, Object> item = indicators.get(i);
                HealthIndicator ind = new HealthIndicator();
                ind.setReportId(reportId);
                ind.setCategory((String) item.get("category"));
                ind.setIndicatorName((String) item.get("indicatorName"));
                ind.setIndicatorValue(item.get("indicatorValue") != null
                        ? item.get("indicatorValue").toString() : null);
                ind.setUnit((String) item.get("unit"));
                ind.setRefRange((String) item.get("refRange"));
                ind.setStatus((String) item.get("status"));
                ind.setSymptoms((String) item.get("symptoms"));
                ind.setSortOrder(i);
                healthIndicatorMapper.insert(ind);
            }
        }
    
        // 3. 更新 gutFlora
        @SuppressWarnings("unchecked")
        List<Map<String, Object>> gutFlora = (List<Map<String, Object>>) payload.get("gutFlora");
        if (gutFlora != null) {
            healthGutFloraMapper.delete(new LambdaQueryWrapper<HealthGutFlora>()
                    .eq(HealthGutFlora::getReportId, reportId));
            for (int i = 0; i < gutFlora.size(); i++) {
                Map<String, Object> item = gutFlora.get(i);
                HealthGutFlora flora = new HealthGutFlora();
                flora.setReportId(reportId);
                flora.setBacteriaName((String) item.get("bacteriaName"));
                flora.setBacteriaValue(item.get("bacteriaValue") != null
                        ? item.get("bacteriaValue").toString() : null);
                flora.setNormalRange((String) item.get("normalRange"));
                flora.setStatus((String) item.get("status"));
                flora.setCategory((String) item.get("category"));
                flora.setPopulationLevel((String) item.get("populationLevel"));
                flora.setDetectionRate((String) item.get("detectionRate"));
                flora.setLevel((String) item.get("level"));
                flora.setSortOrder(i);
                healthGutFloraMapper.insert(flora);
            }
        }
    
        // 4. 更新 diseaseRisks
        @SuppressWarnings("unchecked")
        List<Map<String, Object>> diseaseRisks = (List<Map<String, Object>>) payload.get("diseaseRisks");
        if (diseaseRisks != null) {
            healthDiseaseRiskMapper.delete(new LambdaQueryWrapper<HealthDiseaseRisk>()
                    .eq(HealthDiseaseRisk::getReportId, reportId));
            for (Map<String, Object> item : diseaseRisks) {
                HealthDiseaseRisk risk = new HealthDiseaseRisk();
                risk.setReportId(reportId);
                risk.setDiseaseName((String) item.get("diseaseName"));
                risk.setRiskValue(item.get("riskValue") != null
                        ? item.get("riskValue").toString() : null);
                risk.setRiskLevel((String) item.get("riskLevel"));
                healthDiseaseRiskMapper.insert(risk);
            }
        }
    
        // 5. 刷新 7 维健康评分
        try {
            dimensionScoreService.refreshFromGutReport(subjectId, reportId);
            log.info("编辑后已刷新成员{}的7维健康评分", subjectId);
        } catch (Exception e) {
            log.warn("刷新7维健康评分失败: {}", e.getMessage());
        }
    }
    
  • [x] Step 3: 在 Controller 上方追加所需的 import

确认文件顶部已有:

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.transaction.annotation.Transactional;

如果 @Transactional 没有 import,在文件顶部追加:

import org.springframework.transaction.annotation.Transactional;
  • Step 4: 确保 HealthReportService 中有需要的 Mapper 注入

HealthReportService.java 中确认以下字段已存在,若不存在则添加:

    @Resource
    private HealthIndicatorMapper healthIndicatorMapper;

    @Resource
    private HealthGutFloraMapper healthGutFloraMapper;

    @Resource
    private HealthDiseaseRiskMapper healthDiseaseRiskMapper;

    @Resource
    private HealthReportMapper healthReportMapper;

    @Resource
    private DimensionScoreService dimensionScoreService;
  • Step 5: 编译验证

Run: cd /sc-data/cfc/cfc-backend && mvn clean compile Expected: BUILD SUCCESS

  • [x] Step 6: Commit

    cd /sc-data/cfc
    git add cfc-backend/
    git commit -m "feat(backend): add POST /api/health/report/edit + updateReportFromPayload"
    

Task 7: 后端 — 导入参考 JSON 知识库数据

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/service/KnowledgeBaseDataImporter.java
  • 数据源: docs/superpowers/refs/501999942-某人.json, 530010234-侯.json, 儿童示例.json

  • [x] Step 1: 创建数据导入 Service(一次性工具)

    package com.etotem.cfc.service;
    
    import com.etotem.cfc.entity.HealthKnowledgeBase;
    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.PostConstruct;
    import javax.annotation.Resource;
    import java.io.InputStream;
    import java.util.HashSet;
    import java.util.List;
    import java.util.Map;
    import java.util.Set;
    
    /**
    * 知识库数据导入器 — 从参考 JSON 导入菌属/指标说明
    * 仅在 health_knowledge_base 为空时执行一次
    */
    @Slf4j
    @Service
    public class KnowledgeBaseDataImporter {
    
    @Resource
    private HealthKnowledgeBaseService knowledgeBaseService;
    
    @Resource
    private ObjectMapper objectMapper;
    
    private static final String[] REF_JSON_FILES = {
        "/data/refs/501999942-某人.json",
        "/data/refs/530010234-侯.json",
        "/data/refs/儿童示例.json"
    };
    
    @PostConstruct
    public void init() {
        // 延迟执行,等待数据库表就绪
        new Thread(() -> {
            try {
                Thread.sleep(5000);
                importAll();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }).start();
    }
    
    public void importAll() {
        try {
            // 检查是否已有数据
            HealthKnowledgeBase existing = knowledgeBaseService.query("bacteria", "双歧杆菌属");
            if (existing != null) {
                log.info("知识库已有数据,跳过导入");
                return;
            }
    
            Set<String> dedupSet = new HashSet<>();
    
            for (String jsonFile : REF_JSON_FILES) {
                importFromRef(jsonFile, dedupSet);
            }
    
            log.info("知识库数据导入完成");
        } catch (Exception e) {
            log.error("知识库数据导入失败", e);
        }
    }
    
    private void importFromRef(String classpathFile, Set<String> dedupSet) {
        try (InputStream is = getClass().getResourceAsStream(classpathFile)) {
            if (is == null) {
                log.warn("参考JSON文件不存在: {}", classpathFile);
                return;
            }
            Map<String, Object> root = objectMapper.readValue(is, new TypeReference<Map<String, Object>>() {});
    
            // 导入菌属说明(gutFlora 数组含 description 字段)
            importGutFloraDescriptions(root, dedupSet);
    
            // 导入指标说明(indicators 数组含 status/refRange 等)
            importIndicatorDescriptions(root, dedupSet);
    
        } catch (Exception e) {
            log.error("读取参考JSON失败: {}", classpathFile, e);
        }
    }
    
    private void importGutFloraDescriptions(Map<String, Object> root, Set<String> dedupSet) {
        Object raw = root.get("gutFlora");
        if (raw instanceof List) {
            for (Object item : (List) raw) {
                if (item instanceof Map) {
                    Map<String, Object> flora = (Map<String, Object>) item;
                    String name = (String) flora.get("bacteriaName");
                    String desc = (String) flora.get("description");
                    if (name != null && desc != null && !desc.isEmpty()) {
                        String key = "bacteria:" + name;
                        if (dedupSet.add(key)) {
                            HealthKnowledgeBase entry = new HealthKnowledgeBase();
                            entry.setItemType("bacteria");
                            entry.setItemName(name);
                            entry.setDescription(desc);
                            entry.setCategory((String) flora.get("category"));
                            entry.setNormalRange((String) flora.get("normalRange"));
                            knowledgeBaseService.insert(entry);
                        }
                    }
                }
            }
        }
    }
    
    private void importIndicatorDescriptions(Map<String, Object> root, Set<String> dedupSet) {
        Object raw = root.get("indicators");
        if (raw instanceof List) {
            for (Object item : (List) raw) {
                if (item instanceof Map) {
                    Map<String, Object> ind = (Map<String, Object>) item;
                    String name = (String) ind.get("indicatorName");
                    String category = (String) ind.get("category");
                    if (name == null) continue;
    
                    // 将 category 映射为 itemType
                    String itemType = mapIndicatorCategory(category);
                    String key = itemType + ":" + name;
                    if (dedupSet.add(key)) {
                        HealthKnowledgeBase entry = new HealthKnowledgeBase();
                        entry.setItemType(itemType);
                        entry.setItemName(name);
                        entry.setCategory(category);
                        entry.setNormalRange((String) ind.get("refRange"));
                        entry.setDescription((String) ind.get("symptoms"));
                        // 部分 JSON 用 status 字段描述健康状况
                        knowledgeBaseService.insert(entry);
                    }
                }
            }
        }
    }
    
    private String mapIndicatorCategory(String category) {
        if (category == null) return "nutrient";
        switch (category) {
            case "维生素": return "vitamin";
            case "氨基酸": return "amino_acid";
            case "微量元素": return "indicator";
            case "肠道屏障与代谢物": return "indicator";
            case "短链脂肪酸": return "indicator";
            case "神经递质与激素": return "indicator";
            case "抗生素风险评估": return "indicator";
            default: return "nutrient";
        }
    }
    }
    
  • [x] Step 2: 创建资源目录并将参考 JSON 复制到 resources/data/refs/

    mkdir -p /sc-data/cfc/cfc-backend/src/main/resources/data/refs
    cp /sc-data/cfc/docs/superpowers/refs/501999942-某人.json /sc-data/cfc/cfc-backend/src/main/resources/data/refs/
    cp /sc-data/cfc/docs/superpowers/refs/530010234-侯.json /sc-data/cfc/cfc-backend/src/main/resources/data/refs/
    cp /sc-data/cfc/docs/superpowers/refs/儿童示例.json /sc-data/cfc/cfc-backend/src/main/resources/data/refs/
    
  • [x] Step 3: 编译验证

Run: cd /sc-data/cfc/cfc-backend && mvn clean compile Expected: BUILD SUCCESS

  • [x] Step 4: Commit

    cd /sc-data/cfc
    git add cfc-backend/
    git commit -m "feat(backend): add KnowledgeBaseDataImporter + ref JSONs"
    

Task 8: 前端 — api.js 新增方法

  • [x] Step 1: 在 utils/api.js 中追加 3 个方法(在 getReportDetail 附近,约第 1515 行)

    export const editHealthReport = (reportId, payload, subjectId) => request('/api/health/report/edit', 'POST', { reportId, payload, subjectId })
    
    export const queryKnowledgeBase = (itemType, itemName) => request('/api/health/knowledge/query', 'POST', { itemType, itemName })
    
    export const batchQueryKnowledgeBase = (queries) => request('/api/health/knowledge/batch-query', 'POST', { queries })
    
  • [x] Step 2: Commit

    cd /sc-data/cfc
    git add cfc-frontend/utils/api.js
    git commit -m "feat(frontend): add editHealthReport, queryKnowledgeBase, batchQueryKnowledgeBase API"
    

Task 9: 前端 — 知识库弹出框组件

Files:

  • Create: cfc-frontend/components/health-knowledge-popup.vue

  • [x] Step 1: 创建弹出框组件

    <template>
    <view class="kb-popup-overlay" v-if="visible" @tap="close">
    <view class="kb-popup-content" @tap.stop>
      <view class="kb-popup-header">
        <text class="kb-popup-title">{{ data.itemName || '' }}</text>
        <text class="kb-popup-close" @tap="close">×</text>
      </view>
      <scroll-view class="kb-popup-body" scroll-y>
        <view class="kb-field" v-if="data.category">
          <text class="kb-field-label">分类</text>
          <text class="kb-field-value">{{ data.category }}</text>
        </view>
        <view class="kb-field" v-if="data.normalRange">
          <text class="kb-field-label">正常范围</text>
          <text class="kb-field-value">{{ data.normalRange }}</text>
        </view>
        <view class="kb-field" v-if="data.description">
          <text class="kb-field-label">说明</text>
          <text class="kb-field-value">{{ data.description }}</text>
        </view>
        <view class="kb-field" v-if="data.suggestion">
          <text class="kb-field-label">相关建议</text>
          <text class="kb-field-value">{{ data.suggestion }}</text>
        </view>
        <view class="kb-empty" v-if="!data.itemName">
          <text>暂无相关说明</text>
        </view>
      </scroll-view>
    </view>
    </view>
    </template>
    
    <script>
    export default {
    props: {
    visible: { type: Boolean, default: false },
    data: { type: Object, default: () => ({}) }
    },
    methods: {
    close: function() {
      this.$emit('close')
    }
    }
    }
    </script>
    
    <style scoped>
    .kb-popup-overlay {
    position: fixed;
    top: 0; left: 0; right: 0; bottom: 0;
    background: rgba(0,0,0,0.5);
    z-index: 999;
    display: flex;
    align-items: center;
    justify-content: center;
    }
    .kb-popup-content {
    width: 600rpx;
    max-height: 70vh;
    background: #fff;
    border-radius: 24rpx;
    overflow: hidden;
    display: flex;
    flex-direction: column;
    }
    .kb-popup-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 30rpx 30rpx 20rpx;
    border-bottom: 1rpx solid #f0f0f0;
    }
    .kb-popup-title {
    font-size: 32rpx;
    font-weight: 600;
    color: #333;
    flex: 1;
    }
    .kb-popup-close {
    font-size: 40rpx;
    color: #999;
    padding: 0 10rpx;
    }
    .kb-popup-body {
    padding: 20rpx 30rpx 30rpx;
    max-height: 50vh;
    }
    .kb-field {
    margin-bottom: 20rpx;
    }
    .kb-field-label {
    font-size: 26rpx;
    color: #999;
    display: block;
    margin-bottom: 6rpx;
    }
    .kb-field-value {
    font-size: 28rpx;
    color: #333;
    line-height: 1.6;
    }
    .kb-empty {
    text-align: center;
    color: #ccc;
    padding: 40rpx 0;
    font-size: 28rpx;
    }
    </style>
    
  • [x] Step 2: Commit

    cd /sc-data/cfc
    git add cfc-frontend/components/health-knowledge-popup.vue
    git commit -m "feat(frontend): add health-knowledge-popup component"
    

Task 10: 前端 — pages.json 注册 3 个新页面

实施记录(2026-08-22 更新): 初版仅注册 species/risks 两条;同日已补齐——gut-flora-foods-detail 注册至 health 分包(页面自 growth/foods-detail 迁移而来,见附录偏差①)。现 pages.json 含全部 3 条 gut-flora 子页 + 主页,与计划一致。

  • Step 1: 在 pages.json 中 health 分组内追加 3 条路径

"path": "pages/health/gut-flora-detail" 条目之后追加:

    {
      "path": "pages/health/gut-flora-species-detail",
      "style": {
        "navigationBarTitleText": "菌群详情"
      }
    },
    {
      "path": "pages/health/gut-flora-foods-detail",
      "style": {
        "navigationBarTitleText": "饮食推荐"
      }
    },
    {
      "path": "pages/health/gut-flora-risks-detail",
      "style": {
        "navigationBarTitleText": "疾病风险"
      }
    }
  • [x] Step 2: Commit

    cd /sc-data/cfc
    git add cfc-frontend/pages.json
    git commit -m "feat(frontend): register 3 new health detail pages in pages.json"
    

Task 11: 前端 — 食物推荐详情页

实施记录(2026-08-22 更新): 页面现已存在于计划路径 cfc-frontend/pages/health/gut-flora-foods-detail.vue——由 pages/growth/foods-detail.vue 经 git mv 迁移归位,功能含知识库营养数据合并增强;主页跳转已同步更新。详见附录偏差①修复记录。

Files:

  • Create: cfc-frontend/pages/health/gut-flora-foods-detail.vue

  • [x] Step 1: 创建页面(最简单页面,先做以验证编辑流)

    <template>
    <view class="page">
    <view class="nav-bar">
      <view class="nav-back" @tap="goBack">
        <text class="back-text">‹ 返回</text>
      </view>
      <text class="nav-title">饮食推荐</text>
      <text class="nav-edit" @tap="toggleEdit">{{ isEditing ? '完成' : '编辑' }}</text>
    </view>
    
    <!-- 分类筛选条 -->
    <scroll-view class="filter-bar" scroll-x>
      <view class="filter-item" v-for="(cat, idx) in categories" :key="idx"
            :class="'filter-' + (selectedCategory === cat ? 'active' : '')"
            @tap="selectedCategory = cat">
        <text>{{ cat }}</text>
      </view>
    </scroll-view>
    
    <!-- 排序切换 -->
    <view class="sort-bar">
      <text class="sort-text" @tap="toggleSort">排序: {{ sortDesc ? '推荐↓' : '推荐↑' }}</text>
      <text class="sort-text" @tap="toggleFilter">仅推荐</text>
    </view>
    
    <scroll-view class="content" scroll-y>
      <view class="food-card" v-for="(item, idx) in filteredFoods" :key="idx">
        <view class="food-header">
          <text class="food-name">{{ item.foodName || item.name }}</text>
          <view class="food-cat-tag">
            <text>{{ item.category }}</text>
          </view>
        </view>
        <view class="food-detail-row" v-if="!isEditing">
          <text class="food-nutrition">蛋白{{ item.protein || '--' }} | 脂肪{{ item.fat || '--' }} | 碳水{{ item.carb || '--' }} | 纤维{{ item.fiber || '--' }}</text>
        </view>
        <view class="food-detail-row" v-if="!isEditing">
          <text class="food-nutrition" v-if="item.energy">能量 {{ item.energy }}KJ</text>
        </view>
        <view class="food-edit-row" v-if="isEditing">
          <text class="edit-label">推荐指数:</text>
          <input class="edit-input" type="number" v-model="item.score" />
        </view>
        <view class="food-score" :class="'score-' + (item.score >= 5 ? 'good' : 'normal')">
          <text>推荐: {{ item.score }}</text>
        </view>
      </view>
      <view class="empty-hint" v-if="filteredFoods.length === 0">暂无数据</view>
      <view class="bottom-spacer"></view>
    </scroll-view>
    
    <!-- 保存按钮(编辑模式) -->
    <view class="bottom-bar" v-if="isEditing">
      <view class="btn-cancel" @tap="cancelEdit">取消</view>
      <view class="btn-save" @tap="saveEdit">保存</view>
    </view>
    </view>
    </template>
    
    <script>
    import { getReportDetail, editHealthReport } from '../../utils/api.js'
    
    export default {
    data() {
    return {
      reportId: null,
      reportData: null,
      foods: [],
      isEditing: false,
      editFoods: [],
      selectedCategory: '全部',
      sortDesc: true,
      showRecommendedOnly: false,
      categories: ['全部', '主食', '蔬菜', '水果', '肉类', '其它']
    }
    },
    computed: {
    filteredFoods: function() {
      let list = this.isEditing ? this.editFoods : this.foods
      if (this.selectedCategory !== '全部') {
        list = list.filter(function(item) {
          return item.category === this.selectedCategory
        }.bind(this))
      }
      if (this.showRecommendedOnly) {
        list = list.filter(function(item) {
          return (item.score || 0) >= 5
        })
      }
      // 排序
      list = [].concat(list)
      list.sort(function(a, b) {
        var sa = a.score || 0
        var sb = b.score || 0
        return this.sortDesc ? sb - sa : sa - sb
      }.bind(this))
      return list
    }
    },
    onLoad: function(options) {
    this.reportId = options.reportId ? parseInt(options.reportId) : null
    if (this.reportId) {
      this.loadData()
    }
    },
    methods: {
    goBack: function() {
      uni.navigateBack()
    },
    loadData: function() {
      var self = this
      getReportDetail(this.reportId).then(function(res) {
        if (res.code === 200 && res.data) {
          self.reportData = res.data
          self.foods = res.data.foods || []
        }
      })
    },
    toggleEdit: function() {
      this.isEditing = true
      // 深拷贝
      this.editFoods = JSON.parse(JSON.stringify(this.foods))
    },
    cancelEdit: function() {
      this.isEditing = false
      this.editFoods = []
    },
    saveEdit: function() {
      var self = this
      editHealthReport(self.reportId, { foods: self.editFoods }, 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' })
        }
      })
    },
    toggleSort: function() {
      this.sortDesc = !this.sortDesc
    },
    toggleFilter: function() {
      this.showRecommendedOnly = !this.showRecommendedOnly
    }
    }
    }
    </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; }
    .nav-edit { font-size: 28rpx; color: #4A9BD7; padding: 10rpx 0; }
    
    .filter-bar { background: #fff; padding: 10rpx 20rpx; white-space: nowrap; }
    .filter-item { display: inline-block; padding: 12rpx 28rpx; margin: 0 10rpx; border-radius: 30rpx; font-size: 26rpx; background: #f0f0f0; color: #666; }
    .filter-active { background: #4A9BD7; color: #fff; }
    
    .sort-bar { display: flex; justify-content: space-between; padding: 16rpx 30rpx; background: #fff; border-top: 1rpx solid #eee; }
    .sort-text { font-size: 24rpx; color: #999; }
    
    .content { padding: 20rpx 30rpx; height: calc(100vh - 200rpx); }
    .food-card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; }
    .food-header { display: flex; align-items: center; margin-bottom: 12rpx; }
    .food-name { font-size: 30rpx; font-weight: 600; color: #333; flex: 1; }
    .food-cat-tag { padding: 4rpx 16rpx; background: #f0f0f0; border-radius: 20rpx; font-size: 22rpx; color: #666; }
    .food-detail-row { margin-bottom: 6rpx; }
    .food-nutrition { font-size: 24rpx; color: #999; }
    .food-edit-row { display: flex; align-items: center; margin-bottom: 6rpx; }
    .edit-label { font-size: 26rpx; color: #666; margin-right: 12rpx; }
    .edit-input { border: 1rpx solid #ddd; border-radius: 8rpx; padding: 8rpx 12rpx; font-size: 26rpx; width: 120rpx; }
    .food-score { margin-top: 8rpx; font-size: 24rpx; }
    .score-good { color: #10B981; }
    .score-normal { color: #999; }
    .empty-hint { text-align: center; color: #ccc; padding: 60rpx 0; font-size: 28rpx; }
    
    .bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; display: flex; padding: 20rpx 30rpx; background: #fff; border-top: 1rpx solid #eee; gap: 20rpx; }
    .btn-cancel { flex: 1; padding: 20rpx; border: 1rpx solid #ddd; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #666; }
    .btn-save { flex: 1; padding: 20rpx; background: #4A9BD7; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #fff; }
    .bottom-spacer { height: 40rpx; }
    </style>
    
  • [x] Step 2: Commit

    cd /sc-data/cfc
    git add cfc-frontend/pages/health/gut-flora-foods-detail.vue
    git commit -m "feat(frontend): add gut-flora-foods-detail page"
    

Task 12: 前端 — 疾病风险详情页

Files:

  • Create: cfc-frontend/pages/health/gut-flora-risks-detail.vue

  • [x] Step 1: 创建页面

    <template>
    <view class="page">
    <view class="nav-bar">
      <view class="nav-back" @tap="goBack">
        <text class="back-text">‹ 返回</text>
      </view>
      <text class="nav-title">疾病风险</text>
      <text class="nav-edit" @tap="toggleEdit">{{ isEditing ? '完成' : '编辑' }}</text>
    </view>
    
    <scroll-view class="content" scroll-y>
      <view class="section-label">重要风险</view>
      <view class="risk-card" v-for="(item, idx) in importantRisks" :key="'imp-' + idx"
            :class="'risk-level-' + (item.riskLevel || 'low')">
        <view class="risk-header">
          <text class="risk-name" @tap="showKnowledge('disease', item.diseaseName)">{{ item.diseaseName }}</text>
          <text class="risk-badge" :class="'badge-' + (item.riskLevel || 'low')">
            {{ riskBadgeText(item.riskLevel) }}
          </text>
        </view>
        <view class="risk-detail-row" v-if="!isEditing">
          <text class="risk-value">风险值: {{ item.riskValue || '--' }}</text>
          <text class="risk-level-text">等级: {{ item.riskLevel || '--' }}</text>
        </view>
        <view class="risk-edit-row" v-if="isEditing">
          <text class="edit-label">风险值:</text>
          <input class="edit-input" type="text" v-model="item.riskValue" />
          <text class="edit-label">等级:</text>
          <picker :value="riskLevelIndex(item.riskLevel)" :range="riskLevels" @change="onRiskLevelChange($event, item)">
            <text class="edit-picker">{{ item.riskLevel || '请选择' }}</text>
          </picker>
        </view>
      </view>
    
      <view class="section-label">其它风险</view>
      <view class="risk-card" v-for="(item, idx) in normalRisks" :key="'norm-' + idx"
            :class="'risk-level-' + (item.riskLevel || 'low')">
        <view class="risk-header">
          <text class="risk-name" @tap="showKnowledge('disease', item.diseaseName)">{{ item.diseaseName }}</text>
          <text class="risk-badge" :class="'badge-' + (item.riskLevel || 'low')">
            {{ riskBadgeText(item.riskLevel) }}
          </text>
        </view>
        <view class="risk-detail-row" v-if="!isEditing">
          <text class="risk-value">风险值: {{ item.riskValue || '--' }}</text>
          <text class="risk-level-text">等级: {{ item.riskLevel || '--' }}</text>
        </view>
        <view class="risk-edit-row" v-if="isEditing">
          <text class="edit-label">风险值:</text>
          <input class="edit-input" type="text" v-model="item.riskValue" />
          <text class="edit-label">等级:</text>
          <picker :value="riskLevelIndex(item.riskLevel)" :range="riskLevels" @change="onRiskLevelChange($event, item)">
            <text class="edit-picker">{{ item.riskLevel || '请选择' }}</text>
          </picker>
        </view>
      </view>
      <view class="empty-hint" v-if="risks.length === 0">暂无疾病风险评估</view>
      <view class="bottom-spacer"></view>
    </scroll-view>
    
    <health-knowledge-popup :visible="knowledgeVisible" :data="knowledgeData" @close="closeKnowledge" />
    
    <view class="bottom-bar" v-if="isEditing">
      <view class="btn-cancel" @tap="cancelEdit">取消</view>
      <view class="btn-save" @tap="saveEdit">保存</view>
    </view>
    </view>
    </template>
    
    <script>
    import { getReportDetail, editHealthReport, queryKnowledgeBase } from '../../utils/api.js'
    import HealthKnowledgePopup from '../../components/health-knowledge-popup.vue'
    
    export default {
    components: { HealthKnowledgePopup },
    data() {
    return {
      reportId: null,
      reportData: null,
      risks: [],
      isEditing: false,
      editRisks: [],
      riskLevels: ['低风险', '需注意', '高风险', '异常'],
      knowledgeVisible: false,
      knowledgeData: {}
    }
    },
    computed: {
    importantRisks: function() {
      return this.getRisks().filter(function(item) {
        var level = item.riskLevel || ''
        return level === '需注意' || level === '高风险' || level === '异常'
      })
    },
    normalRisks: function() {
      return this.getRisks().filter(function(item) {
        var level = item.riskLevel || ''
        return level !== '需注意' && level !== '高风险' && level !== '异常'
      })
    }
    },
    onLoad: function(options) {
    this.reportId = options.reportId ? parseInt(options.reportId) : null
    if (this.reportId) {
      this.loadData()
    }
    },
    methods: {
    goBack: function() { uni.navigateBack() },
    getRisks: function() {
      return this.isEditing ? this.editRisks : this.risks
    },
    loadData: function() {
      var self = this
      getReportDetail(this.reportId).then(function(res) {
        if (res.code === 200 && res.data) {
          self.reportData = res.data
          self.risks = res.data.diseaseRisks || []
        }
      })
    },
    toggleEdit: function() {
      this.isEditing = true
      this.editRisks = JSON.parse(JSON.stringify(this.risks))
    },
    cancelEdit: function() {
      this.isEditing = false
      this.editRisks = []
    },
    saveEdit: function() {
      var self = this
      editHealthReport(self.reportId, { diseaseRisks: self.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' })
        }
      })
    },
    riskBadgeText: function(level) {
      var map = { '低风险': '✓ 低', '需注意': '⚠ 注意', '高风险': '⚠ 高', '异常': '⚠ 异常' }
      return map[level] || level || ''
    },
    riskLevelIndex: function(level) {
      var idx = this.riskLevels.indexOf(level)
      return idx >= 0 ? idx : 0
    },
    onRiskLevelChange: function(e, item) {
      item.riskLevel = this.riskLevels[e.detail.value]
    },
    showKnowledge: function(type, name) {
      var self = this
      queryKnowledgeBase(type, name).then(function(res) {
        if (res.code === 200 && res.data) {
          self.knowledgeData = res.data
          self.knowledgeVisible = true
        } else {
          self.knowledgeData = { itemName: name }
          self.knowledgeVisible = true
        }
      })
    },
    closeKnowledge: function() {
      this.knowledgeVisible = false
      this.knowledgeData = {}
    }
    }
    }
    </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; }
    .nav-edit { font-size: 28rpx; color: #4A9BD7; padding: 10rpx 0; }
    
    .content { padding: 20rpx 30rpx; height: calc(100vh - 100rpx); }
    .section-label { font-size: 26rpx; color: #999; margin: 10rpx 0 16rpx; padding-left: 8rpx; }
    
    .risk-card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; border-left: 8rpx solid #10B981; }
    .risk-level-需注意 { border-left-color: #F59E0B; }
    .risk-level-高风险 { border-left-color: #C62828; }
    .risk-level-异常 { border-left-color: #C62828; }
    
    .risk-header { display: flex; align-items: center; margin-bottom: 12rpx; }
    .risk-name { font-size: 30rpx; font-weight: 600; color: #333; flex: 1; }
    .risk-badge { padding: 4rpx 16rpx; border-radius: 20rpx; font-size: 22rpx; }
    .badge-低风险 { background: #E8F5E9; color: #2E7D32; }
    .badge-需注意 { background: #FFF3E0; color: #E65100; }
    .badge-高风险 { background: #FFEBEE; color: #C62828; }
    .badge-异常 { background: #FFEBEE; color: #C62828; }
    
    .risk-detail-row { display: flex; gap: 24rpx; }
    .risk-value, .risk-level-text { font-size: 24rpx; color: #999; }
    .risk-edit-row { display: flex; align-items: center; gap: 12rpx; flex-wrap: wrap; }
    .edit-label { font-size: 26rpx; color: #666; }
    .edit-input { border: 1rpx solid #ddd; border-radius: 8rpx; padding: 8rpx 12rpx; font-size: 26rpx; width: 120rpx; }
    .edit-picker { border: 1rpx solid #ddd; border-radius: 8rpx; padding: 8rpx 12rpx; font-size: 26rpx; color: #333; }
    
    .empty-hint { text-align: center; color: #ccc; padding: 60rpx 0; font-size: 28rpx; }
    .bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; display: flex; padding: 20rpx 30rpx; background: #fff; border-top: 1rpx solid #eee; gap: 20rpx; }
    .btn-cancel { flex: 1; padding: 20rpx; border: 1rpx solid #ddd; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #666; }
    .btn-save { flex: 1; padding: 20rpx; background: #4A9BD7; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #fff; }
    .bottom-spacer { height: 40rpx; }
    </style>
    
  • [x] Step 2: Commit

    cd /sc-data/cfc
    git add cfc-frontend/pages/health/gut-flora-risks-detail.vue
    git commit -m "feat(frontend): add gut-flora-risks-detail page with knowledge popup"
    

Task 13: 前端 — 菌群详情页(12 Tab + 颜色箭头)

Files:

  • Create: cfc-frontend/pages/health/gut-flora-species-detail.vue

  • [x] Step 1: 创建页面

    <template>
    <view class="page">
    <view class="nav-bar">
      <view class="nav-back" @tap="goBack">
        <text class="back-text">‹ 返回</text>
      </view>
      <text class="nav-title">菌群详情</text>
      <text class="nav-edit" @tap="toggleEdit">{{ isEditing ? '完成' : '编辑' }}</text>
    </view>
    
    <!-- 12 分类 Tab -->
    <scroll-view class="tabs" scroll-x>
      <view class="tab-item" v-for="(tab, idx) in tabs" :key="idx"
            :class="'tab-' + (selectedTab === tab.key ? 'active' : '')"
            @tap="selectedTab = tab.key">
        <text>{{ tab.label }}</text>
      </view>
    </scroll-view>
    
    <scroll-view class="content" scroll-y>
      <view class="flora-card" v-for="(item, idx) in filteredFlora" :key="idx">
        <view class="flora-header">
          <text class="flora-name"
                :class="'status-' + (item.status || '正常')"
                @tap="showKnowledge('bacteria', item.bacteriaName)">
            {{ item.bacteriaName }}
            <text class="arrow" v-if="item.status === '偏高' || item.status === '过多'">↑</text>
            <text class="arrow" v-else-if="item.status === '偏低' || item.status === '缺乏' || item.status === '不足'">↓</text>
          </text>
          <text class="flora-level-tag" v-if="item.level && item.level !== 'GENUS'">{{ item.level }}</text>
        </view>
        <view class="flora-details" v-if="!isEditing">
          <text class="detail-text">丰度 {{ item.bacteriaValue || '--' }} | 正常 {{ item.normalRange || '--' }}</text>
          <text class="detail-text">人群 {{ item.populationLevel || '--' }} | 检出 {{ item.detectionRate || '--' }}</text>
        </view>
        <view class="flora-edit-row" v-if="isEditing">
          <text class="edit-label">丰度:</text>
          <input class="edit-input" type="text" v-model="item.bacteriaValue" />
          <text class="edit-label">状态:</text>
          <picker :value="floraStatusIndex(item.status)" :range="floraStatuses" @change="onFloraStatusChange($event, item)">
            <text class="edit-picker">{{ item.status || '正常' }}</text>
          </picker>
        </view>
      </view>
      <view class="empty-hint" v-if="filteredFlora.length === 0">暂无该分类菌属数据</view>
      <view class="bottom-spacer"></view>
    </scroll-view>
    
    <health-knowledge-popup :visible="knowledgeVisible" :data="knowledgeData" @close="closeKnowledge" />
    
    <view class="bottom-bar" v-if="isEditing">
      <view class="btn-cancel" @tap="cancelEdit">取消</view>
      <view class="btn-save" @tap="saveEdit">保存</view>
    </view>
    </view>
    </template>
    
    <script>
    import { getReportDetail, editHealthReport, queryKnowledgeBase } from '../../utils/api.js'
    import HealthKnowledgePopup from '../../components/health-knowledge-popup.vue'
    
    export default {
    components: { HealthKnowledgePopup },
    data() {
    return {
      reportId: null,
      reportData: null,
      gutFlora: [],
      probioticSpecies: [],
      isEditing: false,
      editFlora: [],
      selectedTab: 'core',
      floraStatuses: ['正常', '偏低', '偏高', '缺乏', '不足', '过多'],
      knowledgeVisible: false,
      knowledgeData: {},
      tabs: [
        { key: 'core', label: '核心菌属' },
        { key: 'probiotic', label: '益生菌' },
        { key: 'harmful', label: '有害菌' },
        { key: 'other', label: '其它菌属' },
        { key: 'pathogen', label: '病原菌' },
        { key: 'obesity', label: '肥胖相关' },
        { key: 'constipation', label: '便秘相关' },
        { key: 'depression', label: '抑郁相关' },
        { key: 'allergy', label: '过敏相关' },
        { key: 'bloating', label: '腹胀相关' },
        { key: 'insomnia', label: '失眠相关' },
        { key: 'all', label: '全部' }
      ]
    }
    },
    computed: {
    filteredFlora: function() {
      var list = this.isEditing ? this.editFlora : this.gutFlora
      if (this.selectedTab === 'all') return list
      if (this.selectedTab === 'probiotic') {
        // probio species from separate field
        return this.isEditing ? this.editFlora.filter(function(i) { return i.category === 'probiotic' }) : this.probioticSpecies
      }
      return list.filter(function(item) {
        return item.category === this.selectedTab
      }.bind(this))
    }
    },
    onLoad: function(options) {
    this.reportId = options.reportId ? parseInt(options.reportId) : null
    if (this.reportId) {
      this.loadData()
    }
    },
    methods: {
    goBack: function() { uni.navigateBack() },
    loadData: function() {
      var self = this
      getReportDetail(this.reportId).then(function(res) {
        if (res.code === 200 && res.data) {
          self.reportData = res.data
          self.gutFlora = res.data.gutFlora || []
          self.probioticSpecies = res.data.probioticSpecies || []
        }
      })
    },
    toggleEdit: function() {
      this.isEditing = true
      this.editFlora = JSON.parse(JSON.stringify(this.gutFlora))
    },
    cancelEdit: function() {
      this.isEditing = false
      this.editFlora = []
    },
    saveEdit: function() {
      var self = this
      editHealthReport(self.reportId, { gutFlora: self.editFlora }, 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' })
        }
      })
    },
    floraStatusIndex: function(status) {
      var idx = this.floraStatuses.indexOf(status)
      return idx >= 0 ? idx : 0
    },
    onFloraStatusChange: function(e, item) {
      item.status = this.floraStatuses[e.detail.value]
    },
    showKnowledge: function(type, name) {
      var self = this
      queryKnowledgeBase(type, name).then(function(res) {
        if (res.code === 200 && res.data) {
          self.knowledgeData = res.data
          self.knowledgeVisible = true
        } else {
          self.knowledgeData = { itemName: name }
          self.knowledgeVisible = true
        }
      })
    },
    closeKnowledge: function() {
      this.knowledgeVisible = false
      this.knowledgeData = {}
    }
    }
    }
    </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; }
    .nav-edit { font-size: 28rpx; color: #4A9BD7; padding: 10rpx 0; }
    
    .tabs { background: #fff; padding: 10rpx 20rpx; white-space: nowrap; border-bottom: 1rpx solid #eee; }
    .tab-item { display: inline-block; padding: 12rpx 24rpx; margin: 0 6rpx; border-radius: 30rpx; font-size: 24rpx; background: #f0f0f0; color: #666; }
    .tab-active { background: #4A9BD7; color: #fff; }
    
    .content { padding: 20rpx 30rpx; height: calc(100vh - 180rpx); }
    .flora-card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; }
    .flora-header { display: flex; align-items: center; margin-bottom: 10rpx; }
    .flora-name { font-size: 28rpx; font-weight: 600; color: #333; flex: 1; }
    .flora-name.status-偏高 { color: #C62828; }
    .flora-name.status-过多 { color: #C62828; }
    .flora-name.status-偏低 { color: #E65100; }
    .flora-name.status-缺乏 { color: #E65100; }
    .flora-name.status-不足 { color: #E65100; }
    .arrow { font-size: 24rpx; }
    .flora-level-tag { padding: 2rpx 12rpx; background: #E3F2FD; border-radius: 16rpx; font-size: 20rpx; color: #4A9BD7; }
    .flora-details { }
    .detail-text { font-size: 24rpx; color: #999; display: block; line-height: 1.8; }
    .flora-edit-row { display: flex; align-items: center; gap: 12rpx; flex-wrap: wrap; }
    .edit-label { font-size: 26rpx; color: #666; }
    .edit-input { border: 1rpx solid #ddd; border-radius: 8rpx; padding: 8rpx 12rpx; font-size: 26rpx; width: 140rpx; }
    .edit-picker { border: 1rpx solid #ddd; border-radius: 8rpx; padding: 8rpx 12rpx; font-size: 26rpx; color: #333; }
    
    .empty-hint { text-align: center; color: #ccc; padding: 60rpx 0; font-size: 28rpx; }
    .bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; display: flex; padding: 20rpx 30rpx; background: #fff; border-top: 1rpx solid #eee; gap: 20rpx; }
    .btn-cancel { flex: 1; padding: 20rpx; border: 1rpx solid #ddd; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #666; }
    .btn-save { flex: 1; padding: 20rpx; background: #4A9BD7; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #fff; }
    .bottom-spacer { height: 40rpx; }
    </style>
    
  • [x] Step 2: Commit

    cd /sc-data/cfc
    git add cfc-frontend/pages/health/gut-flora-species-detail.vue
    git commit -m "feat(frontend): add gut-flora-species-detail page (12 tabs + color arrows + knowledge popup)"
    

Task 14: 前端 — 主页 gut-flora-detail.vue 重写

Files:

  • Modify: cfc-frontend/pages/health/gut-flora-detail.vue(完全重写)

  • [x] Step 1: 重写主页 — 完整长 scroll + 评分圆环 + 手风琴 + 颜色箭头 + 知识库 + 编辑模式

    <template>
    <view class="page">
    <view class="nav-bar">
      <view class="nav-back" @tap="goBack">
        <text class="back-text">‹ 返回</text>
      </view>
      <text class="nav-title">肠道菌群报告</text>
    </view>
    
    <scroll-view class="content" scroll-y>
      <!-- 报告头部 -->
      <view class="report-header">
        <text class="report-title">肠道菌群检测报告</text>
        <view class="report-meta">
          <text class="meta-item" v-if="report.personName">{{ report.personName }}</text>
          <text class="meta-item" v-if="report.gender">{{ report.gender }}</text>
          <text class="meta-item" v-if="report.age">{{ report.age }}岁</text>
        </view>
        <view class="report-meta">
          <text class="meta-item" v-if="report.reportDate">日期: {{ report.reportDate }}</text>
          <text class="meta-item" v-if="report.reportNumber">编号: {{ report.reportNumber }}</text>
        </view>
        <view class="badge-row" v-if="report.gutAge || report.gutType">
          <text class="badge" v-if="report.gutAge">肠龄: {{ report.gutAge }}</text>
          <text class="badge" v-if="report.gutType">肠型: {{ report.gutType }}</text>
        </view>
      </view>
    
      <!-- 评分圆环区域,横向滚动 -->
      <scroll-view class="scores-scroll" scroll-x>
        <view class="score-item" v-for="(score, idx) in scoreItems" :key="idx">
          <view class="score-ring" :style="'border-color: ' + score.color + '; color: ' + score.color">
            <text class="score-val">{{ score.value }}</text>
          </view>
          <text class="score-label">{{ score.label }}</text>
        </view>
      </scroll-view>
    
      <!-- 快速跳转入口 -->
      <view class="quick-nav">
        <view class="nav-card" @tap="goToSpecies">
          <text class="nav-icon">🦠</text>
          <text class="nav-label">菌群详情</text>
          <text class="nav-count">{{ floraCount }}种</text>
        </view>
        <view class="nav-card" @tap="goToRisks">
          <text class="nav-icon">⚠️</text>
          <text class="nav-label">疾病风险</text>
          <text class="nav-count">{{ riskCount }}项</text>
        </view>
        <view class="nav-card" @tap="goToFoods">
          <text class="nav-icon">🍽️</text>
          <text class="nav-label">饮食推荐</text>
          <text class="nav-count">{{ foodCount }}项</text>
        </view>
        <view class="nav-card" @tap="goToIndicators">
          <text class="nav-icon">📋</text>
          <text class="nav-label">全部指标</text>
          <text class="nav-count">{{ indicatorCount }}项</text>
        </view>
      </view>
    
      <!-- 肠道功能面板(手风琴) -->
      <view class="accordion-section">
        <view class="accordion-header" @tap="toggleAccordion('gut')">
          <text class="accordion-title">肠道功能与代谢物</text>
          <text class="accordion-arrow">{{ accordionOpen.gut ? '收起' : '展开' }}</text>
        </view>
        <view class="accordion-body" v-if="accordionOpen.gut">
          <view class="indicator-card" v-for="(item, idx) in gutIndicators" :key="idx">
            <view class="indicator-row">
              <text class="indicator-name" @tap="showKnowledge(indicatorType(item), item.indicatorName)">
                {{ item.indicatorName }}
                <text class="arrow" v-if="item.status === '偏高' || item.status === '过多'">↑</text>
                <text class="arrow" v-else-if="item.status === '偏低' || item.status === '缺乏' || item.status === '不足'">↓</text>
                <text class="arrow" v-else-if="item.status === '异常'">⚠</text>
              </text>
              <view class="indicator-value" v-if="!isEditing">
                <text :class="'val-text val-' + item.status">{{ item.indicatorValue || '--' }}</text>
              </view>
              <view class="indicator-edit" v-if="isEditing">
                <input class="edit-input" type="text" v-model="item.indicatorValue" />
                <picker :value="indStatusIndex(item.status)" :range="indStatuses" @change="onIndStatusChange($event, item)">
                  <text class="edit-picker">{{ item.status || '正常' }}</text>
                </picker>
              </view>
            </view>
            <text class="indicator-range" v-if="item.refRange">参考: {{ item.refRange }}</text>
          </view>
        </view>
      </view>
    
      <!-- 营养指标面板(手风琴) -->
      <view class="accordion-section">
        <view class="accordion-header" @tap="toggleAccordion('nutri')">
          <text class="accordion-title">营养指标</text>
          <text class="accordion-arrow">{{ accordionOpen.nutri ? '收起' : '展开' }}</text>
        </view>
        <view class="accordion-body" v-if="accordionOpen.nutri">
          <!-- 按分类子分组 -->
          <view class="sub-group" v-for="(group, gIdx) in nutritionGroups" :key="gIdx">
            <text class="sub-group-title">{{ group.category }}</text>
            <view class="indicator-card" v-for="(item, idx) in group.items" :key="idx">
              <view class="indicator-row">
                <text class="indicator-name" @tap="showKnowledge(indicatorType(item), item.indicatorName)">
                  {{ item.indicatorName }}
                  <text class="arrow" v-if="item.status === '偏高' || item.status === '过多'">↑</text>
                  <text class="arrow" v-else-if="item.status === '偏低' || item.status === '缺乏' || item.status === '不足'">↓</text>
                  <text class="arrow" v-else-if="item.status === '异常'">⚠</text>
                </text>
                <view class="indicator-value" v-if="!isEditing">
                  <text :class="'val-text val-' + item.status">{{ item.indicatorValue || '--' }}</text>
                </view>
                <view class="indicator-edit" v-if="isEditing">
                  <input class="edit-input" type="text" v-model="item.indicatorValue" />
                  <picker :value="indStatusIndex(item.status)" :range="indStatuses" @change="onIndStatusChange($event, item)">
                    <text class="edit-picker">{{ item.status || '正常' }}</text>
                  </picker>
                </view>
              </view>
              <text class="indicator-range" v-if="item.refRange">参考: {{ item.refRange }}</text>
            </view>
          </view>
        </view>
      </view>
    
      <view class="bottom-spacer"></view>
    </scroll-view>
    
    <!-- 知识库弹出框 -->
    <health-knowledge-popup :visible="knowledgeVisible" :data="knowledgeData" @close="closeKnowledge" />
    
    <!-- FAB 编辑按钮 / 底部保存栏 -->
    <view class="fab" v-if="!isEditing" @tap="toggleEdit">
      <text class="fab-icon">✏️</text>
    </view>
    <view class="bottom-bar" v-if="isEditing">
      <view class="btn-cancel" @tap="cancelEdit">取消</view>
      <view class="btn-save" @tap="saveEdit">保存</view>
    </view>
    </view>
    </template>
    
    <script>
    import { getReportDetail, editHealthReport, queryKnowledgeBase } from '../../utils/api.js'
    import HealthKnowledgePopup from '../../components/health-knowledge-popup.vue'
    
    var INDICATOR_TYPE_MAP = {
    '维生素': 'vitamin',
    '氨基酸': 'amino_acid',
    '微量元素': 'indicator',
    '肠道屏障与代谢物': 'indicator',
    '短链脂肪酸': 'indicator',
    '神经递质与激素': 'indicator',
    '抗生素风险评估': 'indicator'
    }
    
    export default {
    components: { HealthKnowledgePopup },
    data() {
    return {
      reportId: null,
      reportData: null,
      report: {},
      isEditing: false,
      accordionOpen: { gut: false, nutri: false },
      scoreItems: [],
      allIndicators: [],
      gutIndicators: [],
      nutritionGroups: [],
      floraCount: 0,
      riskCount: 0,
      foodCount: 0,
      indicatorCount: 0,
      knowledgeVisible: false,
      knowledgeData: {},
      indStatuses: ['正常', '偏低', '偏高', '缺乏', '不足', '过多', '异常']
    }
    },
    onLoad: function(options) {
    this.reportId = options.reportId ? parseInt(options.reportId) : null
    if (this.reportId) {
      this.loadData()
    }
    },
    methods: {
    goBack: function() { uni.navigateBack() },
    loadData: function() {
      var self = this
      getReportDetail(this.reportId).then(function(res) {
        if (res.code === 200 && res.data) {
          self.reportData = res.data
          self.report = res.data.report || res.data
          self.buildScores(res.data)
          self.buildIndicators(res.data)
          self.floraCount = (res.data.gutFlora || []).length
          self.riskCount = (res.data.diseaseRisks || []).length
          self.foodCount = (res.data.foods || []).length
          self.indicatorCount = (res.data.indicators || []).length
        }
      })
    },
    buildScores: function(data) {
      var r = data.report || data
      var items = []
      var scoreFields = [
        { key: 'overallScore', label: '综合', color: '#4A9BD7' },
        { key: 'gutHealthScore', label: '菌群健康', color: '#10B981' },
        { key: 'chronicDiseaseScore', label: '慢病控制', color: '#F59E0B' },
        { key: 'nutritionScore', label: '营养均衡', color: '#FF8C42' },
        { key: 'gutBalanceScore', label: '平衡', color: '#6366F1' },
        { key: 'gutDiversityScore', label: '多样性', color: '#FF6B9D' },
        { key: 'beneficialBacteriaScore', label: '有益菌', color: '#10B981' },
        { key: 'harmfulBacteriaScore', label: '有害菌', color: '#C62828' },
        { key: 'coreSpeciesScore', label: '核心菌属', color: '#4A9BD7' }
      ]
      for (var i = 0; i < scoreFields.length; i++) {
        var f = scoreFields[i]
        if (r[f.key] != null) {
          items.push({ value: r[f.key], label: f.label, color: f.color })
        }
      }
      this.scoreItems = items
    },
    buildIndicators: function(data) {
      var indicators = data.indicators || []
      this.allIndicators = indicators
    
      // 肠道类
      var gutCategories = ['肠道屏障与代谢物', '短链脂肪酸', '神经递质与激素', '抗生素风险评估']
      this.gutIndicators = indicators.filter(function(item) {
        return gutCategories.indexOf(item.category) >= 0
      })
    
      // 营养类
      var nutriCategories = ['营养', '主要营养', '氨基酸', '维生素', '微量元素']
      var groups = []
      var seen = {}
      for (var i = 0; i < indicators.length; i++) {
        var item = indicators[i]
        if (gutCategories.indexOf(item.category) >= 0) continue
        if (!seen[item.category]) {
          seen[item.category] = true
          groups.push({ category: item.category, items: [] })
        }
        for (var g = 0; g < groups.length; g++) {
          if (groups[g].category === item.category) {
            groups[g].items.push(item)
            break
          }
        }
      }
      this.nutritionGroups = groups
    },
    toggleAccordion: function(key) {
      this.accordionOpen[key] = !this.accordionOpen[key]
    },
    indicatorType: function(item) {
      return INDICATOR_TYPE_MAP[item.category] || 'nutrient'
    },
    indStatusIndex: function(status) {
      var idx = this.indStatuses.indexOf(status)
      return idx >= 0 ? idx : 0
    },
    onIndStatusChange: function(e, item) {
      item.status = this.indStatuses[e.detail.value]
    },
    showKnowledge: function(type, name) {
      var self = this
      queryKnowledgeBase(type, name).then(function(res) {
        if (res.code === 200 && res.data) {
          self.knowledgeData = res.data
          self.knowledgeVisible = true
        } else {
          self.knowledgeData = { itemName: name }
          self.knowledgeVisible = true
        }
      })
    },
    closeKnowledge: function() {
      this.knowledgeVisible = false
      this.knowledgeData = {}
    },
    toggleEdit: function() {
      this.isEditing = true
    },
    cancelEdit: function() {
      this.isEditing = false
      this.loadData()
    },
    saveEdit: function() {
      var self = this
      var payload = {
        overallScore: this.report.overallScore,
        gutHealthScore: this.report.gutHealthScore,
        nutritionScore: this.report.nutritionScore,
        indicators: this.allIndicators
      }
      editHealthReport(self.reportId, payload, 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' })
        }
      })
    },
    goToSpecies: function() {
      uni.navigateTo({ url: '/pages/health/gut-flora-species-detail?reportId=' + this.reportId })
    },
    goToRisks: function() {
      uni.navigateTo({ url: '/pages/health/gut-flora-risks-detail?reportId=' + this.reportId })
    },
    goToFoods: function() {
      uni.navigateTo({ url: '/pages/health/gut-flora-foods-detail?reportId=' + this.reportId })
    },
    goToIndicators: function() {
      // 滚动到指标区域,而非跳转页面
      this.accordionOpen.nutri = true
    }
    }
    }
    </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 { height: calc(100vh - 100rpx); }
    
    /* 报告头部 */
    .report-header { background: linear-gradient(135deg, #4A9BD7, #6366F1); padding: 40rpx 30rpx; color: #fff; }
    .report-title { font-size: 36rpx; font-weight: 700; display: block; margin-bottom: 16rpx; }
    .report-meta { display: flex; gap: 20rpx; margin-bottom: 6rpx; }
    .meta-item { font-size: 24rpx; opacity: 0.9; }
    .badge-row { display: flex; gap: 16rpx; margin-top: 12rpx; }
    .badge { padding: 6rpx 20rpx; background: rgba(255,255,255,0.2); border-radius: 20rpx; font-size: 22rpx; }
    
    /* 评分圆环 */
    .scores-scroll { padding: 20rpx 0; white-space: nowrap; background: #fff; }
    .score-item { display: inline-flex; flex-direction: column; align-items: center; margin: 0 20rpx; }
    .score-ring { width: 100rpx; height: 100rpx; border-radius: 50%; border: 6rpx solid; display: flex; align-items: center; justify-content: center; font-size: 32rpx; font-weight: 700; margin-bottom: 8rpx; }
    .score-label { font-size: 22rpx; color: #666; }
    
    /* 快速导航 */
    .quick-nav { display: flex; padding: 20rpx; background: #fff; margin-bottom: 16rpx; }
    .nav-card { flex: 1; display: flex; flex-direction: column; align-items: center; padding: 16rpx 0; }
    .nav-icon { font-size: 40rpx; margin-bottom: 6rpx; }
    .nav-label { font-size: 24rpx; color: #333; }
    .nav-count { font-size: 20rpx; color: #999; }
    
    /* 手风琴 */
    .accordion-section { background: #fff; margin-bottom: 16rpx; }
    .accordion-header { display: flex; justify-content: space-between; padding: 30rpx; border-bottom: 1rpx solid #f0f0f0; }
    .accordion-title { font-size: 30rpx; font-weight: 600; color: #333; }
    .accordion-arrow { font-size: 24rpx; color: #999; }
    .accordion-body { padding: 10rpx 30rpx 20rpx; }
    
    .sub-group { margin-bottom: 16rpx; }
    .sub-group-title { font-size: 26rpx; color: #4A9BD7; font-weight: 600; display: block; margin: 12rpx 0 8rpx; }
    
    .indicator-card { padding: 16rpx 0; border-bottom: 1rpx solid #f5f5f5; }
    .indicator-row { display: flex; align-items: center; justify-content: space-between; }
    .indicator-name { font-size: 28rpx; color: #333; flex: 1; }
    .indicator-name .arrow { font-size: 22rpx; }
    
    .val-text { font-size: 28rpx; font-weight: 600; color: #333; }
    .val-偏高 { color: #C62828; }
    .val-过多 { color: #C62828; }
    .val-偏低 { color: #E65100; }
    .val-缺乏 { color: #E65100; }
    .val-不足 { color: #E65100; }
    .val-异常 { color: #C62828; }
    .val-注意 { color: #F59E0B; }
    
    .indicator-range { font-size: 22rpx; color: #bbb; display: block; margin-top: 4rpx; }
    
    .indicator-edit { display: flex; gap: 8rpx; align-items: center; }
    .edit-input { border: 1rpx solid #ddd; border-radius: 8rpx; padding: 6rpx 10rpx; font-size: 26rpx; width: 100rpx; }
    .edit-picker { border: 1rpx solid #ddd; border-radius: 8rpx; padding: 6rpx 10rpx; font-size: 24rpx; color: #333; }
    
    /* FAB */
    .fab { position: fixed; right: 30rpx; bottom: 40rpx; width: 100rpx; height: 100rpx; background: #4A9BD7; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 4rpx 16rpx rgba(74,155,215,0.4); }
    .fab-icon { font-size: 40rpx; color: #fff; }
    
    /* 底部编辑栏 */
    .bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; display: flex; padding: 20rpx 30rpx; background: #fff; border-top: 1rpx solid #eee; gap: 20rpx; z-index: 100; }
    .btn-cancel { flex: 1; padding: 20rpx; border: 1rpx solid #ddd; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #666; }
    .btn-save { flex: 1; padding: 20rpx; background: #4A9BD7; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #fff; }
    .bottom-spacer { height: 40rpx; }
    </style>
    
  • [x] Step 2: Commit

    cd /sc-data/cfc
    git add cfc-frontend/pages/health/gut-flora-detail.vue
    git commit -m "feat(frontend): rewrite gut-flora-detail with full data, accordion, colors, knowledge popup, edit mode"
    

Task 15: E2E 测试适配

Files:

  • Modify: tests/e2e/flora-microbiome-flow.spec.js

  • [x] Step 1: 检查并更新现有 E2E 测试

读取现有测试文件,确认 .kb-link.kb-popup-overlay.flora-name 等新选择器是否涵盖。若无,添加新断言并在关键操作步骤增加超时。

关键检查项:

  • 报告头部显示(.report-title, .badge
  • 评分圆环存在(.score-item 数量 >= 3)
  • 快速导航入口可见(.nav-card 数量 = 4)
  • 手风琴可折叠(.accordion-header点击后 .accordion-body 可见)
  • 知识库弹出框触发(.indicator-name 点击后 .kb-popup-overlay 可见)
  • 颜色状态类名(.val-偏高, .status-偏低 等)
  • FAB 编辑按钮(.fab
  • 导航到详情页路径正确

为 Playwright 配置 viewport: { width: 375, height: 667 }(iPhone 6/7/8 尺寸)

  • [x] Step 2: Commit

    cd /sc-data/cfc
    git add tests/e2e/flora-microbiome-flow.spec.js
    git commit -m "test(e2e): update flora-microbiome-flow tests for restructured pages"
    

Task 16: 最终验证

  • Step 1: 后端编译

Run: cd /sc-data/cfc/cfc-backend && mvn clean compile Expected: BUILD SUCCESS

  • Step 2: 检查小程序项目完整注册

Run: cd /sc-data/cfc && grep -c 'gut-flora' cfc-frontend/pages.json Expected: 4(gut-flora-detail, gut-flora-species-detail, gut-flora-risks-detail, gut-flora-foods-detail;✅ 2026-08-22 偏差①修复后达标)

  • Step 3: 检查所有新文件

Run: cd /sc-data/cfc && git ls-files -- 'cfc-backend/' 'cfc-frontend/' | grep -E '(KnowledgeBase|gut-flora|knowledge-popup)' Expected: 所有新文件已跟踪

  • [x] Step 4: 最终 push

    cd /sc-data/cfc
    git push origin cfclub
    

Self-Review Checklist

1. 规范覆盖度

规范需求 对应 Task
知识库独立表 health_knowledge_base Task 1 (Entity) + Task 5 (Migration)
知识库查询 API(单条+批量) Task 3 (Service) + Task 4 (Controller)
报告编辑 API Task 6
仅展示个性化数据(无内嵌说明) Task 12-14(所有卡片不显示 description)
菌名/指标名可点击弹出知识库 Task 12-14(@tap="showKnowledge")
颜色+箭头表示异常值 Task 13-14(:class="'status-' + item.status" + <text class="arrow">
主页手风琴 Task 14(toggleAccordion
4 页架构 Task 10 (pages.json) + Task 11-14
12 分类 Tab + 卡片 Task 13
编辑模式切换 Task 11-14(toggleEdit/cancelEdit/saveEdit
数据库迁移 Task 5(迁移93)
schema.sql 同步 Task 5
异常值颜色: 偏高=红↑, 偏低=琥珀↓ Task 13-14
E2E 测试 Task 15

2. 占位符扫描

  • 所有代码块包含完整实现,无 TBD/TODO
  • 所有文件路径使用绝对(相对于项目根)
  • 所有 API 方法签名在前后端间一致

3. 类型一致性

  • HealthKnowledgeBase 的字段名与数据库列名一致(itemTypeitem_type via MyBatis-Plus 自动映射)
  • Controller 方法参数名与前端 api.js 调用参数一致
  • batchQuery 的 Map key 格式 "itemType:itemName" 在前端 /utils/api.js 和 Service 中一致

Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-07-17-flora-microbiome-report-implementation.md.

Two execution options:

  1. Subagent-Driven (recommended) — I dispatch a fresh subagent per task, review between tasks, fast iteration
  2. Inline Execution — Execute tasks in this session with checkpoints

Which approach?


附录:实施偏差记录(2026-08-22 核实回填)

偏差① 食物推荐页落地路径变更 —— ✅ 已修复(2026-08-22)

偏差原状:初版实施将食物推荐页落在 pages/growth/foods-detail.vue,未按计划创建 pages/health/gut-flora-foods-detail.vue

修复动作(2026-08-22)

修复内容
文件迁移 git mv cfc-frontend/pages/growth/foods-detail.vue cfc-frontend/pages/health/gut-flora-foods-detail.vue(git 历史保留)
pages.json 移除 growth 分包注册;health 分包新增 gut-flora-foods-detail(gut-flora-risks-detail 之后),4 页架构齐备
主页入口 gut-flora-detail.vue goToFoods() 改跳 /pages/health/gut-flora-foods-detail?reportId=
功能保留 知识库「食物库」营养数据合并增强(getKnowledgeSection)原样保留
验证 script 块 node --check 通过;pages.json JSON 校验通过;全仓无 growth/foods-detail 残留引用;E2E spec 无 foods 路径断言、无需变更

注:growth/caution-detail(戒备食材页)与 diet/index.vue(独立饮食推荐系统)不属菌群报告流,维持原位。

偏差② 迁移编号变化

计划编写时为「迁移93」,实际落地时该编号已被其它迁移占用,health_knowledge_base 表创建落在 迁移98(DatabaseInitializer.java:6795,schema.sql:3293 已同步)。

核实证据清单

  • 后端四件套存在:entity/HealthKnowledgeBase.javamapper/HealthKnowledgeBaseMapper.javaservice/HealthKnowledgeBaseService.javacontroller/HealthKnowledgeBaseController.java(且扩展了 v3/unified 批量端点)
  • 编辑链路存在:HealthReportController /report/edit(L357) + updateReportFromPayload + DimensionScoreService.refreshFromGutReport
  • 前端存在:components/health-knowledge-popup.vuegut-flora-detail.vue(重写版)、gut-flora-species-detail.vuegut-flora-risks-detail.vue、api.js 三个方法(L1941-1947)
  • E2E 存在:tests/e2e/flora-microbiome-flow.spec.js
  • 五维页面接入:身(body-detail 营养模块+肠胃健康评分+菌群评分面板)、心(mind-detail 情绪与肠胃 + /api/mind/neurotransmitters)、智(wisdom-detail 肠胃与认知 + /api/wisdom/gut-cognition);行/富无菌群业务(符合预期)