Преглед изворни кода

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

E2E Test Bot пре 3 недеља
родитељ
комит
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());
         }
 
+        // 方案规则种子数据(主链路 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 个示例文章,覆盖学习力/情绪管理/亲子教育分类)
         try {
             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,
                                     String activityType, String status,
                                     String startTime, String endTime, String location,
@@ -9190,15 +9271,52 @@ private void runMigration100() {
 		// 迁移261: butler_profiles 添加停接新单开关(管家自助选择功能)
 		ensureColumn("butler_profiles", "accepting",
 				"TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否接受新家庭绑定: 0=停接/1=接单'");
+
 		// 迁移262: family_join_requests 添加 inviter_user_id 列(审批人=邀请人,谁分享的卡谁确认)
 		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 INDEX idx_inviter (inviter_user_id)");
 			log.info("已添加inviter_user_id列到family_join_requests表");
 		} catch (Exception e) {
-			// 列/索引已存在,忽略错误
 			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 java.math.BigDecimal;
+import java.util.Map;
+import com.etotem.cfc.util.ParamUtils;
 
 @Tag(name = "健康现状档案", description = "当前状态调研-健康现状档案接口")
 @RestController
@@ -22,25 +24,44 @@ public class HealthStatusController {
     @Resource
     private HealthStatusService healthStatusService;
 
-    @Operation(summary = "查询当前账号健康现状(未填写返回 null)")
+    @Operation(summary = "查询当前账号健康现状(memberId 为空查账号级,非空查成员级)")
     @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")
     public Result<HealthStatus> save(
             @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("请至少填写一项");
         }
         String validateMsg = validate(status);
         if (validateMsg != null) {
             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)
     private Long id;
 
-    /** 所属账号(一个账号一份) */
+    /** 所属账号 */
     private Long userId;
 
+    /** 家庭成员ID(NULL=账号级,非空=成员级归档) */
+    private Long memberId;
+
     /** 身高(cm) */
     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.DanKnowledgeBaseTagMapper;
 import com.etotem.cfc.mapper.ProductDimensionConfigMapper;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -21,6 +22,7 @@ import java.util.Map;
 import java.util.stream.Collectors;
 import com.etotem.cfc.util.SortUtil;
 
+@Slf4j
 @Service
 public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper, DanKnowledgeBase> {
 
@@ -110,12 +112,21 @@ public class DanKnowledgeBaseService extends ServiceImpl<DanKnowledgeBaseMapper,
 
     @Transactional(rollbackFor = Exception.class)
     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);
     }
 

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

@@ -9,4 +9,10 @@ public interface HealthStatusService {
 
     /** 保存/更新(upsert:有则更新,无则插入;filledAt 仅首次赋值) */
     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;
         return healthStatusMapper.selectOne(
                 new LambdaQueryWrapper<HealthStatus>()
-                        .eq(HealthStatus::getUserId, userId));
+                        .eq(HealthStatus::getUserId, userId)
+                        .isNull(HealthStatus::getMemberId));
     }
 
     @Override
     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 (status == null) throw new IllegalArgumentException("健康现状不能为空");
 
-        HealthStatus existing = getByUserId(userId);
+        HealthStatus existing = getByMemberId(userId, memberId);
         status.setUserId(userId);
+        status.setMemberId(memberId);
         if (existing == null) {
             status.setId(null);
             status.setFilledAt(new Date());
@@ -41,6 +58,6 @@ public class HealthStatusServiceImpl implements HealthStatusService {
             status.setUpdatedAt(new Date());
             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 (
     id BIGINT AUTO_INCREMENT PRIMARY KEY 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 '摘要',
     cover_image VARCHAR(500) DEFAULT '' COMMENT '封面图 URL',
     content LONGTEXT COMMENT '富文本内容',
@@ -4976,7 +4976,8 @@ CREATE TABLE IF NOT EXISTS notification_templates (
 -- 当前状态调研-健康现状档案(迁移239)
 CREATE TABLE IF NOT EXISTS health_status (
     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)',
     weight_kg DECIMAL(5,1) DEFAULT NULL COMMENT '体重(kg)',
     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 '其他补充说明',
     filled_at DATETIME DEFAULT NULL 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='当前状态调研-健康现状档案';
 
 -- 系统提示词管理表(cfc-web 后台可维护,langgraph 动态加载)

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

@@ -46,6 +46,19 @@
       <view class="section-title">📋 健康问卷</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-title">基础体征</view>
@@ -364,6 +377,9 @@ export default {
       medRows: [{ name: '', startDate: '', durationText: '', time: '' }],
       currentMonth: '',
       monthEnd: '',
+      // 多成员分别填写:memberId → 各自的表单快照 { form, lipidCholesterol, selectedDiseases, medRows }
+      memberFormMap: {},
+      activeFormMemberId: '',
       // 方案数据
       planData: {},
       sections: {
@@ -412,6 +428,27 @@ export default {
     },
     selectedNames: function() {
       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) {
@@ -436,8 +473,7 @@ export default {
     this.currentMonth = y + '-' + mStr
     var lastDay = new Date(y, m, 0).getDate()
     this.monthEnd = y + '-' + mStr + '-' + (lastDay < 10 ? '0' + lastDay : '' + lastDay)
-    // 加载健康问卷已有数据
-    this.loadHealthForm()
+    // 注意:健康问卷数据由 loadExecutors 内按成员异步加载(含当前成员快照恢复),此处不重复调用
     // 自动执行
     if (options.autoExecute === '1') {
       setTimeout(function() { self.doAutoExecute() }, 1500)
@@ -464,6 +500,11 @@ export default {
           } else {
             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
       }).catch(function() { self.executors = []; self._executorsLoaded = true })
@@ -495,23 +536,81 @@ export default {
       this.generatePlan()
     },
 
-    // ===== 健康问卷方法 (从 health-status-form.vue 移植) =====
-    loadHealthForm: function() {
+    // ===== 健康问卷方法 (从 health-status-form.vue 移植,支持按成员加载) =====
+    loadHealthForm: function(memberId) {
       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) {
           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() {})
     },
+    // 切换成员时先保存当前编辑内容,再加载目标成员快照
+    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) {
       try {
         var arr = JSON.parse(json || '[]')
@@ -521,14 +620,14 @@ export default {
       } catch (e) {}
       return ''
     },
-    loadDiseases: function(json) {
+    loadDiseasesInto: function(targetList, json) {
       var self = this
       try {
         var arr = JSON.parse(json || '[]')
         if (arr instanceof Array) {
           arr.forEach(function(item) {
             if (item.name && self.selectedNames.indexOf(item.name) < 0) {
-              self.selectedDiseases.push({
+              targetList.push({
                 name: item.name,
                 diagnosedAt: item.diagnosedAt || '',
                 custom: false
@@ -647,19 +746,25 @@ export default {
     removeMed: function(idx) {
       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 = []
-      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 = []
-      this.selectedDiseases.forEach(function(item) {
+      ;(s.selectedDiseases || []).forEach(function(item) {
         var d = { name: item.name }
         if (item.diagnosedAt) d.diagnosedAt = item.diagnosedAt
         diseases.push(d)
       })
       var meds = []
-      this.medRows.forEach(function(item) {
+      ;(s.medRows || []).forEach(function(item) {
         if (item.name) {
           var m = { name: item.name }
           if (item.startDate) m.startDate = item.startDate
@@ -669,33 +774,79 @@ export default {
         }
       })
       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 (diseases.length > 0) payload.diseaseHistory = JSON.stringify(diseases)
       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
     },
+    // 当前成员快照(无改动数据时返回 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() {
       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()
       }).catch(function() {
         // 保存失败也继续生成
+        self.loading = false
         self.generatePlan()
       })
     },
@@ -897,6 +1048,8 @@ export default {
       this.diseaseInput = ''
       this.medRows = [{ name: '', startDate: '', durationText: '', time: '' }]
       this.expandedGroups = this.diseaseGroups.slice(0, 3).map(function(g) { return g.key })
+      this.memberFormMap = {}
+      this.activeFormMemberId = ''
       this.loadExecutors()
     },
     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 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)
 
 // ===== 饮食模块 =====