|
@@ -0,0 +1,2142 @@
|
|
|
|
|
+# 菌群报告页面重构 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
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 文件结构总览
|
|
|
|
|
+
|
|
|
|
|
+### 后端(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 | 食物推荐(筛选+卡片+编辑) |
|
|
|
|
|
+| `pages/health/gut-flora-risks-detail.vue` | NEW | 疾病风险(分类+卡片+编辑) |
|
|
|
|
|
+| `pages.json` | MODIFY | 注册 3 个新页面 |
|
|
|
|
|
+| `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`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 创建实体类**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+package com.etotem.cfc.entity;
|
|
|
|
|
+
|
|
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
|
|
+import lombok.Data;
|
|
|
|
|
+
|
|
|
|
|
+import java.io.Serializable;
|
|
|
|
|
+import java.util.Date;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 健康知识库 — 菌属/营养素/指标标准说明
|
|
|
|
|
+ * 独立于报告数据存储,通过 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;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 编译验证**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd /sc-data/cfc/cfc-backend && mvn clean compile`
|
|
|
|
|
+Expected: BUILD SUCCESS
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 创建 Mapper 接口**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
|
|
+
|
|
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
|
|
+import com.etotem.cfc.entity.HealthKnowledgeBase;
|
|
|
|
|
+
|
|
|
|
|
+public interface HealthKnowledgeBaseMapper extends BaseMapper<HealthKnowledgeBase> {
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 编译验证**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd /sc-data/cfc/cfc-backend && mvn clean compile`
|
|
|
|
|
+Expected: BUILD SUCCESS
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 创建 Service 类**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+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);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 编译验证**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd /sc-data/cfc/cfc-backend && mvn clean compile`
|
|
|
|
|
+Expected: BUILD SUCCESS
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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/` 确保无重名
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **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 未出现
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 创建 Controller**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+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);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 编译验证**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd /sc-data/cfc/cfc-backend && mvn clean compile`
|
|
|
|
|
+Expected: BUILD SUCCESS
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 在 DatabaseInitializer 添加迁移 93**
|
|
|
|
|
+
|
|
|
|
|
+在 `runMigrations()` 末尾(`// 迁移92` 之后)追加:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ // 迁移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 行附近。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 在 schema.sql 末尾追加 DDL**
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+-- 健康知识库 — 菌属/营养素/指标说明
|
|
|
|
|
+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='健康知识库 - 菌属/营养素/指标说明';
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 编译验证**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd /sc-data/cfc/cfc-backend && mvn clean compile`
|
|
|
|
|
+Expected: BUILD SUCCESS
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 在 Controller 中新增编辑端点**
|
|
|
|
|
+
|
|
|
|
|
+在 `HealthReportController` 中(`getReportDetail` 方法之后)追加:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 编辑已发布报告 — 修正 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("已保存");
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 在 Service 中新增 `updateReportFromPayload`**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 更新已发布报告的编辑数据
|
|
|
|
|
+ * 逻辑: 只更新已有字段的值,不增删条目
|
|
|
|
|
+ */
|
|
|
|
|
+ @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());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 在 Controller 上方追加所需的 import**
|
|
|
|
|
+
|
|
|
|
|
+确认文件顶部已有:
|
|
|
|
|
+```java
|
|
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
|
|
+import org.springframework.transaction.annotation.Transactional;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+如果 `@Transactional` 没有 import,在文件顶部追加:
|
|
|
|
|
+```java
|
|
|
|
|
+import org.springframework.transaction.annotation.Transactional;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 确保 HealthReportService 中有需要的 Mapper 注入**
|
|
|
|
|
+
|
|
|
|
|
+在 `HealthReportService.java` 中确认以下字段已存在,若不存在则添加:
|
|
|
|
|
+```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
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 创建数据导入 Service(一次性工具)**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+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";
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 创建资源目录并将参考 JSON 复制到 resources/data/refs/**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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/
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 编译验证**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd /sc-data/cfc/cfc-backend && mvn clean compile`
|
|
|
|
|
+Expected: BUILD SUCCESS
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /sc-data/cfc
|
|
|
|
|
+git add cfc-backend/
|
|
|
|
|
+git commit -m "feat(backend): add KnowledgeBaseDataImporter + ref JSONs"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+### Task 8: 前端 — api.js 新增方法
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 在 utils/api.js 中追加 3 个方法**(在 `getReportDetail` 附近,约第 1515 行)
|
|
|
|
|
+
|
|
|
|
|
+```javascript
|
|
|
|
|
+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 })
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 创建弹出框组件**
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<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>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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 个新页面
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 在 pages.json 中 health 分组内追加 3 条路径**
|
|
|
|
|
+
|
|
|
|
|
+在 `"path": "pages/health/gut-flora-detail"` 条目之后追加:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+ {
|
|
|
|
|
+ "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": "疾病风险"
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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: 前端 — 食物推荐详情页
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `cfc-frontend/pages/health/gut-flora-foods-detail.vue`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 创建页面(最简单页面,先做以验证编辑流)**
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<template>
|
|
|
|
|
+ <view class="page">
|
|
|
|
|
+ <view class="nav-bar">
|
|
|
|
|
+ <view class="nav-back" @tap="goBack">
|
|
|
|
|
+ <text class="back-text">‹ 返回</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <text class="nav-title">饮食推荐</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>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 创建页面**
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<template>
|
|
|
|
|
+ <view class="page">
|
|
|
|
|
+ <view class="nav-bar">
|
|
|
|
|
+ <view class="nav-back" @tap="goBack">
|
|
|
|
|
+ <text class="back-text">‹ 返回</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <text class="nav-title">疾病风险</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>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 创建页面**
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<template>
|
|
|
|
|
+ <view class="page">
|
|
|
|
|
+ <view class="nav-bar">
|
|
|
|
|
+ <view class="nav-back" @tap="goBack">
|
|
|
|
|
+ <text class="back-text">‹ 返回</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <text class="nav-title">菌群详情</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>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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`(完全重写)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 重写主页 — 完整长 scroll + 评分圆环 + 手风琴 + 颜色箭头 + 知识库 + 编辑模式**
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<template>
|
|
|
|
|
+ <view class="page">
|
|
|
|
|
+ <view class="nav-bar">
|
|
|
|
|
+ <view class="nav-back" @tap="goBack">
|
|
|
|
|
+ <text class="back-text">‹ 返回</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <text class="nav-title">肠道菌群报告</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>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **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 尺寸)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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-foods-detail, gut-flora-risks-detail)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 检查所有新文件**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd /sc-data/cfc && git ls-files -- 'cfc-backend/' 'cfc-frontend/' | grep -E '(KnowledgeBase|gut-flora|knowledge-popup)'`
|
|
|
|
|
+Expected: 所有新文件已跟踪
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: 最终 push**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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` 的字段名与数据库列名一致(`itemType` ↔ `item_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?
|