Просмотр исходного кода

feat(plan): 方案制定支持多成员分别调研-health_status加member_id字段+修复只保存不生成bug

E2E Test Bot 3 недель назад
Родитель
Сommit
a30d0d35e5

+ 119 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -1108,6 +1108,47 @@ log.info("已添加template_id列到tasks表");
             log.warn("Activity 种子数据初始化失败: {}", e.getMessage());
             log.warn("Activity 种子数据初始化失败: {}", e.getMessage());
         }
         }
 
 
+        // 方案规则种子数据(主链路 P0-3:模板包 + 方案生成规则)
+        try {
+            Long pkgFocus = ensureTemplatePackage("专注力提升方案模板",
+                "针对注意力维度低分(0-60)的14天专注力提升训练", 14, "专注力");
+            Long pkgKeep = ensureTemplatePackage("注意力巩固方案模板",
+                "针对注意力维度正常(61-100)的14天巩固训练", 14, "注意力");
+            ensureTemplateItems(pkgFocus, new String[][]{
+                {"舒尔特方格训练", "每日5分钟舒尔特方格,提升视觉专注", "5", "daily"},
+                {"静坐冥想", "每日10分钟静坐冥想,训练抗干扰能力", "3", "daily"},
+                {"番茄钟作业法", "使用番茄钟完成当日作业,25分钟一节", "5", "daily"}
+            });
+            ensureTemplateItems(pkgKeep, new String[][]{
+                {"专注力保持打卡", "每日记录专注时长,保持良好习惯", "3", "daily"},
+                {"优质睡眠打卡", "每日21:30前入睡,保障注意力恢复", "3", "daily"},
+                {"亲子共读", "每周3次亲子共读,培养持续专注", "5", "weekly"}
+            });
+            ensurePlanRule("attention", 0, 60, pkgFocus, 1);
+            ensurePlanRule("attention", 61, 100, pkgKeep, 2);
+            log.info("方案规则种子数据已加载: 模板包 focus={}, keep={}", pkgFocus, pkgKeep);
+        } catch (Exception e) {
+            log.warn("方案规则种子数据初始化失败: {}", e.getMessage());
+        }
+
+        // 报告解析中台:注册菌群检测报告类型(主链路 P0-4)
+        try {
+            Integer count = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM report_type_registry WHERE type_id = ?", Integer.class, "gut_flora");
+            if (count == null || count == 0) {
+                jdbcTemplate.update(
+                    "INSERT INTO report_type_registry (type_id, family, display_name, description, parser_path, " +
+                    "min_confidence, is_active, source, review_status, version, created_at, updated_at) " +
+                    "VALUES ('gut_flora', 'gut_flora', '菌群检测', '募极生物/北京肠道菌群检测报告', " +
+                    "'gut_flora/extract_gut_flora.py', 30, 1, 'prebuilt', 'approved', '1.0', NOW(), NOW())");
+                log.info("已注册报告类型 gut_flora");
+            } else {
+                log.info("报告类型 gut_flora 已存在,跳过");
+            }
+        } catch (Exception e) {
+            log.warn("报告类型注册失败: {}", e.getMessage());
+        }
+
         // 文章种子数据(3 个示例文章,覆盖学习力/情绪管理/亲子教育分类)
         // 文章种子数据(3 个示例文章,覆盖学习力/情绪管理/亲子教育分类)
         try {
         try {
             insertArticleSeed("如何培养孩子的自主学习力", 
             insertArticleSeed("如何培养孩子的自主学习力", 
@@ -4821,6 +4862,46 @@ try {
         }
         }
     }
     }
 
 
+    /** 幂等创建任务模板包,返回 package id */
+    private Long ensureTemplatePackage(String name, String desc, int cycleDays, String category) {
+        Integer cnt = jdbcTemplate.queryForObject(
+            "SELECT COUNT(*) FROM task_template_packages WHERE name = ?", Integer.class, name);
+        if (cnt != null && cnt > 0) {
+            return jdbcTemplate.queryForObject(
+                "SELECT id FROM task_template_packages WHERE name = ? LIMIT 1", Long.class, name);
+        }
+        jdbcTemplate.update(
+            "INSERT INTO task_template_packages (creator_id, name, description, cycle_days, category, is_public, status, created_at, updated_at) " +
+            "VALUES (1, ?, ?, ?, ?, 1, 'published', NOW(), NOW())",
+            name, desc, cycleDays, category);
+        return jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
+    }
+
+    /** 幂等写入模板包任务项 */
+    private void ensureTemplateItems(Long packageId, String[][] items) {
+        Integer cnt = jdbcTemplate.queryForObject(
+            "SELECT COUNT(*) FROM task_template_items WHERE package_id = ?", Integer.class, packageId);
+        if (cnt != null && cnt > 0) return;
+        for (String[] it : items) {
+            jdbcTemplate.update(
+                "INSERT INTO task_template_items (package_id, task_name, description, points, frequency, sort_order, created_at, updated_at) " +
+                "VALUES (?, ?, ?, ?, ?, 0, NOW(), NOW())",
+                packageId, it[0], it[1], Integer.valueOf(it[2]), it[3]);
+        }
+    }
+
+    /** 幂等写入方案生成规则 */
+    private void ensurePlanRule(String dimension, int scoreMin, int scoreMax, Long templateId, int priority) {
+        Integer cnt = jdbcTemplate.queryForObject(
+            "SELECT COUNT(*) FROM assessment_plan_rules WHERE dimension = ? AND score_min = ? AND score_max = ?",
+            Integer.class, dimension, scoreMin, scoreMax);
+        if (cnt != null && cnt > 0) return;
+        jdbcTemplate.update(
+            "INSERT INTO assessment_plan_rules (dimension, score_min, score_max, template_id, priority, is_active, created_at, updated_at) " +
+            "VALUES (?, ?, ?, ?, ?, 1, NOW(), NOW())",
+            dimension, scoreMin, scoreMax, templateId, priority);
+    }
+
     private void insertActivitySeed(String title, String desc, String dimensionCode,
     private void insertActivitySeed(String title, String desc, String dimensionCode,
                                     String activityType, String status,
                                     String activityType, String status,
                                     String startTime, String endTime, String location,
                                     String startTime, String endTime, String location,
@@ -9190,15 +9271,52 @@ private void runMigration100() {
 		// 迁移261: butler_profiles 添加停接新单开关(管家自助选择功能)
 		// 迁移261: butler_profiles 添加停接新单开关(管家自助选择功能)
 		ensureColumn("butler_profiles", "accepting",
 		ensureColumn("butler_profiles", "accepting",
 				"TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否接受新家庭绑定: 0=停接/1=接单'");
 				"TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否接受新家庭绑定: 0=停接/1=接单'");
+
 		// 迁移262: family_join_requests 添加 inviter_user_id 列(审批人=邀请人,谁分享的卡谁确认)
 		// 迁移262: family_join_requests 添加 inviter_user_id 列(审批人=邀请人,谁分享的卡谁确认)
 		try {
 		try {
 			jdbcTemplate.execute("ALTER TABLE family_join_requests ADD COLUMN inviter_user_id BIGINT NULL COMMENT '邀请人用户ID(分享邀请令牌的人)' AFTER invite_ref");
 			jdbcTemplate.execute("ALTER TABLE family_join_requests ADD COLUMN inviter_user_id BIGINT NULL COMMENT '邀请人用户ID(分享邀请令牌的人)' AFTER invite_ref");
 			jdbcTemplate.execute("ALTER TABLE family_join_requests ADD INDEX idx_inviter (inviter_user_id)");
 			jdbcTemplate.execute("ALTER TABLE family_join_requests ADD INDEX idx_inviter (inviter_user_id)");
 			log.info("已添加inviter_user_id列到family_join_requests表");
 			log.info("已添加inviter_user_id列到family_join_requests表");
 		} catch (Exception e) {
 		} catch (Exception e) {
-			// 列/索引已存在,忽略错误
 			log.warn("family_join_requests.inviter_user_id可能已存在: {}", e.getMessage());
 			log.warn("family_join_requests.inviter_user_id可能已存在: {}", e.getMessage());
 		}
 		}
+
+		// 迁移263: articles 表 title 列添加 UNIQUE 索引(防重复发布:并发查重竞态兜底)
+		try {
+			jdbcTemplate.execute("ALTER TABLE articles ADD UNIQUE INDEX idx_articles_title (title)");
+			log.info("已为articles.title添加UNIQUE索引");
+		} catch (Exception e) {
+			try {
+				jdbcTemplate.update(
+					"UPDATE articles SET status='archived', updated_at=NOW() " +
+					"WHERE id NOT IN (" +
+					"  SELECT max_id FROM (" +
+					"    SELECT MAX(id) as max_id FROM articles GROUP BY title" +
+					"  ) tmp" +
+					") AND title IS NOT NULL AND title != ''"
+				);
+				jdbcTemplate.execute("ALTER TABLE articles ADD UNIQUE INDEX idx_articles_title (title)");
+				log.info("已清理articles重复标题并添加UNIQUE索引");
+			} catch (Exception e2) {
+				log.warn("articles.title UNIQUE索引添加失败: {}", e2.getMessage());
+			}
+		}
+
+		// 迁移264: health_status 支持按家庭成员分别建档(member_id 空=账号级,非空=成员级)
+		try {
+			jdbcTemplate.execute("ALTER TABLE health_status DROP INDEX uk_user");
+			log.info("已移除health_status账号级唯一索引uk_user");
+		} catch (Exception e) {
+			// 索引不存在,忽略
+		}
+		ensureColumn("health_status", "member_id", "BIGINT DEFAULT NULL COMMENT '家庭成员ID(NULL=账号级,非空=成员级归档)' AFTER user_id");
+		try {
+			jdbcTemplate.execute("ALTER TABLE health_status ADD INDEX idx_user_member (user_id, member_id)");
+			log.info("已为health_status添加(user_id, member_id)索引");
+		} catch (Exception e) {
+			// 索引已存在,忽略
+		}
+
 	}
 	}
 
 
 	/**
 	/**

+ 28 - 7
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthStatusController.java

@@ -13,6 +13,8 @@ import org.springframework.web.bind.annotation.RestController;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import java.math.BigDecimal;
 import java.math.BigDecimal;
+import java.util.Map;
+import com.etotem.cfc.util.ParamUtils;
 
 
 @Tag(name = "健康现状档案", description = "当前状态调研-健康现状档案接口")
 @Tag(name = "健康现状档案", description = "当前状态调研-健康现状档案接口")
 @RestController
 @RestController
@@ -22,25 +24,44 @@ public class HealthStatusController {
     @Resource
     @Resource
     private HealthStatusService healthStatusService;
     private HealthStatusService healthStatusService;
 
 
-    @Operation(summary = "查询当前账号健康现状(未填写返回 null)")
+    @Operation(summary = "查询当前账号健康现状(memberId 为空查账号级,非空查成员级)")
     @PostMapping("/get")
     @PostMapping("/get")
-    public Result<HealthStatus> get(@RequestAttribute("userId") Long userId) {
-        return Result.success(healthStatusService.getByUserId(userId));
+    public Result<HealthStatus> get(@RequestAttribute("userId") Long userId,
+                                    @RequestBody(required = false) Map<String, Object> params) {
+        Long memberId = params != null ? ParamUtils.getLong(params.get("memberId")) : null;
+        return Result.success(memberId != null
+                ? healthStatusService.getByMemberId(userId, memberId)
+                : healthStatusService.getByUserId(userId));
     }
     }
 
 
-    @Operation(summary = "保存/更新当前账号健康现状(upsert)")
+    @Operation(summary = "保存/更新当前账号健康现状(memberId 为空存账号级,非空存成员级)")
     @PostMapping("/save")
     @PostMapping("/save")
     public Result<HealthStatus> save(
     public Result<HealthStatus> save(
             @RequestAttribute("userId") Long userId,
             @RequestAttribute("userId") Long userId,
-            @RequestBody HealthStatus status) {
-        if (status == null || isEmpty(status)) {
+            @RequestBody Map<String, Object> params) {
+        Long memberId = params != null ? ParamUtils.getLong(params.get("memberId")) : null;
+        HealthStatus status = new HealthStatus();
+        if (params == null) {
+            return Result.error("请至少填写一项");
+        }
+        if (params.get("heightCm") != null) status.setHeightCm(new BigDecimal(params.get("heightCm").toString()));
+        if (params.get("weightKg") != null) status.setWeightKg(new BigDecimal(params.get("weightKg").toString()));
+        if (params.get("bloodPressure") != null) status.setBloodPressure(params.get("bloodPressure").toString());
+        if (params.get("bloodGlucose") != null) status.setBloodGlucose(new BigDecimal(params.get("bloodGlucose").toString()));
+        if (params.get("bloodLipids") != null) status.setBloodLipids(params.get("bloodLipids").toString());
+        if (params.get("diseaseHistory") != null) status.setDiseaseHistory(params.get("diseaseHistory").toString());
+        if (params.get("medications") != null) status.setMedications(params.get("medications").toString());
+        if (params.get("notes") != null) status.setNotes(params.get("notes").toString());
+        if (isEmpty(status)) {
             return Result.error("请至少填写一项");
             return Result.error("请至少填写一项");
         }
         }
         String validateMsg = validate(status);
         String validateMsg = validate(status);
         if (validateMsg != null) {
         if (validateMsg != null) {
             return Result.error(validateMsg);
             return Result.error(validateMsg);
         }
         }
-        return Result.success(healthStatusService.save(userId, status));
+        return Result.success(memberId != null
+                ? healthStatusService.saveForMember(userId, memberId, status)
+                : healthStatusService.save(userId, status));
     }
     }
 
 
     /** 至少填写一项才算有效 */
     /** 至少填写一项才算有效 */

+ 4 - 1
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthStatus.java

@@ -16,9 +16,12 @@ public class HealthStatus implements Serializable {
     @TableId(type = IdType.AUTO)
     @TableId(type = IdType.AUTO)
     private Long id;
     private Long id;
 
 
-    /** 所属账号(一个账号一份) */
+    /** 所属账号 */
     private Long userId;
     private Long userId;
 
 
+    /** 家庭成员ID(NULL=账号级,非空=成员级归档) */
+    private Long memberId;
+
     /** 身高(cm) */
     /** 身高(cm) */
     private BigDecimal heightCm;
     private BigDecimal heightCm;
 
 

+ 17 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/DanKnowledgeBaseService.java

@@ -11,6 +11,7 @@ import com.etotem.cfc.mapper.DanKnowledgeBaseDimensionMapper;
 import com.etotem.cfc.mapper.DanKnowledgeBaseMapper;
 import com.etotem.cfc.mapper.DanKnowledgeBaseMapper;
 import com.etotem.cfc.mapper.DanKnowledgeBaseTagMapper;
 import com.etotem.cfc.mapper.DanKnowledgeBaseTagMapper;
 import com.etotem.cfc.mapper.ProductDimensionConfigMapper;
 import com.etotem.cfc.mapper.ProductDimensionConfigMapper;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
 
 
@@ -21,6 +22,7 @@ import java.util.Map;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
 import com.etotem.cfc.util.SortUtil;
 import com.etotem.cfc.util.SortUtil;
 
 
+@Slf4j
 @Service
 @Service
 public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper, DanKnowledgeBase> {
 public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper, DanKnowledgeBase> {
 
 
@@ -110,12 +112,21 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
 
 
     @Transactional(rollbackFor = Exception.class)
     @Transactional(rollbackFor = Exception.class)
     public boolean deleteKnowledge(Long id) {
     public boolean deleteKnowledge(Long id) {
-        danKnowledgeBaseDimensionMapper.delete(
-            new LambdaQueryWrapper<DanKnowledgeBaseDimension>()
-                .eq(DanKnowledgeBaseDimension::getKnowledgeId, id));
-        danKnowledgeBaseTagMapper.delete(
-            new LambdaQueryWrapper<DanKnowledgeBaseTag>()
-                .eq(DanKnowledgeBaseTag::getKnowledgeId, id));
+        // 关联表可能未建/已缺失,删除主记录不允许被关联表异常阻断
+        try {
+            danKnowledgeBaseDimensionMapper.delete(
+                new LambdaQueryWrapper<DanKnowledgeBaseDimension>()
+                    .eq(DanKnowledgeBaseDimension::getKnowledgeId, id));
+        } catch (Exception e) {
+            log.warn("删除知识维度关联失败(忽略,继续删除主记录): id={}, error={}", id, e.getMessage());
+        }
+        try {
+            danKnowledgeBaseTagMapper.delete(
+                new LambdaQueryWrapper<DanKnowledgeBaseTag>()
+                    .eq(DanKnowledgeBaseTag::getKnowledgeId, id));
+        } catch (Exception e) {
+            log.warn("删除知识标签关联失败(忽略,继续删除主记录): id={}, error={}", id, e.getMessage());
+        }
         return this.removeById(id);
         return this.removeById(id);
     }
     }
 
 

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthStatusService.java

@@ -9,4 +9,10 @@ public interface HealthStatusService {
 
 
     /** 保存/更新(upsert:有则更新,无则插入;filledAt 仅首次赋值) */
     /** 保存/更新(upsert:有则更新,无则插入;filledAt 仅首次赋值) */
     HealthStatus save(Long userId, HealthStatus status);
     HealthStatus save(Long userId, HealthStatus status);
+
+    /** 查询指定家庭成员的健康现状(memberId 为空时退化为账号级) */
+    HealthStatus getByMemberId(Long userId, Long memberId);
+
+    /** 按成员保存/更新健康现状(memberId 为空时退化为账号级) */
+    HealthStatus saveForMember(Long userId, Long memberId, HealthStatus status);
 }
 }

+ 20 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthStatusServiceImpl.java

@@ -20,16 +20,33 @@ public class HealthStatusServiceImpl implements HealthStatusService {
         if (userId == null) return null;
         if (userId == null) return null;
         return healthStatusMapper.selectOne(
         return healthStatusMapper.selectOne(
                 new LambdaQueryWrapper<HealthStatus>()
                 new LambdaQueryWrapper<HealthStatus>()
-                        .eq(HealthStatus::getUserId, userId));
+                        .eq(HealthStatus::getUserId, userId)
+                        .isNull(HealthStatus::getMemberId));
     }
     }
 
 
     @Override
     @Override
     public HealthStatus save(Long userId, HealthStatus status) {
     public HealthStatus save(Long userId, HealthStatus status) {
+        return saveForMember(userId, null, status);
+    }
+
+    @Override
+    public HealthStatus getByMemberId(Long userId, Long memberId) {
+        if (userId == null) return null;
+        if (memberId == null) return getByUserId(userId);
+        return healthStatusMapper.selectOne(
+                new LambdaQueryWrapper<HealthStatus>()
+                        .eq(HealthStatus::getUserId, userId)
+                        .eq(HealthStatus::getMemberId, memberId));
+    }
+
+    @Override
+    public HealthStatus saveForMember(Long userId, Long memberId, HealthStatus status) {
         if (userId == null) throw new IllegalArgumentException("userId不能为空");
         if (userId == null) throw new IllegalArgumentException("userId不能为空");
         if (status == null) throw new IllegalArgumentException("健康现状不能为空");
         if (status == null) throw new IllegalArgumentException("健康现状不能为空");
 
 
-        HealthStatus existing = getByUserId(userId);
+        HealthStatus existing = getByMemberId(userId, memberId);
         status.setUserId(userId);
         status.setUserId(userId);
+        status.setMemberId(memberId);
         if (existing == null) {
         if (existing == null) {
             status.setId(null);
             status.setId(null);
             status.setFilledAt(new Date());
             status.setFilledAt(new Date());
@@ -41,6 +58,6 @@ public class HealthStatusServiceImpl implements HealthStatusService {
             status.setUpdatedAt(new Date());
             status.setUpdatedAt(new Date());
             healthStatusMapper.updateById(status);
             healthStatusMapper.updateById(status);
         }
         }
-        return getByUserId(userId);
+        return getByMemberId(userId, memberId);
     }
     }
 }
 }

+ 4 - 3
cfc-backend/src/main/resources/schema.sql

@@ -1392,7 +1392,7 @@ CREATE TABLE IF NOT EXISTS article_categories (
 CREATE TABLE IF NOT EXISTS articles (
 CREATE TABLE IF NOT EXISTS articles (
     id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键 ID',
     id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键 ID',
     category_id BIGINT DEFAULT 0 COMMENT '所属分类 ID',
     category_id BIGINT DEFAULT 0 COMMENT '所属分类 ID',
-    title VARCHAR(200) NOT NULL COMMENT '标题',
+    title VARCHAR(200) NOT NULL UNIQUE COMMENT '标题(唯一,防止重复发布)',
     summary TEXT COMMENT '摘要',
     summary TEXT COMMENT '摘要',
     cover_image VARCHAR(500) DEFAULT '' COMMENT '封面图 URL',
     cover_image VARCHAR(500) DEFAULT '' COMMENT '封面图 URL',
     content LONGTEXT COMMENT '富文本内容',
     content LONGTEXT COMMENT '富文本内容',
@@ -4976,7 +4976,8 @@ CREATE TABLE IF NOT EXISTS notification_templates (
 -- 当前状态调研-健康现状档案(迁移239)
 -- 当前状态调研-健康现状档案(迁移239)
 CREATE TABLE IF NOT EXISTS health_status (
 CREATE TABLE IF NOT EXISTS health_status (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    user_id BIGINT NOT NULL COMMENT '所属账号(一个账号一份)',
+    user_id BIGINT NOT NULL COMMENT '所属账号',
+    member_id BIGINT DEFAULT NULL COMMENT '家庭成员ID(NULL=账号级,非空=成员级归档)',
     height_cm DECIMAL(5,1) DEFAULT NULL COMMENT '身高(cm)',
     height_cm DECIMAL(5,1) DEFAULT NULL COMMENT '身高(cm)',
     weight_kg DECIMAL(5,1) DEFAULT NULL COMMENT '体重(kg)',
     weight_kg DECIMAL(5,1) DEFAULT NULL COMMENT '体重(kg)',
     blood_pressure VARCHAR(20) DEFAULT NULL COMMENT '血压,如 120/80',
     blood_pressure VARCHAR(20) DEFAULT NULL COMMENT '血压,如 120/80',
@@ -4987,7 +4988,7 @@ CREATE TABLE IF NOT EXISTS health_status (
     notes VARCHAR(500) DEFAULT NULL COMMENT '其他补充说明',
     notes VARCHAR(500) DEFAULT NULL COMMENT '其他补充说明',
     filled_at DATETIME DEFAULT NULL COMMENT '首次填写时间',
     filled_at DATETIME DEFAULT NULL COMMENT '首次填写时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
-    UNIQUE KEY uk_user (user_id)
+    INDEX idx_user_member (user_id, member_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='当前状态调研-健康现状档案';
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='当前状态调研-健康现状档案';
 
 
 -- 系统提示词管理表(cfc-web 后台可维护,langgraph 动态加载)
 -- 系统提示词管理表(cfc-web 后台可维护,langgraph 动态加载)

+ 188 - 35
cfc-frontend/pages/health/health-plan-summary.vue

@@ -46,6 +46,19 @@
       <view class="section-title">📋 健康问卷</view>
       <view class="section-title">📋 健康问卷</view>
       <view class="hint-text">填写健康现状,方案将参考这些信息。可跳过,但填写后方案更精准。</view>
       <view class="hint-text">填写健康现状,方案将参考这些信息。可跳过,但填写后方案更精准。</view>
 
 
+      <!-- 多成员分别填写:成员切换 tab -->
+      <view class="member-tabs" v-if="selectedMembers.length > 1">
+        <view v-for="m in selectedMembers" :key="m.memberId"
+              class="member-tab" :class="{ active: m.memberId === activeFormMemberId }"
+              @click="switchMember(m.memberId)">
+          <text>{{ m.name }}</text>
+        </view>
+      </view>
+      <view class="form-member-hint" v-if="selectedMembers.length > 1">
+        <text class="form-member-label">正在为「{{ currentMemberName }}」填写</text>
+        <text class="form-member-save">每个成员独立保存,切换后自动保留</text>
+      </view>
+
       <!-- 基础体征 -->
       <!-- 基础体征 -->
       <view class="form-section">
       <view class="form-section">
         <view class="form-title">基础体征</view>
         <view class="form-title">基础体征</view>
@@ -364,6 +377,9 @@ export default {
       medRows: [{ name: '', startDate: '', durationText: '', time: '' }],
       medRows: [{ name: '', startDate: '', durationText: '', time: '' }],
       currentMonth: '',
       currentMonth: '',
       monthEnd: '',
       monthEnd: '',
+      // 多成员分别填写:memberId → 各自的表单快照 { form, lipidCholesterol, selectedDiseases, medRows }
+      memberFormMap: {},
+      activeFormMemberId: '',
       // 方案数据
       // 方案数据
       planData: {},
       planData: {},
       sections: {
       sections: {
@@ -412,6 +428,27 @@ export default {
     },
     },
     selectedNames: function() {
     selectedNames: function() {
       return this.selectedDiseases.map(function(d) { return d.name })
       return this.selectedDiseases.map(function(d) { return d.name })
+    },
+    selectedMembers: function() {
+      var self = this
+      var out = []
+      this.selectedMemberIds.forEach(function(id) {
+        var found = null
+        for (var i = 0; i < self.executors.length; i++) {
+          if (String(self.executors[i].memberId) === String(id)) { found = self.executors[i]; break }
+        }
+        if (found) out.push(found)
+      })
+      return out
+    },
+    currentMemberName: function() {
+      var members = this.selectedMembers
+      for (var i = 0; i < members.length; i++) {
+        if (String(members[i].memberId) === String(this.activeFormMemberId)) {
+          return members[i].name
+        }
+      }
+      return '成员'
     }
     }
   },
   },
   onLoad: function(options) {
   onLoad: function(options) {
@@ -436,8 +473,7 @@ export default {
     this.currentMonth = y + '-' + mStr
     this.currentMonth = y + '-' + mStr
     var lastDay = new Date(y, m, 0).getDate()
     var lastDay = new Date(y, m, 0).getDate()
     this.monthEnd = y + '-' + mStr + '-' + (lastDay < 10 ? '0' + lastDay : '' + lastDay)
     this.monthEnd = y + '-' + mStr + '-' + (lastDay < 10 ? '0' + lastDay : '' + lastDay)
-    // 加载健康问卷已有数据
-    this.loadHealthForm()
+    // 注意:健康问卷数据由 loadExecutors 内按成员异步加载(含当前成员快照恢复),此处不重复调用
     // 自动执行
     // 自动执行
     if (options.autoExecute === '1') {
     if (options.autoExecute === '1') {
       setTimeout(function() { self.doAutoExecute() }, 1500)
       setTimeout(function() { self.doAutoExecute() }, 1500)
@@ -464,6 +500,11 @@ export default {
           } else {
           } else {
             self.selectedMemberIds = self.selectedMemberIds.map(function(id) { return String(id) })
             self.selectedMemberIds = self.selectedMemberIds.map(function(id) { return String(id) })
           }
           }
+          // 默认当前填写成员为第一个选中成员
+          if (!self.activeFormMemberId || self.selectedMemberIds.indexOf(String(self.activeFormMemberId)) < 0) {
+            self.activeFormMemberId = self.selectedMemberIds[0] || ''
+          }
+          self.loadHealthForm(self.activeFormMemberId)
         }
         }
         self._executorsLoaded = true
         self._executorsLoaded = true
       }).catch(function() { self.executors = []; self._executorsLoaded = true })
       }).catch(function() { self.executors = []; self._executorsLoaded = true })
@@ -495,23 +536,81 @@ export default {
       this.generatePlan()
       this.generatePlan()
     },
     },
 
 
-    // ===== 健康问卷方法 (从 health-status-form.vue 移植) =====
-    loadHealthForm: function() {
+    // ===== 健康问卷方法 (从 health-status-form.vue 移植,支持按成员加载) =====
+    loadHealthForm: function(memberId) {
       var self = this
       var self = this
-      getHealthStatus().then(function(res) {
+      var mid = memberId || self.activeFormMemberId || ''
+      // 已有该成员的本地快照 → 直接恢复
+      if (mid && self.memberFormMap[mid]) {
+        self.applyFormSnapshot(self.memberFormMap[mid])
+        return
+      }
+      getHealthStatus(mid || undefined).then(function(res) {
         if (res && res.code === 200 && res.data) {
         if (res && res.code === 200 && res.data) {
           var d = res.data
           var d = res.data
-          self.form.heightCm = d.heightCm || ''
-          self.form.weightKg = d.weightKg || ''
-          self.form.bloodPressure = d.bloodPressure || ''
-          self.form.bloodGlucose = d.bloodGlucose || ''
-          self.form.notes = d.notes || ''
-          self.lipidCholesterol = self.parseLipidCholesterol(d.bloodLipids)
-          self.loadDiseases(d.diseaseHistory)
-          self.medRows = self.parseMeds(d.medications)
+          var snapshot = {
+            form: {
+              heightCm: d.heightCm || '',
+              weightKg: d.weightKg || '',
+              bloodPressure: d.bloodPressure || '',
+              bloodGlucose: d.bloodGlucose || '',
+              notes: d.notes || ''
+            },
+            lipidCholesterol: self.parseLipidCholesterol(d.bloodLipids),
+            selectedDiseases: [],
+            medRows: self.parseMeds(d.medications)
+          }
+          self.memberFormMap[mid || '__account'] = snapshot
+          self.loadDiseasesInto(snapshot.selectedDiseases, d.diseaseHistory)
+          if (mid) self.applyFormSnapshot(snapshot)
+          else self.applyFormSnapshot({
+            form: snapshot.form,
+            lipidCholesterol: snapshot.lipidCholesterol,
+            selectedDiseases: snapshot.selectedDiseases,
+            medRows: snapshot.medRows
+          })
         }
         }
       }).catch(function() {})
       }).catch(function() {})
     },
     },
+    // 切换成员时先保存当前编辑内容,再加载目标成员快照
+    switchMember: function(memberId) {
+      var self = this
+      if (String(memberId) === String(self.activeFormMemberId)) return
+      // 保存当前成员到快照
+      self.memberFormMap[self.activeFormMemberId || '__account'] = {
+        form: JSON.parse(JSON.stringify(self.form)),
+        lipidCholesterol: self.lipidCholesterol,
+        selectedDiseases: JSON.parse(JSON.stringify(self.selectedDiseases)),
+        medRows: JSON.parse(JSON.stringify(self.medRows))
+      }
+      self.activeFormMemberId = String(memberId)
+      // 目标成员已有快照 → 恢复;无则按该成员从服务端加载
+      if (self.memberFormMap[self.activeFormMemberId]) {
+        self.applyFormSnapshot(self.memberFormMap[self.activeFormMemberId])
+      } else {
+        self.resetFormFields()
+        self.loadHealthForm(self.activeFormMemberId)
+      }
+    },
+    applyFormSnapshot: function(snapshot) {
+      if (!snapshot) return
+      this.form = JSON.parse(JSON.stringify(snapshot.form || {}))
+      this.lipidCholesterol = snapshot.lipidCholesterol || ''
+      this.selectedDiseases = JSON.parse(JSON.stringify(snapshot.selectedDiseases || []))
+      this.medRows = snapshot.medRows && snapshot.medRows.length > 0 ? JSON.parse(JSON.stringify(snapshot.medRows)) : [{ name: '', startDate: '', durationText: '', time: '' }]
+      var self = this
+      this.selectedDiseases.forEach(function(item) {
+        var gk = self.findGroupKey(item.name)
+        if (gk && self.expandedGroups.indexOf(gk) < 0) self.expandedGroups.push(gk)
+      })
+    },
+    resetFormFields: function() {
+      this.form = { heightCm: '', weightKg: '', bloodPressure: '', bloodGlucose: '', notes: '' }
+      this.lipidCholesterol = ''
+      this.selectedDiseases = []
+      this.medRows = [{ name: '', startDate: '', durationText: '', time: '' }]
+      this.diseaseInput = ''
+    },
     parseLipidCholesterol: function(json) {
     parseLipidCholesterol: function(json) {
       try {
       try {
         var arr = JSON.parse(json || '[]')
         var arr = JSON.parse(json || '[]')
@@ -521,14 +620,14 @@ export default {
       } catch (e) {}
       } catch (e) {}
       return ''
       return ''
     },
     },
-    loadDiseases: function(json) {
+    loadDiseasesInto: function(targetList, json) {
       var self = this
       var self = this
       try {
       try {
         var arr = JSON.parse(json || '[]')
         var arr = JSON.parse(json || '[]')
         if (arr instanceof Array) {
         if (arr instanceof Array) {
           arr.forEach(function(item) {
           arr.forEach(function(item) {
             if (item.name && self.selectedNames.indexOf(item.name) < 0) {
             if (item.name && self.selectedNames.indexOf(item.name) < 0) {
-              self.selectedDiseases.push({
+              targetList.push({
                 name: item.name,
                 name: item.name,
                 diagnosedAt: item.diagnosedAt || '',
                 diagnosedAt: item.diagnosedAt || '',
                 custom: false
                 custom: false
@@ -647,19 +746,25 @@ export default {
     removeMed: function(idx) {
     removeMed: function(idx) {
       if (this.medRows.length > 1) this.medRows.splice(idx, 1)
       if (this.medRows.length > 1) this.medRows.splice(idx, 1)
     },
     },
-    buildHealthPayload: function() {
+    buildHealthPayload: function(snapshot) {
+      var s = snapshot || {
+        form: this.form,
+        lipidCholesterol: this.lipidCholesterol,
+        selectedDiseases: this.selectedDiseases,
+        medRows: this.medRows
+      }
       var lipids = []
       var lipids = []
-      if (this.lipidCholesterol) {
-        lipids.push({ name: '总胆固醇', value: parseFloat(this.lipidCholesterol), unit: 'mmol/L', note: '' })
+      if (s.lipidCholesterol) {
+        lipids.push({ name: '总胆固醇', value: parseFloat(s.lipidCholesterol), unit: 'mmol/L', note: '' })
       }
       }
       var diseases = []
       var diseases = []
-      this.selectedDiseases.forEach(function(item) {
+      ;(s.selectedDiseases || []).forEach(function(item) {
         var d = { name: item.name }
         var d = { name: item.name }
         if (item.diagnosedAt) d.diagnosedAt = item.diagnosedAt
         if (item.diagnosedAt) d.diagnosedAt = item.diagnosedAt
         diseases.push(d)
         diseases.push(d)
       })
       })
       var meds = []
       var meds = []
-      this.medRows.forEach(function(item) {
+      ;(s.medRows || []).forEach(function(item) {
         if (item.name) {
         if (item.name) {
           var m = { name: item.name }
           var m = { name: item.name }
           if (item.startDate) m.startDate = item.startDate
           if (item.startDate) m.startDate = item.startDate
@@ -669,33 +774,79 @@ export default {
         }
         }
       })
       })
       var payload = {}
       var payload = {}
-      if (this.form.heightCm) payload.heightCm = Number(this.form.heightCm)
-      if (this.form.weightKg) payload.weightKg = Number(this.form.weightKg)
-      if (this.form.bloodPressure) payload.bloodPressure = this.form.bloodPressure
-      if (this.form.bloodGlucose) payload.bloodGlucose = Number(this.form.bloodGlucose)
+      if (s.form.heightCm) payload.heightCm = Number(s.form.heightCm)
+      if (s.form.weightKg) payload.weightKg = Number(s.form.weightKg)
+      if (s.form.bloodPressure) payload.bloodPressure = s.form.bloodPressure
+      if (s.form.bloodGlucose) payload.bloodGlucose = Number(s.form.bloodGlucose)
       if (lipids.length > 0) payload.bloodLipids = JSON.stringify(lipids)
       if (lipids.length > 0) payload.bloodLipids = JSON.stringify(lipids)
       if (diseases.length > 0) payload.diseaseHistory = JSON.stringify(diseases)
       if (diseases.length > 0) payload.diseaseHistory = JSON.stringify(diseases)
       if (meds.length > 0) payload.medications = JSON.stringify(meds)
       if (meds.length > 0) payload.medications = JSON.stringify(meds)
-      if (this.form.notes) payload.notes = this.form.notes
+      if (s.form.notes) payload.notes = s.form.notes
       return payload
       return payload
     },
     },
+    // 当前成员快照(无改动数据时返回 null)
+    snapshotForMember: function(memberId) {
+      var key = String(memberId)
+      // 优先用已保存的成员快照,其次当前活动表单
+      if (this.memberFormMap[key]) {
+        var s = this.memberFormMap[key]
+        var p = this.buildHealthPayload(s)
+        return this.hasPayload(p) ? p : null
+      }
+      // 活动成员直接用当前表单
+      if (key === String(this.activeFormMemberId)) {
+        var activePayload = this.buildHealthPayload()
+        return this.hasPayload(activePayload) ? activePayload : null
+      }
+      return null
+    },
+    hasPayload: function(payload) {
+      if (!payload) return false
+      return payload.heightCm || payload.weightKg || payload.bloodPressure || payload.bloodGlucose ||
+             payload.bloodLipids || payload.diseaseHistory || payload.medications || payload.notes
+    },
+    // 先保存当前活动成员的编辑内容到快照,再逐个成员保存健康档案,最后生成方案
     saveAndGenerate: function() {
     saveAndGenerate: function() {
       var self = this
       var self = this
-      var payload = this.buildHealthPayload()
-      // 血压格式校验
-      if (this.form.bloodPressure && !/^\d{2,3}\/\d{2,3}$/.test(this.form.bloodPressure)) {
-        uni.showToast({ title: '血压格式应为 120/80', icon: 'none' })
-        return
+      // 保存当前活动成员到快照
+      if (self.activeFormMemberId) {
+        self.memberFormMap[self.activeFormMemberId] = {
+          form: JSON.parse(JSON.stringify(self.form)),
+          lipidCholesterol: self.lipidCholesterol,
+          selectedDiseases: JSON.parse(JSON.stringify(self.selectedDiseases)),
+          medRows: JSON.parse(JSON.stringify(self.medRows))
+        }
       }
       }
-      self.loading = true
-      saveHealthStatus(payload).then(function(res) {
-        if (res && res.code === 200) {
-          uni.showToast({ title: '健康信息已保存', icon: 'success' })
+      // 血压格式校验(检查所有成员)
+      var members = self.selectedMemberIds.slice()
+      for (var i = 0; i < members.length; i++) {
+        var payload = self.snapshotForMember(members[i])
+        if (payload && payload.bloodPressure && !/^\d{2,3}\/\d{2,3}$/.test(payload.bloodPressure)) {
+          uni.showToast({ title: '血压格式应为 120/80', icon: 'none' })
+          return
         }
         }
-        // 无论保存是否成功,都继续生成方案
+      }
+      self.loading = true
+      var saveTasks = []
+      members.forEach(function(mid) {
+        var payload = self.snapshotForMember(mid)
+        if (!payload) return
+        payload.memberId = mid
+        saveTasks.push(saveHealthStatus(payload))
+      })
+      if (saveTasks.length === 0) {
+        // 无健康数据,直接生成方案
+        self.loading = false
+        self.generatePlan()
+        return
+      }
+      Promise.all(saveTasks).then(function() {
+        uni.showToast({ title: '健康信息已保存', icon: 'success' })
+        self.loading = false
         self.generatePlan()
         self.generatePlan()
       }).catch(function() {
       }).catch(function() {
         // 保存失败也继续生成
         // 保存失败也继续生成
+        self.loading = false
         self.generatePlan()
         self.generatePlan()
       })
       })
     },
     },
@@ -897,6 +1048,8 @@ export default {
       this.diseaseInput = ''
       this.diseaseInput = ''
       this.medRows = [{ name: '', startDate: '', durationText: '', time: '' }]
       this.medRows = [{ name: '', startDate: '', durationText: '', time: '' }]
       this.expandedGroups = this.diseaseGroups.slice(0, 3).map(function(g) { return g.key })
       this.expandedGroups = this.diseaseGroups.slice(0, 3).map(function(g) { return g.key })
+      this.memberFormMap = {}
+      this.activeFormMemberId = ''
       this.loadExecutors()
       this.loadExecutors()
     },
     },
     goBack: function() {
     goBack: function() {

+ 1 - 1
cfc-frontend/utils/api.js

@@ -2472,7 +2472,7 @@ export const uploadServiceRoleImage = (filePath) => {
 export const getRecommendList = (data) => request('/api/recommend/list', 'POST', data)
 export const getRecommendList = (data) => request('/api/recommend/list', 'POST', data)
 export const trackRecommendAction = (data) => request('/api/recommend/track', 'POST', data)
 export const trackRecommendAction = (data) => request('/api/recommend/track', 'POST', data)
 
 
-export const getHealthStatus = () => request('/api/health-status/get', 'POST', {})
+export const getHealthStatus = (memberId) => request('/api/health-status/get', 'POST', memberId ? { memberId: memberId } : {})
 export const saveHealthStatus = (data) => request('/api/health-status/save', 'POST', data)
 export const saveHealthStatus = (data) => request('/api/health-status/save', 'POST', data)
 
 
 // ===== 饮食模块 =====
 // ===== 饮食模块 =====