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 处落地路径偏差(食物推荐页)已于同日修复归位,详见文末「附录:实施偏差记录」。文中代码块为计划原稿,实际代码以仓库为准。
| 文件 | 操作 | 职责 |
|---|---|---|
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 |
| 文件 | 操作 | 职责 |
|---|---|---|
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 | 验证知识库交互+新选择器 |
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"
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"
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)"
Files:
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthKnowledgeBaseController.javaBean 名冲突检查: 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)"
Files:
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.javaModify: 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)"
Files:
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.javaModify: 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;
在 HealthReportService.java 中确认以下字段已存在,若不存在则添加:
@Resource
private HealthIndicatorMapper healthIndicatorMapper;
@Resource
private HealthGutFloraMapper healthGutFloraMapper;
@Resource
private HealthDiseaseRiskMapper healthDiseaseRiskMapper;
@Resource
private HealthReportMapper healthReportMapper;
@Resource
private DimensionScoreService dimensionScoreService;
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"
Files:
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"
[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"
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"
✅ 实施记录(2026-08-22 更新): 初版仅注册 species/risks 两条;同日已补齐——
gut-flora-foods-detail注册至 health 分包(页面自 growth/foods-detail 迁移而来,见附录偏差①)。现 pages.json 含全部 3 条 gut-flora 子页 + 主页,与计划一致。
在 "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"
✅ 实施记录(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"
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"
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)"
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"
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)为 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"
Run: cd /sc-data/cfc/cfc-backend && mvn clean compile
Expected: BUILD SUCCESS
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 偏差①修复后达标)
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
| 规范需求 | 对应 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 |
TBD/TODOHealthKnowledgeBase 的字段名与数据库列名一致(itemType ↔ item_type via MyBatis-Plus 自动映射)api.js 调用参数一致batchQuery 的 Map key 格式 "itemType:itemName" 在前端 /utils/api.js 和 Service 中一致Plan complete and saved to docs/superpowers/plans/2026-07-17-flora-microbiome-report-implementation.md.
Two execution options:
Which approach?
偏差原状:初版实施将食物推荐页落在 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.java、mapper/HealthKnowledgeBaseMapper.java、service/HealthKnowledgeBaseService.java、controller/HealthKnowledgeBaseController.java(且扩展了 v3/unified 批量端点)HealthReportController /report/edit(L357) + updateReportFromPayload + DimensionScoreService.refreshFromGutReportcomponents/health-knowledge-popup.vue、gut-flora-detail.vue(重写版)、gut-flora-species-detail.vue、gut-flora-risks-detail.vue、api.js 三个方法(L1941-1947)tests/e2e/flora-microbiome-flow.spec.js/api/mind/neurotransmitters)、智(wisdom-detail 肠胃与认知 + /api/wisdom/gut-cognition);行/富无菌群业务(符合预期)