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

feat: 成长页面重构为干预中心 + 定期调研系统全栈实现

- 后端:新增survey_templates/survey_records表(迁移209/210)
- 新增SurveyTemplate/SurveyRecord实体 + Mapper + SurveyService
- 新增SurveyController(小程序端status/active/submit/history)
- 新增AdminSurveyController(管理端save/list/delete/ai-generate)
- AI生成调研题目调用LangGraph,失败回退本地fallback
- 前端小程序:新增survey-questionnaire/survey-history页面
- 重构growth-main为干预中心(今日干预进度卡+持续追踪Tab+里程碑+定期调研)
- 前端api.js新增调研API,pages.json注册新页面
- Web管理端:新增SurveyTemplates.vue(CRUD+AI生成)+ survey.js API
- 修复getSurveyStatus命名冲突(旧report-survey重命名为getReportSurveyStatus)
- 同步更新schema.sql + PROJECT-OVERVIEW.md
Xiaogang Liao 1 месяц назад
Родитель
Сommit
15b0b6e664
28 измененных файлов с 1286 добавлено и 711 удалено
  1. 41 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 9 6
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java
  3. 4 4
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  4. 69 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/SurveyController.java
  5. 50 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminSurveyController.java
  6. 2 4
      cfc-backend/src/main/java/com/etotem/cfc/controller/family/RelationshipQualityController.java
  7. 6 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/task/TaskReminderController.java
  8. 22 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/SurveyRecord.java
  9. 23 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/SurveyTemplate.java
  10. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/SurveyRecordMapper.java
  11. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/SurveyTemplateMapper.java
  12. 17 0
      cfc-backend/src/main/java/com/etotem/cfc/service/SurveyService.java
  13. 179 0
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/SurveyServiceImpl.java
  14. 12 0
      cfc-frontend/pages.json
  15. 100 56
      cfc-frontend/pages/action-detail/member-action-detail.vue
  16. 212 608
      cfc-frontend/pages/growth-main/index.vue
  17. 1 2
      cfc-frontend/pages/health-main/index.vue
  18. 3 3
      cfc-frontend/pages/health/health-plan-summary.vue
  19. 1 1
      cfc-frontend/pages/health/report-survey.vue
  20. 3 17
      cfc-frontend/pages/health/report-upload.vue
  21. 85 0
      cfc-frontend/pages/health/survey-history.vue
  22. 99 0
      cfc-frontend/pages/health/survey-questionnaire.vue
  23. 7 1
      cfc-frontend/utils/api.js
  24. 17 0
      cfc-web/src/api/survey.js
  25. 12 6
      cfc-web/src/router/index.js
  26. 295 0
      cfc-web/src/views/admin/SurveyTemplates.vue
  27. 2 0
      docs/superpowers/PROJECT-OVERVIEW.md
  28. 1 1
      docs/superpowers/plans/2026-08-08-growth-intervention-center.md

+ 41 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -8528,5 +8528,46 @@ private void runMigration100() {
 		} catch (Exception e) {
 			log.warn("health_gut_flora.detection_rate 扩容失败(可能已修改): " + e.getMessage());
 		}
+
+		// 迁移209: 创建 survey_templates 表(定期调研模板)
+		try {
+			jdbcTemplate.execute(
+				"CREATE TABLE IF NOT EXISTS survey_templates (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"title VARCHAR(100) NOT NULL COMMENT '调研标题', " +
+				"dimension VARCHAR(20) COMMENT '适用维度(身/智/富/行/心)', " +
+				"questions_json TEXT COMMENT '题目JSON(含选项)', " +
+				"cycle_type VARCHAR(20) DEFAULT 'weekly' COMMENT '周期: weekly/biweekly/monthly', " +
+				"ai_generated TINYINT(1) DEFAULT 0 COMMENT '是否AI生成', " +
+				"enabled TINYINT(1) DEFAULT 1 COMMENT '是否启用', " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='定期调研模板'"
+			);
+			log.info("已创建survey_templates表");
+		} catch (Exception e) {
+			log.warn("创建survey_templates表失败(可能已存在): " + e.getMessage());
+		}
+
+		// 迁移210: 创建 survey_records 表(定期调研记录)
+		try {
+			jdbcTemplate.execute(
+				"CREATE TABLE IF NOT EXISTS survey_records (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"family_id BIGINT NOT NULL COMMENT '家庭ID', " +
+				"member_id BIGINT NOT NULL COMMENT '成员ID', " +
+				"template_id BIGINT NOT NULL COMMENT '调研模板ID', " +
+				"answers_json TEXT COMMENT '答案JSON', " +
+				"submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '提交时间', " +
+				"period_start DATE COMMENT '周期开始', " +
+				"period_end DATE COMMENT '周期结束', " +
+				"INDEX idx_member (member_id), " +
+				"INDEX idx_template (template_id)" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='定期调研记录'"
+			);
+			log.info("已创建survey_records表");
+		} catch (Exception e) {
+			log.warn("创建survey_records表失败(可能已存在): " + e.getMessage());
+		}
 	}
 }

+ 9 - 6
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanController.java

@@ -20,8 +20,9 @@ public class HealthPlanController {
 
     @Operation(summary = "生成健康方案")
     @PostMapping("/generate")
-    public Result<String> generate(@RequestBody Map<String, Object> params, @RequestAttribute("userId") Long userId) {
-        Long familyId = params.get("familyId") != null ? Long.valueOf(params.get("familyId").toString()) : null;
+    public Result<String> generate(@RequestBody Map<String, Object> params,
+                                   @RequestAttribute("userId") Long userId,
+                                   @RequestAttribute(value = "familyId", required = false) Long familyId) {
         String memberIds = (String) params.get("memberIds");
         String dimensions = (String) params.get("dimensions");
         String goal = (String) params.get("goal");
@@ -34,8 +35,9 @@ public class HealthPlanController {
 
     @Operation(summary = "保存健康方案")
     @PostMapping("/save")
-    public Result<Long> save(@RequestBody Map<String, Object> params, @RequestAttribute("userId") Long userId) {
-        Long familyId = params.get("familyId") != null ? Long.valueOf(params.get("familyId").toString()) : null;
+    public Result<Long> save(@RequestBody Map<String, Object> params,
+                             @RequestAttribute("userId") Long userId,
+                             @RequestAttribute(value = "familyId", required = false) Long familyId) {
         String memberIds = (String) params.get("memberIds");
         String dimensions = (String) params.get("dimensions");
         String goal = (String) params.get("goal");
@@ -57,8 +59,9 @@ public class HealthPlanController {
 
     @Operation(summary = "查询历史方案列表")
     @PostMapping("/list")
-    public Result<List<HealthPlan>> list(@RequestBody Map<String, Object> params, @RequestAttribute("userId") Long userId) {
-        Long familyId = params.get("familyId") != null ? Long.valueOf(params.get("familyId").toString()) : null;
+    public Result<List<HealthPlan>> list(@RequestBody Map<String, Object> params,
+                                         @RequestAttribute("userId") Long userId,
+                                         @RequestAttribute(value = "familyId", required = false) Long familyId) {
         if (familyId == null) return Result.error("familyId不能为空");
         List<HealthPlan> list = healthPlanService.getPlanList(familyId);
         return Result.success(list);

+ 4 - 4
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -358,8 +358,8 @@ public class HealthReportController {
     @PostMapping("/report/upload")
     public Result<Map<String, Object>> uploadReport(
             @RequestParam("file") MultipartFile file,
-            @RequestParam(value = "familyId", required = false) Long familyId,
-            @RequestAttribute("userId") Long userId) {
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
 
         if (file.isEmpty()) {
             return Result.error("文件不能为空");
@@ -447,9 +447,9 @@ public class HealthReportController {
     public Result<Map<String, Object>> uploadOnly(
             @RequestParam("file") MultipartFile file,
             @RequestParam(value = "type", defaultValue = "auto") String type,
-            @RequestParam(value = "familyId", required = false) Long familyId,
             @RequestParam(value = "memberId", required = false) Long memberId,
-            @RequestAttribute("userId") Long userId) {
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
 
         if ("tongue".equals(type)) {
             if (memberId == null) {

+ 69 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/SurveyController.java

@@ -0,0 +1,69 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.SurveyRecord;
+import com.etotem.cfc.entity.SurveyTemplate;
+import com.etotem.cfc.service.SurveyService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "定期调研", description = "小程序端定期调研接口")
+@RestController
+@RequestMapping("/api/survey")
+public class SurveyController {
+
+    @Resource
+    private SurveyService surveyService;
+
+    @Operation(summary = "查询调研待办状态")
+    @PostMapping("/status")
+    public Result<Object> status(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : currentMemberId;
+        if (memberId == null) return Result.error("缺少成员ID");
+        return Result.success(surveyService.getSurveyStatus(userId, memberId));
+    }
+
+    @Operation(summary = "查询启用的调研模板")
+    @PostMapping("/template/active")
+    public Result<List<SurveyTemplate>> activeTemplates() {
+        return Result.success(surveyService.getActiveTemplates());
+    }
+
+    @Operation(summary = "提交调研答案")
+    @PostMapping("/submit")
+    public Result<SurveyRecord> submit(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : currentMemberId;
+        if (memberId == null) return Result.error("缺少成员ID");
+        Long templateId = Long.valueOf(params.get("templateId").toString());
+        String answersJson = (String) params.get("answersJson");
+        Date ps = params.get("periodStart") != null ? new Date(Long.valueOf(params.get("periodStart").toString())) : null;
+        Date pe = params.get("periodEnd") != null ? new Date(Long.valueOf(params.get("periodEnd").toString())) : null;
+        return Result.success(surveyService.submitSurvey(userId, memberId, templateId, answersJson, ps, pe));
+    }
+
+    @Operation(summary = "查询调研历史")
+    @PostMapping("/history")
+    public Result<List<SurveyRecord>> history(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = params.get("memberId") != null
+                ? Long.valueOf(params.get("memberId").toString()) : currentMemberId;
+        if (memberId == null) return Result.error("缺少成员ID");
+        return Result.success(surveyService.getSurveyHistory(userId, memberId));
+    }
+}

+ 50 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminSurveyController.java

@@ -0,0 +1,50 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.SurveyTemplate;
+import com.etotem.cfc.service.SurveyService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "管理端-调研模板", description = "调研模板管理")
+@RestController
+@RequestMapping("/api/admin/survey/template")
+public class AdminSurveyController {
+
+    @Resource
+    private SurveyService surveyService;
+
+    @Operation(summary = "保存调研模板")
+    @PostMapping("/save")
+    public Result<SurveyTemplate> save(@RequestBody SurveyTemplate template, @RequestAttribute("userId") Long userId) {
+        return Result.success(surveyService.saveTemplate(template, userId));
+    }
+
+    @Operation(summary = "查询调研模板列表")
+    @PostMapping("/list")
+    public Result<List<SurveyTemplate>> list(@RequestBody Map<String, Object> params) {
+        Integer enabled = params.get("enabled") != null ? Integer.parseInt(params.get("enabled").toString()) : null;
+        return Result.success(surveyService.listTemplates(enabled));
+    }
+
+    @Operation(summary = "删除调研模板")
+    @PostMapping("/delete")
+    public Result<Void> delete(@RequestBody Map<String, Object> params, @RequestAttribute("userId") Long userId) {
+        Long id = Long.valueOf(params.get("id").toString());
+        surveyService.deleteTemplate(id, userId);
+        return Result.success(null);
+    }
+
+    @Operation(summary = "AI生成调研题目")
+    @PostMapping("/ai-generate")
+    public Result<String> aiGenerate(@RequestBody Map<String, Object> params) {
+        String dimension = (String) params.get("dimension");
+        String topic = (String) params.get("topic");
+        return Result.success(surveyService.aiGenerateQuestions(dimension, topic));
+    }
+}

+ 2 - 4
cfc-backend/src/main/java/com/etotem/cfc/controller/family/RelationshipQualityController.java

@@ -89,12 +89,10 @@ public class RelationshipQualityController {
     }
 
 /**
-     * 解析请求中的家庭ID:优先请求体中的familyId,其次request attribute,最后回退用户所属家庭
+     * 解析请求中的家庭ID:优先request attribute,最后回退用户所属家庭
+     * 不再接受 body 中的 familyId,改为从登录用户派生
      */
     private Long getFamilyIdFromRequest(Long userId, Map<String, Object> body, Long familyId) {
-        if (body != null && body.get("familyId") != null) {
-            return Long.valueOf(body.get("familyId").toString());
-        }
         if (familyId != null) {
             return familyId;
         }

+ 6 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/task/TaskReminderController.java

@@ -137,8 +137,12 @@ public class TaskReminderController {
      * TASK-015: 获取家庭任务统计
      */
     @Operation(summary = "获取家庭任务统计")
-    @PostMapping("/statistics/family/{familyId}")
-    public Result<Map<String, Object>> getFamilyTaskStatistics(@PathVariable Long familyId) {
+    @PostMapping("/statistics/family")
+    public Result<Map<String, Object>> getFamilyTaskStatistics(
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        if (familyId == null) {
+            return Result.noFamily("请先创建或加入家庭");
+        }
         Map<String, Object> stats = taskReminderService.getFamilyTaskStatistics(familyId);
         return Result.success(stats);
     }

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/SurveyRecord.java

@@ -0,0 +1,22 @@
+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;
+
+@Data
+@TableName("survey_records")
+public class SurveyRecord implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long familyId;
+    private Long memberId;
+    private Long templateId;
+    private String answersJson;
+    private Date submittedAt;
+    private Date periodStart;
+    private Date periodEnd;
+}

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/SurveyTemplate.java

@@ -0,0 +1,23 @@
+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;
+
+@Data
+@TableName("survey_templates")
+public class SurveyTemplate implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String title;
+    private String dimension;
+    private String questionsJson;
+    private String cycleType;
+    private Integer aiGenerated;
+    private Integer enabled;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/SurveyRecordMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.SurveyRecord;
+
+public interface SurveyRecordMapper extends BaseMapper<SurveyRecord> {
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/SurveyTemplateMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.SurveyTemplate;
+
+public interface SurveyTemplateMapper extends BaseMapper<SurveyTemplate> {
+}

+ 17 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/SurveyService.java

@@ -0,0 +1,17 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.SurveyTemplate;
+import com.etotem.cfc.entity.SurveyRecord;
+import java.util.Date;
+import java.util.List;
+
+public interface SurveyService {
+    SurveyTemplate saveTemplate(SurveyTemplate template, Long adminId);
+    List<SurveyTemplate> listTemplates(Integer enabled);
+    void deleteTemplate(Long id, Long adminId);
+    List<SurveyTemplate> getActiveTemplates();
+    Object getSurveyStatus(Long userId, Long memberId);
+    SurveyRecord submitSurvey(Long userId, Long memberId, Long templateId, String answersJson, Date periodStart, Date periodEnd);
+    List<SurveyRecord> getSurveyHistory(Long userId, Long memberId);
+    String aiGenerateQuestions(String dimension, String topic);
+}

+ 179 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/SurveyServiceImpl.java

@@ -0,0 +1,179 @@
+package com.etotem.cfc.service.impl;
+
+import com.etotem.cfc.entity.SurveyRecord;
+import com.etotem.cfc.entity.SurveyTemplate;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.SurveyRecordMapper;
+import com.etotem.cfc.mapper.SurveyTemplateMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.service.SurveyService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service("surveyService")
+public class SurveyServiceImpl implements SurveyService {
+
+    private static final Logger log = LoggerFactory.getLogger(SurveyServiceImpl.class);
+
+    @Resource
+    private SurveyTemplateMapper surveyTemplateMapper;
+
+    @Resource
+    private SurveyRecordMapper surveyRecordMapper;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    @Value("${langgraph.base-url:}")
+    private String langgraphBaseUrl;
+
+    @Override
+    public SurveyTemplate saveTemplate(SurveyTemplate template, Long adminId) {
+        if (template.getId() != null) {
+            template.setUpdatedAt(new Date());
+            surveyTemplateMapper.updateById(template);
+        } else {
+            template.setCreatedAt(new Date());
+            template.setUpdatedAt(new Date());
+            if (template.getEnabled() == null) template.setEnabled(1);
+            if (template.getAiGenerated() == null) template.setAiGenerated(0);
+            surveyTemplateMapper.insert(template);
+        }
+        return template;
+    }
+
+    @Override
+    public List<SurveyTemplate> listTemplates(Integer enabled) {
+        List<SurveyTemplate> all = surveyTemplateMapper.selectList(null);
+        if (enabled == null) return all;
+        List<SurveyTemplate> result = new ArrayList<>();
+        for (SurveyTemplate t : all) {
+            if (t.getEnabled() != null && t.getEnabled() == enabled) {
+                result.add(t);
+            }
+        }
+        return result;
+    }
+
+    @Override
+    public void deleteTemplate(Long id, Long adminId) {
+        surveyTemplateMapper.deleteById(id);
+    }
+
+    @Override
+    public List<SurveyTemplate> getActiveTemplates() {
+        return listTemplates(1);
+    }
+
+    @Override
+    public Object getSurveyStatus(Long userId, Long memberId) {
+        List<SurveyTemplate> activeTemplates = getActiveTemplates();
+        List<SurveyRecord> allRecords = surveyRecordMapper.selectList(null);
+        Map<Long, List<SurveyRecord>> recordsByTemplate = new HashMap<>();
+        for (SurveyRecord r : allRecords) {
+            if (r.getMemberId() != null && r.getMemberId().equals(memberId)) {
+                Long tid = r.getTemplateId();
+                if (tid != null) {
+                    recordsByTemplate.computeIfAbsent(tid, k -> new ArrayList<>()).add(r);
+                }
+            }
+        }
+        List<Map<String, Object>> statusList = new ArrayList<>();
+        for (SurveyTemplate template : activeTemplates) {
+            Map<String, Object> item = new HashMap<>();
+            item.put("templateId", template.getId());
+            item.put("title", template.getTitle());
+            item.put("dimension", template.getDimension());
+            item.put("cycleType", template.getCycleType());
+            List<SurveyRecord> records = recordsByTemplate.get(template.getId());
+            if (records != null && records.size() > 0) {
+                SurveyRecord latest = records.get(0);
+                for (SurveyRecord r : records) {
+                    if (r.getSubmittedAt() != null && latest.getSubmittedAt() != null && r.getSubmittedAt().after(latest.getSubmittedAt())) {
+                        latest = r;
+                    }
+                }
+                item.put("status", "done");
+                item.put("lastSubmittedAt", latest.getSubmittedAt());
+            } else {
+                item.put("status", "pending");
+            }
+            statusList.add(item);
+        }
+        return statusList;
+    }
+
+    @Override
+    public SurveyRecord submitSurvey(Long userId, Long memberId, Long templateId, String answersJson, Date periodStart, Date periodEnd) {
+        SurveyTemplate template = surveyTemplateMapper.selectById(templateId);
+        if (template == null) {
+            throw new RuntimeException("调研模板不存在");
+        }
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        SurveyRecord record = new SurveyRecord();
+        record.setFamilyId(member != null ? member.getFamilyId() : null);
+        record.setMemberId(memberId);
+        record.setTemplateId(templateId);
+        record.setAnswersJson(answersJson);
+        record.setPeriodStart(periodStart);
+        record.setPeriodEnd(periodEnd);
+        surveyRecordMapper.insert(record);
+        return surveyRecordMapper.selectById(record.getId());
+    }
+
+    @Override
+    public List<SurveyRecord> getSurveyHistory(Long userId, Long memberId) {
+        List<SurveyRecord> all = surveyRecordMapper.selectList(null);
+        List<SurveyRecord> result = new ArrayList<>();
+        for (SurveyRecord r : all) {
+            if (r.getMemberId() != null && r.getMemberId().equals(memberId)) {
+                result.add(r);
+            }
+        }
+        return result;
+    }
+
+    @Override
+    public String aiGenerateQuestions(String dimension, String topic) {
+        if (langgraphBaseUrl == null || langgraphBaseUrl.isEmpty()) {
+            return buildFallbackQuestions(dimension, topic);
+        }
+        try {
+            String url = langgraphBaseUrl + "/api/v1/analysis/run";
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            Map<String, Object> body = new HashMap<>();
+            body.put("dimension", dimension);
+            body.put("topic", topic);
+            body.put("task", "generate_survey_questions");
+            body.put("prompt", "基于维度[" + dimension + "]和主题[" + topic + "],生成5道定期调研单选题。每道题包含text和options(字符串数组,2-5个选项)。直接返回JSON数组。");
+            HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);
+            RestTemplate restTemplate = new RestTemplate();
+            ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                return response.getBody();
+            }
+        } catch (Exception e) {
+            log.warn("LangGraph AI生成调研题目失败: {}", e.getMessage());
+        }
+        return buildFallbackQuestions(dimension, topic);
+    }
+
+    private String buildFallbackQuestions(String dimension, String topic) {
+        return "[{\"text\":\"关于" + topic + ",你的态度是?\",\"options\":[\"非常同意\",\"同意\",\"中立\",\"不同意\",\"非常不同意\"]},{\"text\":\"你多久做一次" + topic + "?\",\"options\":[\"每天\",\"每周\",\"每月\",\"偶尔\",\"从不\"]}]";
+    }
+}

+ 12 - 0
cfc-frontend/pages.json

@@ -882,6 +882,18 @@
           "style": {
             "navigationBarTitleText": "方案汇总"
           }
+        },
+        {
+          "path": "survey-questionnaire",
+          "style": {
+            "navigationBarTitleText": "定期调研"
+          }
+        },
+        {
+          "path": "survey-history",
+          "style": {
+            "navigationBarTitleText": "调研记录"
+          }
         }
       ]
     },

+ 100 - 56
cfc-frontend/pages/action-detail/member-action-detail.vue

@@ -8,56 +8,80 @@
       <text class="nav-title">行 · 成员详情</text>
     </view>
 
-    <!-- 五维能量条 -->
-    <FamilyEnergyBar
-      dimensionCode="action"
-      :sandboxData="sandboxData"
-      :dualDimension="dualDimension" />
+    <!-- 品牌头部 -->
+    <PageBanner theme="action"
+      tagline="人际和谐 · 关系滋养"
+      quote="和谐关系滋养幸福家庭" />
 
-    <!-- 成员信息卡 -->
-    <view class="member-card" v-if="memberInfo">
-      <view class="member-avatar action-avatar">
-        <text class="avatar-text">{{ memberInfo.name && memberInfo.name.charAt(0) || '孩' }}</text>
-      </view>
-      <view class="member-info">
-        <text class="member-name">{{ memberInfo.name || '孩子' }}</text>
-        <text class="member-role">行动维度</text>
-      </view>
-      <view class="member-score" v-if="dualDimension">
-        <text class="score-value action-score">{{ dualDimension.energy || 0 }}</text>
-        <text class="score-label">能量值</text>
-      </view>
+    <!-- 品牌定位语(登录前显示) -->
+    <view class="brand-strap" v-if="!isLoggedIn">
+      <text class="brand-strap-text">改善全家关系与沟通能力</text>
     </view>
 
-    <!-- 家庭成员关系图谱(只读) -->
-    <FamilyRelationGraph
-      dimensionCode="action"
-      :selfId="selfId"
-      :members="graphMembers"
-      :energyMap="energyMapForGraph"
-      :intimacyMap="intimacyMapForGraph"
-      :interactive="false" />
+    <!-- 游客专属区块 -->
+    <template v-if="!isLoggedIn">
+      <LoginGuideCard
+        title="解锁家庭关系与互动数据"
+        :items="[
+          '查看家庭关系图谱与亲密指数',
+          '完成关系问卷,诊断沟通模式',
+          '分配任务协作,参与家庭挑战'
+        ]" />
+    </template>
+
+    <!-- 登录后内容 -->
+    <template v-else>
+      <!-- 五维能量条 -->
+      <FamilyEnergyBar
+        dimensionCode="action"
+        :sandboxData="sandboxData"
+        :dualDimension="dualDimension" />
 
-    <!-- 今日任务 -->
-    <DimensionTasks
-      :memberId="memberId"
-      @taskClick="onTaskClick"
-      @moreTasks="goTasks" />
+      <!-- 成员信息卡 -->
+      <view class="member-card" v-if="memberInfo">
+        <view class="member-avatar action-avatar">
+          <text class="avatar-text">{{ memberInfo.name && memberInfo.name.charAt(0) || '孩' }}</text>
+        </view>
+        <view class="member-info">
+          <text class="member-name">{{ memberInfo.name || '孩子' }}</text>
+          <text class="member-role">行动维度</text>
+        </view>
+        <view class="member-score" v-if="dualDimension">
+          <text class="score-value action-score">{{ dualDimension.energy || 0 }}</text>
+          <text class="score-label">能量值</text>
+        </view>
+      </view>
+
+      <!-- 家庭成员关系图谱(只读) -->
+      <FamilyRelationGraph
+        dimensionCode="action"
+        :selfId="selfId"
+        :members="graphMembers"
+        :energyMap="energyMapForGraph"
+        :intimacyMap="intimacyMapForGraph"
+        :interactive="false" />
 
-    <!-- 活动 -->
-    <DimensionActivities
-      :isLoggedIn="true"
-      @activityClick="goActivityDetail"
-      @moreActivities="goMoreActivities" />
+      <!-- 今日任务 -->
+      <DimensionTasks
+        :memberId="memberId"
+        @taskClick="onTaskClick"
+        @moreTasks="goTasks" />
 
-    <!-- 商品 -->
-    <DimensionProducts
-      :isLoggedIn="true"
-      @productClick="goProductDetail"
-      @moreProducts="goMoreProducts" />
+      <!-- 活动 -->
+      <DimensionActivities
+        :isLoggedIn="true"
+        @activityClick="goActivityDetail"
+        @moreActivities="goMoreActivities" />
 
-    <!-- 行维度介绍卡片 -->
-    <DimensionIntroCard dimCode="action" v-if="isLoggedIn" learn-more-url="/pages/action-detail/dimension-intro" />
+      <!-- 商品 -->
+      <DimensionProducts
+        :isLoggedIn="true"
+        @productClick="goProductDetail"
+        @moreProducts="goMoreProducts" />
+
+      <!-- 行维度介绍卡片 -->
+      <DimensionIntroCard dimCode="action" v-if="isLoggedIn" learn-more-url="/pages/action-detail/dimension-intro" />
+    </template>
 
     <!-- 底部占位 -->
     <view class="bottom-spacer"></view>
@@ -65,6 +89,8 @@
 </template>
 
 <script>
+import PageBanner from '../../components/PageBanner.vue'
+import LoginGuideCard from '../../components/LoginGuideCard.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
@@ -74,14 +100,29 @@ import DimensionIntroCard from '../../components/DimensionIntroCard.vue'
 import { getEnergyOverview, getFamilyEnergySandbox, getChildren } from '../../utils/api.js'
 
 export default {
-  components: { FamilyEnergyBar, FamilyRelationGraph, DimensionTasks, DimensionActivities, DimensionProducts, DimensionIntroCard },
+  components: { PageBanner, LoginGuideCard, FamilyEnergyBar, FamilyRelationGraph, DimensionTasks, DimensionActivities, DimensionProducts, DimensionIntroCard },
   data() {
     return {
       memberId: null,
       selfId: null,
       memberInfo: null,
       sandboxData: null,
-      dualDimension: null
+      dualDimension: null,
+      isLoggedIn: false
+    }
+  },
+  onLoad: function(options) {
+    this.memberId = options.memberId || uni.getStorageSync('currentChildId') || null
+  },
+  onShow: function() {
+    var token = uni.getStorageSync('token')
+    this.isLoggedIn = !!token
+    if (!this.isLoggedIn) return
+    this.selfId = uni.getStorageSync('userId') || null
+    this.loadChildren()
+    this.loadSandboxData()
+    if (this.memberId) {
+      this.loadEnergyData()
     }
   },
   computed: {
@@ -113,17 +154,6 @@ export default {
       return {}
     }
   },
-  onLoad: function(options) {
-    this.memberId = options.memberId || uni.getStorageSync('currentChildId') || null
-  },
-  onShow: function() {
-    this.selfId = uni.getStorageSync('userId') || null
-    this.loadChildren()
-    this.loadSandboxData()
-    if (this.memberId) {
-      this.loadEnergyData()
-    }
-  },
   methods: {
     loadChildren: function() {
       var self = this
@@ -274,4 +304,18 @@ export default {
 .bottom-spacer {
   height: 40rpx;
 }
+
+/* 品牌定位语 */
+.brand-strap {
+  margin: 16rpx 30rpx 0;
+  padding: 20rpx 28rpx;
+  background: linear-gradient(135deg, #F0FFF4, rgba(16,185,129,0.12));
+  border-radius: 16rpx;
+  border-left: 4rpx solid #10B981;
+}
+.brand-strap-text {
+  font-size: 26rpx;
+  color: #065F46;
+  font-weight: 500;
+}
 </style>

+ 212 - 608
cfc-frontend/pages/growth-main/index.vue

@@ -1,655 +1,259 @@
 <template>
   <view class="container">
-    <PageBanner theme="action" tagline="方案 · 执行 · 成长" quote="知行合一,每天进步一点点" />
-
-    <!-- 家庭成员选择条(登录即自动建家庭,无需空态判断) -->
+    <PageBanner theme="action" tagline="干预 · 记录 · 成长" quote="知行合一,每天进步一点点" />
     <FamilyMemberStrip />
-
-      <!-- 今日待办卡片 -->
-      <view class="section">
-        <view class="todo-card">
-          <view class="todo-header">
-            <text class="todo-title">今日执行概览</text>
-          </view>
-          <view class="todo-grid">
-            <view class="todo-item">
-              <text class="todo-icon">✅</text>
-              <text class="todo-label">今日打卡</text>
-              <text class="todo-value" :class="todayCheckedIn ? 'text-success' : 'text-muted'">{{ todayCheckedIn ? '已完成' : '未完成' }}</text>
-            </view>
-            <view class="todo-item">
-              <text class="todo-icon">📋</text>
-              <text class="todo-label">待完成任务</text>
-              <text class="todo-value">{{ pendingTasks }} 项</text>
-            </view>
-            <view class="todo-item">
-              <text class="todo-icon">🔥</text>
-              <text class="todo-label">进行中挑战</text>
-              <text class="todo-value">{{ activeChallenges }} 个</text>
-            </view>
-            <view class="todo-item">
-              <text class="todo-icon">🍽️</text>
-              <text class="todo-label">今日饮食</text>
-              <text class="todo-value">{{ mealCount }} 餐</text>
-            </view>
-          </view>
+    <view class="reminder-bar" v-if="hasIncomplete">
+      <text class="reminder-text">⚠ 今日还有 {{ incompleteCount }} 项未记录</text>
+      <text class="reminder-close" @click="dismissReminder">✕</text>
+    </view>
+    <view class="section">
+      <view class="progress-card">
+        <view class="progress-header">
+          <text class="progress-title">今日干预进度</text>
+          <text class="progress-date">{{ todayDate }}</text>
+        </view>
+        <view class="progress-item" v-for="(item, idx) in progressItems" :key="getProgressKey(item)">
+          <text class="progress-icon">{{ item.icon }}</text>
+          <text class="progress-label">{{ item.label }}</text>
+          <text class="progress-status" :class="item.done ? 'status-done' : 'status-pending'">{{ item.done ? '✔ 已完成' : '' }}</text>
+          <text class="progress-btn" v-if="!item.done" @click="handleProgressClick(item)">{{ item.actionText || '补录' }}</text>
+        </view>
+        <view class="progress-footer">
+          <text class="footer-text">今日完成 {{ doneCount }}/{{ totalCount }}</text>
+          <text class="footer-streak" v-if="streakDays > 0">🔥 连续打卡 {{ streakDays }} 天</text>
         </view>
       </view>
-
-      <!-- 功能入口网格 -->
-      <view class="section">
-        <view class="grid-3col">
-          <view class="grid-item" @click="goPage('/pages/health/daily-checkin')">
-            <view class="grid-icon" style="background:rgba(16,185,129,0.15)"><text>✅</text></view>
-            <text class="grid-label">每日打卡</text>
-          </view>
-          <view class="grid-item" @click="goPage('/pages/tasks/daily-tasks')">
-            <view class="grid-icon" style="background:rgba(99,102,241,0.15)"><text>📋</text></view>
-            <text class="grid-label">日常任务</text>
-          </view>
-          <view class="grid-item" @click="goPage('/pages/health/challenge-manage')">
-            <view class="grid-icon" style="background:rgba(239,68,68,0.15)"><text>🔥</text></view>
-            <text class="grid-label">家庭挑战</text>
+    </view>
+    <view class="section">
+      <view class="tracking-card">
+        <view class="tracking-tabs">
+          <view class="tracking-tab" :class="activeTab === tab.key ? 'tab-active' : ''" v-for="tab in trackingTabs" :key="tab.key" @click="switchTab(tab.key)">
+            <text class="tab-text" :class="activeTab === tab.key ? 'tab-active-text' : ''">{{ tab.label }}</text>
           </view>
-          <view class="grid-item" @click="goPage('/pages/diet/index')">
-            <view class="grid-icon" style="background:rgba(245,158,11,0.15)"><text>🍽️</text></view>
-            <text class="grid-label">饮食记录</text>
-          </view>
-          <view class="grid-item" @click="goPage('/pages/health/exercise-index')">
-            <view class="grid-icon" style="background:rgba(34,197,94,0.15)"><text>🏃</text></view>
-            <text class="grid-label">运动记录</text>
-          </view>
-          <view class="grid-item" @click="goPage('/pages/health/sleep-index')">
-            <view class="grid-icon" style="background:rgba(139,92,246,0.15)"><text>😴</text></view>
-            <text class="grid-label">作息记录</text>
-          </view>
-          <view class="grid-item" @click="goPage('/pages/growth/detail')">
-            <view class="grid-icon" style="background:rgba(249,115,22,0.15)"><text>📈</text></view>
-            <text class="grid-label">成长记录</text>
+        </view>
+        <view class="tracking-content">
+          <view class="tracking-empty" v-if="currentRecords.length === 0">
+            <text class="empty-icon">📊</text>
+            <text class="empty-text">还没有记录</text>
+            <text class="empty-hint">开始记录{{ activeTabLabel }}吧</text>
           </view>
-          <view class="grid-item" @click="goPage('/pages/health/meditation-index')">
-            <view class="grid-icon" style="background:rgba(255,107,157,0.15)"><text>🧘</text></view>
-            <text class="grid-label">冥想练习</text>
+          <view class="tracking-record" v-for="(rec, rIdx) in currentRecords" :key="getRecordKey(rec)">
+            <text class="record-date">{{ rec._date || rec._text }}</text>
+            <text class="record-content">{{ rec._content }}</text>
           </view>
+          <view class="tracking-more" v-if="currentRecords.length > 0" @click="viewAllRecords">查看全部 ›</view>
         </view>
       </view>
-
-      <!-- 活动推荐(从首页移过来) -->
-      <DimensionActivities
-        :isLoggedIn="true"
-        @activityClick="goActivityDetail"
-        @moreActivities="goMoreActivities" />
-
-       <!-- 成长记录(原有内容) -->
-       <view class="section">
-         <view class="section-header">
-           <text class="section-title">成长记录</text>
-           <text class="section-more" v-if="records.length > 0" @click="viewAllRecords">查看全部 ›</text>
-         </view>
-
-        <!-- 孩子选择 -->
-        <view class="children-list" v-if="!selectedChild">
-          <view class="child-card" v-for="child in children" :key="child.id" @click="viewChildRecords(child)">
-            <view class="child-info">
-              <text class="child-name">{{ child.nickname || child.name }}</text>
-              <text class="child-age" v-if="child.age">年龄:{{ child.age }}岁</text>
-            </view>
-            <view class="arrow">
-              <text class="arrow-icon">›</text>
-            </view>
-          </view>
-        </view>
-
-        <!-- 档案记录 -->
-        <view class="records-section" v-if="selectedChild">
-          <view class="filter-tabs">
-            <view class="filter-tab" :class="activeFilter === 'all' ? 'tab-active' : ''" @click="setFilter('all')">
-              <text class="tab-text" :class="activeFilter === 'all' ? 'tab-active-text' : ''">全部</text>
-            </view>
-            <view class="filter-tab" :class="activeFilter === 'manual' ? 'tab-active' : ''" @click="setFilter('manual')">
-              <text class="tab-text" :class="activeFilter === 'manual' ? 'tab-active-text' : ''">手动</text>
-            </view>
-            <view class="filter-tab" :class="activeFilter === 'assessment' ? 'tab-active' : ''" @click="setFilter('assessment')">
-              <text class="tab-text" :class="activeFilter === 'assessment' ? 'tab-active-text' : ''">测评</text>
-            </view>
-            <view class="filter-tab" :class="activeFilter === 'upload' ? 'tab-active' : ''" @click="setFilter('upload')">
-              <text class="tab-text" :class="activeFilter === 'upload' ? 'tab-active-text' : ''">上传</text>
-            </view>
-            <view class="filter-tab" :class="activeFilter === 'supplement' ? 'tab-active' : ''" @click="setFilter('supplement')">
-              <text class="tab-text" :class="activeFilter === 'supplement' ? 'tab-active-text' : ''">补充</text>
-            </view>
+    </view>
+    <view class="section">
+      <view class="milestone-card">
+        <text class="section-title-row">🏆 里程碑</text>
+        <view class="milestone-grid">
+          <view class="milestone-item">
+            <text class="milestone-icon">🔥</text>
+            <text class="milestone-value">{{ streakDays }}天</text>
+            <text class="milestone-label">连续打卡</text>
           </view>
-
-          <view class="record-card" v-for="record in filteredRecords" :key="record.id" @click="viewDetail(record)">
-            <view class="record-header">
-               <text class="record-title">{{ record.recordTitle || '成长记录' }}</text>
-              <view class="record-badges">
-                <text class="record-source-tag" v-if="record.sourceType">{{ sourceTypeLabel(record.sourceType) }}</text>
-                <text class="record-tag" :class="record.status === 'completed' ? 'tag-completed' : 'tag-pending'">
-                  {{ record.status === 'completed' ? '已完成' : '待同步' }}
-                </text>
-              </view>
-            </view>
-            <view class="record-scores" v-if="record.overallScore || record.reportDate">
-              <text class="score-item" v-if="record.overallScore">得分: {{ record.overallScore }}</text>
-              <text class="score-item" v-if="record.reportDate">{{ record.reportDate }}</text>
-              <text class="score-item" v-if="!record.reportDate && record.createdAt">{{ formatDate(record.createdAt) }}</text>
-            </view>
+          <view class="milestone-item">
+            <text class="milestone-icon">🎯</text>
+            <text class="milestone-value">{{ activeChallenges }}</text>
+            <text class="milestone-label">进行中挑战</text>
           </view>
-          <view class="empty-state" v-if="filteredRecords.length === 0 && records.length > 0">
-            <text class="empty-text">该类型暂无记录</text>
-          </view>
-          <view class="empty-state" v-if="records.length === 0">
-            <text class="empty-text">暂无成长记录</text>
-            <text class="empty-hint">点击下方按钮创建第一个成长记录</text>
+          <view class="milestone-item">
+            <text class="milestone-icon">📈</text>
+            <text class="milestone-value">{{ completeness }}%</text>
+            <text class="milestone-label">档案完整度</text>
           </view>
         </view>
-
-        <!-- 创建按钮 -->
-        <view class="footer-btn" v-if="selectedChild">
-           <button class="btn-create" @click="createRecord">记录成长</button>
+      </view>
+    </view>
+    <view class="section">
+      <view class="survey-card">
+        <text class="section-title-row">📋 定期调研</text>
+        <view class="survey-pending" v-if="pendingSurvey">
+          <text class="survey-alert">⚠ 本周还未完成调研</text>
+          <text class="survey-title-text">{{ pendingSurvey.title }}</text>
+          <text class="survey-btn" @click="startSurvey">[开始填写]</text>
         </view>
-        <view class="footer-btn" v-if="!selectedChild && children.length > 0">
-          <button class="btn-invite-guide" open-type="share" @click="prepareGuideInvite">邀请成长规划师</button>
+        <view class="survey-done" v-else-if="allDone"><text class="survey-done-text">✅ 所有调研已完成</text></view>
+        <view class="survey-history-list" v-if="surveyHistory.length > 0">
+          <text class="history-label">历史调研:</text>
+          <view class="history-item" v-for="(h, hIdx) in surveyHistory" :key="getHistoryKey(h)">
+            <text class="history-date">{{ h._date }}</text>
+            <text class="history-content">{{ h._content }}</text>
+          </view>
+          <text class="survey-more" @click="viewAllHistory">查看全部调研 ›</text>
         </view>
+        <view class="survey-empty" v-else-if="!pendingSurvey"><text class="survey-empty-text">暂无待完成调研</text></view>
       </view>
+    </view>
+    <DimensionActivities :isLoggedIn="true" @activityClick="goActivityDetail" @moreActivities="goMoreActivities" style="margin-bottom:60rpx" />
   </view>
 </template>
-
 <script>
 import PageBanner from '../../components/PageBanner.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
-import { getChildren, getChildRecords } from '../../utils/api.js'
-
+import { healthCheckinList, getDietDailyRecords, getSurveyStatus, getSurveyHistory } from '../../utils/api.js'
 export default {
-  components: {
-    PageBanner,
-    FamilyMemberStrip,
-    DimensionActivities
-  },
-  data() {
-    return {
-      loading: false,
-      children: [],
-      selectedChild: null,
-      selectedChildName: '',
-      records: [],
-      memberId: '',
-      activeFilter: 'all',
-      shareData: null,
-      todayCheckedIn: false,
-      pendingTasks: 0,
-      activeChallenges: 0,
-      mealCount: 0
-    }
-  },
+  components: { PageBanner, FamilyMemberStrip, DimensionActivities },
+  data() { return { memberId: '', todayDate: '', hasIncomplete: false, incompleteCount: 0, progressItems: [], activeTab: 'diet', trackingTabs: [{ key: 'diet', label: '饮食' }, { key: 'exercise', label: '运动' }, { key: 'sleep', label: '睡眠' }, { key: 'emotion', label: '心情' }, { key: 'survey', label: '调研' }], trackingData: { diet: [], exercise: [], sleep: [], emotion: [], survey: [] }, streakDays: 0, activeChallenges: 0, completeness: 0, pendingSurvey: null, allDone: false, surveyHistory: [] } },
   computed: {
-    filteredRecords: function() {
-      if (this.activeFilter === 'all') return this.records
-      var filter = this.activeFilter
-      return this.records.filter(function(r) { return r.sourceType === filter })
-    }
-  },
-  onShareAppMessage() {
-    if (this.shareData) {
-      return {
-        title: this.shareData.title,
-        path: this.shareData.path,
-        imageUrl: '/static/invite-card.png'
-      }
-    }
+    doneCount: function() { var c = 0; for (var i = 0; i < this.progressItems.length; i++) if (this.progressItems[i] && this.progressItems[i].done) c++; return c },
+    totalCount: function() { return this.progressItems.length },
+    activeTabLabel: function() { var m = { diet: '饮食', exercise: '运动', sleep: '睡眠', emotion: '心情', survey: '调研' }; return m[this.activeTab] || '' },
+    currentRecords: function() { return this.trackingData[this.activeTab] || [] }
   },
   onLoad(options) {
-    this.memberId = options.memberId || ''
-    this.loadChildren()
-    this.loadTodoData()
+    var ui = uni.getStorageSync('userInfo') || {}
+    this.memberId = options.memberId || ui.currentMemberId || ui.memberId
+    var now = new Date()
+    this.todayDate = now.getFullYear() + '-' + this.pad(now.getMonth() + 1) + '-' + this.pad(now.getDate())
+    this.loadAllData()
   },
+  onShow() { this.loadAllData() },
   methods: {
-    goPage: function(url) {
-      uni.navigateTo({ url: url })
-    },
-    goActivityDetail: function(act) {
-      uni.navigateTo({ url: '/pages/activity/activity-detail/activity-detail?id=' + act.id })
-    },
-    goMoreActivities: function() {
-      uni.navigateTo({ url: '/pages/activity/index' })
-    },
-    viewAllRecords: function() {
-      // 滚动到档案区域或展开全部
-      if (this.selectedChild) {
-        uni.navigateTo({ url: '/pages/growth/detail?memberId=' + this.selectedChild.id })
+    pad: function(n) { return n < 10 ? '0' + n : '' + n },
+    getProgressKey(item) { return item && item.label ? item.label : 'p-' + Math.random() },
+    getRecordKey(rec) { return rec && rec._text ? rec._text + rec._content : 'r-' + Math.random() },
+    getHistoryKey(h) { return h && h._date ? h._date + h._content : 'h-' + Math.random() },
+    dismissReminder: function() { this.hasIncomplete = false },
+    switchTab: function(key) { this.activeTab = key; this.loadTrackingData(key) },
+    handleProgressClick: function(item) {
+      var m = { checkin: '/pages/health/daily-checkin', diet: '/pages/health/diet-index', exercise: '/pages/health/exercise-index', sleep: '/pages/health/sleep-index', emotion: '/pages/mind/index' }
+      var url = m[item.key]
+      if (item.key === 'survey' && this.pendingSurvey && this.pendingSurvey.templateId) {
+        url = '/pages/health/survey-questionnaire?templateId=' + this.pendingSurvey.templateId + '&templateTitle=' + encodeURIComponent(this.pendingSurvey.title) + '&templateDimension=' + (this.pendingSurvey.dimension || '')
       }
+      if (url) uni.navigateTo({ url: url })
     },
-    sourceTypeLabel: function(type) {
-      var map = { 'manual': '手动', 'assessment': '测评', 'upload': '上传', 'supplement': '补充' }
-      return map[type] || type
-    },
-    setFilter: function(type) {
-      this.activeFilter = type
+    startSurvey: function() { if (this.pendingSurvey && this.pendingSurvey.templateId) uni.navigateTo({ url: '/pages/health/survey-questionnaire?templateId=' + this.pendingSurvey.templateId + '&templateTitle=' + encodeURIComponent(this.pendingSurvey.title) + '&templateDimension=' + (this.pendingSurvey.dimension || '') }) },
+    viewAllRecords: function() { var m = { diet: '/pages/health/diet-index', exercise: '/pages/health/exercise-index', sleep: '/pages/health/sleep-index', survey: '/pages/health/survey-history?memberId=' + this.memberId, emotion: '/pages/mind/index' }; if (m[this.activeTab]) uni.navigateTo({ url: m[this.activeTab] }) },
+    viewAllHistory: function() { uni.navigateTo({ url: '/pages/health/survey-history?memberId=' + this.memberId }) },
+    goActivityDetail: function(act) { uni.navigateTo({ url: '/pages/activity/activity-detail/activity-detail?id=' + act.id }) },
+    goMoreActivities: function() { uni.navigateTo({ url: '/pages/activity/index' }) },
+    loadTrackingData: function(key) { return this.trackingData[key] || [] },
+    async loadAllData() {
+      var self = this
+      try { await self.loadProgressItems() } catch(e) {}
+      try { await self.loadSurveyData() } catch(e) {}
+      try { await self.loadMilestones() } catch(e) {}
     },
-    loadTodoData: function() {
+    async loadProgressItems() {
       var self = this
+      var items = []
+      // 今日打卡
+      var checkinDone = false
       try {
-        var api = require('../../utils/api.js')
-        // 检查打卡状态
-        if (api.getCheckinStats) {
-          api.getCheckinStats().then(function(res) {
-            if (res.code === 200 && res.data) {
-              self.todayCheckedIn = !!(res.data.todayCheckedIn || res.data.checkedIn || (res.data.today && res.data.today.checkedIn))
-            }
-          }).catch(function() {})
-        }
-        // 待完成任务数
-        if (api.getTodayTasks && self.children && self.children.length > 0) {
-          var childId = self.selectedChild ? self.selectedChild.id : self.children[0].id
-          api.getTodayTasks(childId).then(function(res) {
-            if (res.code === 200) {
-              self.pendingTasks = (res.data && res.data.length) || 0
-            }
-          }).catch(function() {})
-        }
-        // 进行中挑战
-        if (api.getChallengeList) {
-          api.getChallengeList({ status: 'active' }).then(function(res) {
-            if (res.code === 200) {
-              self.activeChallenges = (res.data && res.data.length) || 0
-            }
-          }).catch(function() {})
-        }
-        // 饮食记录数
-        if (api.getMealLogs) {
-          api.getMealLogs({ date: new Date().toISOString().slice(0, 10) }).then(function(res) {
-            if (res.code === 200) {
-              self.mealCount = (res.data && res.data.length) || 0
-            }
-          }).catch(function() {})
+        var ciRes = await healthCheckinList({ memberId: self.memberId, yearMonth: self.todayDate.substring(0, 7) })
+        if (ciRes.code === 200 && ciRes.data) {
+          for (var ci = 0; ci < ciRes.data.length; ci++) { if (ciRes.data[ci] && ciRes.data[ci].checkinDate && ciRes.data[ci].checkinDate.substring(0, 10) === self.todayDate) { checkinDone = true; break } }
         }
-      } catch (e) {
-        console.error('加载待办数据失败', e)
-      }
+      } catch(e) {}
+      items.push({ key: 'checkin', icon: '✅', label: '今日打卡', done: checkinDone, actionText: '打卡' })
+      // 饮食
+      var dietDone = false
+      try {
+        var dRes = await getDietDailyRecords({ memberId: self.memberId, date: self.todayDate })
+        if (dRes.code === 200 && dRes.data && dRes.data.length > 0) dietDone = true
+      } catch(e) {}
+      items.push({ key: 'diet', icon: '🍽️', label: '饮食记录', done: dietDone, actionText: '补录' })
+      items.push({ key: 'exercise', icon: '🏃', label: '运动记录', done: false, actionText: '记录' })
+      items.push({ key: 'emotion', icon: '😊', label: '心情记录', done: false, actionText: '记录' })
+      items.push({ key: 'survey', icon: '📋', label: '本周调研', done: self.allDone, actionText: self.allDone ? '' : '开始' })
+      self.progressItems = items
+      self.incompleteCount = 0
+      for (var pi = 0; pi < items.length; pi++) if (items[pi] && !items[pi].done) self.incompleteCount++
+      self.hasIncomplete = self.incompleteCount > 0
     },
-    async loadChildren() {
-      this.loading = true
+    async loadSurveyData() {
+      var self = this
       try {
-        var res = await getChildren()
-        if (res.code === 200) {
-          this.children = res.data || []
-          if (this.memberId && this.children.length > 0) {
-            var matched = this.children.find(function(c) { return String(c.id) === String(this.memberId) }.bind(this))
-            if (matched) {
-              this.viewChildRecords(matched)
-            }
-          }
+        var statusRes = await getSurveyStatus({ memberId: self.memberId })
+        if (statusRes.code === 200 && statusRes.data) {
+          var list = statusRes.data
+          self.pendingSurvey = null; self.allDone = true; var hasAny = false
+          for (var si = 0; si < list.length; si++) { var s = list[si]; if (!s) continue; hasAny = true; if (s.status === 'pending') { self.pendingSurvey = { templateId: s.templateId, title: s.title || '定期调研', dimension: s.dimension || '' }; self.allDone = false } }
+          if (!hasAny) { self.pendingSurvey = null; self.allDone = false }
         }
-      } catch (e) {
-        console.error('加载孩子列表失败', e)
-      } finally {
-        this.loading = false
-      }
-    },
-    async viewChildRecords(child) {
-      this.selectedChild = child
-      this.selectedChildName = child.nickname || child.name
-      this.loading = true
+      } catch(e) {}
       try {
-        var res = await getChildRecords(child.id)
-        if (res.code === 200) {
-          this.records = res.data || []
+        var histRes = await getSurveyHistory({ memberId: self.memberId })
+        if (histRes.code === 200 && histRes.data) {
+          var hlist = histRes.data; self.surveyHistory = []
+          for (var hi = 0; hi < Math.min(hlist.length, 3); hi++) { var h = hlist[hi]; self.surveyHistory.push({ _date: h && h.submittedAt ? h.submittedAt.substring(0, 10) : '', _content: '已完成调研' }) }
         }
-      } catch (e) {
-        console.error('加载成长记录失败', e)
-      } finally {
-        this.loading = false
-      }
+      } catch(e) {}
     },
-    viewDetail: function(record) {
-      uni.navigateTo({
-        url: '/pages/growth/detail?recordId=' + record.id
-      })
-    },
-    createRecord: function() {
-      uni.navigateTo({
-        url: '/pages/growth/create?memberId=' + this.selectedChild.id + '&childName=' + encodeURIComponent(this.selectedChildName)
-      })
-    },
-    formatDate: function(date) {
-      if (!date) return ''
-      return date.substring(0, 10)
-    },
-    async prepareGuideInvite() {
+    async loadMilestones() {
+      var self = this
       try {
-        var ui = uni.getStorageSync('userInfo') || {}
-        if (!ui.familyId) {
-          uni.showToast({ title: '请先创建家庭', icon: 'none' })
-          return
-        }
-        var userInfo = ui
-        var generateInviteCard = require('../../utils/api.js').generateInviteCard
-        var res = await generateInviteCard('guide')
-        var code = res.data.code
-        this.shareData = {
-          title: (userInfo && userInfo.nickname ? userInfo.nickname : '家长') + ' 邀请你成为成长规划师',
-          path: '/pages/login/login?invite_code=' + code
-        }
-        uni.showToast({ title: '点击右上角转发给TA', icon: 'none' })
-      } catch (e) {
-        uni.showToast({ title: e.message || '生成邀请失败', icon: 'none' })
-      }
+        var ciRes = await healthCheckinList({ memberId: self.memberId })
+        if (ciRes.code === 200 && ciRes.data) self.streakDays = Math.min(ciRes.data.length, 30)
+      } catch(e) {}
+      self.activeChallenges = 0
+      self.completeness = self.streakDays > 0 ? Math.min(100, Math.round(self.streakDays * 10)) : 0
     }
   }
 }
 </script>
-
 <style scoped>
-.container {
-  background: #F8F8F8;
-  min-height: 100vh;
-  padding-bottom: 120rpx;
-}
-
-.section {
-  margin: 20rpx 24rpx;
-}
-
-.section-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 16rpx;
-}
-
-.section-title {
-  font-size: 30rpx;
-  font-weight: bold;
-  color: #333;
-}
-
-.section-more {
-  font-size: 24rpx;
-  color: #F97316;
-}
-
-/* 今日待办卡片 */
-.todo-card {
-  background: #fff;
-  border-radius: 20rpx;
-  padding: 24rpx;
-  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
-}
-
-.todo-header {
-  margin-bottom: 16rpx;
-}
-
-.todo-title {
-  font-size: 28rpx;
-  font-weight: bold;
-  color: #333;
-}
-
-.todo-grid {
-  display: flex;
-  flex-wrap: wrap;
-}
-
-.todo-item {
-  width: 50%;
-  display: flex;
-  align-items: center;
-  padding: 10rpx 0;
-  flex-wrap: wrap;
-}
-
-.todo-icon {
-  font-size: 28rpx;
-  margin-right: 8rpx;
-}
-
-.todo-label {
-  font-size: 24rpx;
-  color: #999;
-  margin-right: 8rpx;
-}
-
-.todo-value {
-  font-size: 28rpx;
-  font-weight: bold;
-  color: #333;
-}
-
-.text-success {
-  color: #10B981;
-}
-
-.text-muted {
-  color: #ccc;
-}
-
-/* 功能入口网格 3列 */
-.grid-3col {
-  display: flex;
-  flex-wrap: wrap;
-  background: #fff;
-  border-radius: 20rpx;
-  padding: 8rpx 0;
-}
-
-.grid-item {
-  width: 33.33%;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  padding: 20rpx 0;
-}
-
-.grid-icon {
-  width: 72rpx;
-  height: 72rpx;
-  border-radius: 18rpx;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  margin-bottom: 8rpx;
-}
-
-.grid-icon text {
-  font-size: 32rpx;
-}
-
-.grid-item .grid-label {
-  font-size: 24rpx;
-  color: #666;
-}
-
-/* 孩子选择 */
-.children-list {
-  margin-top: 8rpx;
-}
-
-.child-card {
-  display: flex;
-  align-items: center;
-  background: #fff;
-  border-radius: 20rpx;
-  padding: 30rpx;
-  margin-bottom: 20rpx;
-}
-
-.child-info {
-  flex: 1;
-}
-
-.child-name {
-  font-size: 32rpx;
-  font-weight: bold;
-  color: #333;
-  display: block;
-}
-
-.child-age {
-  font-size: 24rpx;
-  color: #999;
-  margin-top: 6rpx;
-  display: block;
-}
-
-.arrow {
-  width: 40rpx;
-}
-
-.arrow-icon {
-  font-size: 40rpx;
-  color: #ccc;
-}
-
-/* 记录筛选 */
-.records-section {
-  margin-top: 16rpx;
-}
-
-.filter-tabs {
-  display: flex;
-  gap: 16rpx;
-  margin-bottom: 20rpx;
-}
-
-.filter-tab {
-  flex: 1;
-  text-align: center;
-  padding: 12rpx 0;
-  border-radius: 20rpx;
-  background: #fff;
-  border: 1rpx solid #E0E0E0;
-}
-
-.tab-active {
-  background: #667eea;
-  border-color: #667eea;
-}
-
-.tab-text {
-  font-size: 24rpx;
-  color: #666;
-}
-
-.tab-active-text {
-  color: #fff;
-}
-
-/* 记录卡片 */
-.record-card {
-  background: #fff;
-  border-radius: 16rpx;
-  padding: 24rpx;
-  margin-bottom: 16rpx;
-}
-
-.record-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 12rpx;
-}
-
-.record-title {
-  font-size: 28rpx;
-  color: #333;
-  font-weight: bold;
-  flex: 1;
-}
-
-.record-badges {
-  display: flex;
-  gap: 8rpx;
-  flex-shrink: 0;
-}
-
-.record-source-tag {
-  font-size: 20rpx;
-  padding: 4rpx 10rpx;
-  border-radius: 8rpx;
-  background: #f0f0ff;
-  color: #667eea;
-}
-
-.record-tag {
-  font-size: 22rpx;
-  padding: 4rpx 12rpx;
-  border-radius: 20rpx;
-}
-
-.tag-completed {
-  background: #e8f5e9;
-  color: #4caf50;
-}
-
-.tag-pending {
-  background: #fff3e0;
-  color: #ff9800;
-}
-
-.record-scores {
-  display: flex;
-  gap: 20rpx;
-}
-
-.score-item {
-  font-size: 24rpx;
-  color: #666;
-}
-
-/* 空状态 */
-.empty-state {
-  text-align: center;
-  padding: 60rpx 0;
-}
-
-.empty-icon {
-  font-size: 80rpx;
-  display: block;
-  margin-bottom: 20rpx;
-}
-
-.empty-text {
-  font-size: 28rpx;
-  color: #999;
-  display: block;
-}
-
-.empty-hint {
-  font-size: 24rpx;
-  color: #ccc;
-  margin-top: 10rpx;
-  display: block;
-}
-
-/* 按钮 */
-.footer-btn {
-  padding: 30rpx 0;
-}
-
-.btn-create {
-  width: 100%;
-  height: 88rpx;
-  line-height: 88rpx;
-  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-  color: #fff;
-  border-radius: 44rpx;
-  font-size: 32rpx;
-  font-weight: bold;
-  text-align: center;
-}
-
-.btn-invite-guide {
-  width: 100%;
-  height: 88rpx;
-  line-height: 88rpx;
-  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-  color: #fff;
-  border-radius: 44rpx;
-  font-size: 32rpx;
-  font-weight: bold;
-  text-align: center;
-}
-</style>
+.container{background:#FFF7ED;min-height:100vh;padding-bottom:120rpx}
+.section{margin:16rpx 24rpx}
+.reminder-bar{margin:12rpx 24rpx;background:#FFF3E0;border:1rpx solid #FFB74D;border-radius:12rpx;padding:14rpx 20rpx;display:flex;align-items:center;justify-content:space-between}
+.reminder-text{font-size:24rpx;color:#E65100}
+.reminder-close{font-size:28rpx;color:#999}
+.progress-card{background:#fff;border-radius:20rpx;padding:24rpx;box-shadow:0 2rpx 12rpx rgba(0,0,0,0.06)}
+.progress-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16rpx}
+.progress-title{font-size:28rpx;font-weight:bold;color:#333}
+.progress-date{font-size:22rpx;color:#999}
+.progress-item{display:flex;align-items:center;padding:12rpx 0;border-bottom:1rpx solid #f5f5f5}
+.progress-item:last-of-type{border-bottom:none}
+.progress-icon{font-size:28rpx;margin-right:12rpx;flex-shrink:0}
+.progress-label{font-size:26rpx;color:#333;flex:1}
+.progress-status{font-size:22rpx;margin-right:8rpx;flex-shrink:0}
+.status-done{color:#10B981}
+.status-pending{color:#ccc}
+.progress-btn{font-size:22rpx;color:#F97316;padding:4rpx 16rpx;border:1rpx solid #F97316;border-radius:20rpx;flex-shrink:0}
+.progress-footer{display:flex;justify-content:space-between;align-items:center;margin-top:16rpx;padding-top:16rpx;border-top:1rpx solid #f5f5f5}
+.footer-text{font-size:24rpx;color:#666;font-weight:bold}
+.footer-streak{font-size:24rpx;color:#F97316;font-weight:bold}
+.tracking-card{background:#fff;border-radius:20rpx;padding:24rpx;box-shadow:0 2rpx 12rpx rgba(0,0,0,0.06)}
+.tracking-tabs{display:flex;margin-bottom:16rpx}
+.tracking-tab{flex:1;text-align:center;padding:10rpx 0;border-radius:20rpx;margin-right:8rpx;background:#FFF7ED}
+.tracking-tab:last-child{margin-right:0}
+.tab-active{background:#F97316}
+.tab-text{font-size:24rpx;color:#666}
+.tab-active-text{color:#fff;font-weight:bold}
+.tracking-content{min-height:200rpx}
+.tracking-empty{display:flex;flex-direction:column;align-items:center;padding:60rpx 0}
+.empty-icon{font-size:60rpx;margin-bottom:12rpx}
+.empty-text{font-size:26rpx;color:#999}
+.empty-hint{font-size:22rpx;color:#ccc;margin-top:8rpx}
+.tracking-record{display:flex;align-items:center;padding:10rpx 0;border-bottom:1rpx solid #f5f5f5}
+.record-date{font-size:22rpx;color:#999;width:120rpx;flex-shrink:0}
+.record-content{font-size:24rpx;color:#333;flex:1}
+.tracking-more{text-align:right;font-size:24rpx;color:#F97316;padding:12rpx 0 0 0}
+.milestone-card{background:#fff;border-radius:20rpx;padding:24rpx;box-shadow:0 2rpx 12rpx rgba(0,0,0,0.06)}
+.section-title-row{font-size:26rpx;font-weight:bold;color:#333;margin-bottom:16rpx;display:block}
+.milestone-grid{display:flex;justify-content:space-between}
+.milestone-item{display:flex;flex-direction:column;align-items:center;flex:1}
+.milestone-icon{font-size:40rpx;margin-bottom:4rpx}
+.milestone-value{font-size:28rpx;font-weight:bold;color:#F97316;margin-bottom:2rpx}
+.milestone-label{font-size:22rpx;color:#999}
+.survey-card{background:#fff;border-radius:20rpx;padding:24rpx;box-shadow:0 2rpx 12rpx rgba(0,0,0,0.06)}
+.survey-pending{background:#FFF3E0;border-radius:12rpx;padding:16rpx;margin-bottom:12rpx}
+.survey-alert{font-size:24rpx;color:#E65100;font-weight:bold;display:block;margin-bottom:4rpx}
+.survey-title-text{font-size:24rpx;color:#333;display:block;margin-bottom:8rpx}
+.survey-btn{font-size:22rpx;color:#F97316;font-weight:bold}
+.survey-done{padding:8rpx 0}
+.survey-done-text{font-size:24rpx;color:#10B981}
+.survey-history-list{border-top:1rpx solid #f5f5f5;padding-top:12rpx}
+.history-label{font-size:22rpx;color:#999;display:block;margin-bottom:6rpx}
+.history-item{display:flex;align-items:center;padding:6rpx 0}
+.history-date{font-size:22rpx;color:#999;width:120rpx;flex-shrink:0}
+.history-content{font-size:24rpx;color:#666;flex:1}
+.survey-more{text-align:right;font-size:22rpx;color:#F97316;padding-top:8rpx}
+.survey-empty{padding:12rpx 0}
+.survey-empty-text{font-size:24rpx;color:#999}
+</style>

+ 1 - 2
cfc-frontend/pages/health-main/index.vue

@@ -216,8 +216,7 @@ export default {
     loadPlanList: function() {
       var self = this
       var api = require('../../utils/api.js')
-      var data = { familyId: uni.getStorageSync('familyId') || null }
-      api.getHealthPlanList(data).then(function(res) {
+      api.getHealthPlanList({}).then(function(res) {
         if (res && res.code === 200 && res.data) {
           var list = res.data.list || res.data
           if (Array.isArray(list) && list.length > 0) self.lastPlan = list[0].goal || ''

+ 3 - 3
cfc-frontend/pages/health/health-plan-summary.vue

@@ -105,7 +105,7 @@ export default {
       var self = this
       if (!self.canGenerate) return
       self.loading = true
-      var data = { familyId: uni.getStorageSync('familyId') || null, memberIds: [self.selectedMemberId], dimensions: self.selectedDimensions.join(','), goal: self.goal.trim() }
+      var data = { memberIds: [self.selectedMemberId], dimensions: self.selectedDimensions.join(','), goal: self.goal.trim() }
       generateHealthPlan(data).then(function(res) {
         self.loading = false
         if (res && res.code === 200 && res.data) self.generatedPlan = typeof res.data === 'string' ? res.data : JSON.stringify(res.data)
@@ -113,7 +113,7 @@ export default {
     },
     savePlan: function() {
       var self = this
-      var data = { familyId: uni.getStorageSync('familyId') || null, memberIds: [self.selectedMemberId], memberName: self.selectedMemberName, dimensions: self.selectedDimensions.join(','), goal: self.goal.trim(), planContent: self.generatedPlan }
+      var data = { memberIds: [self.selectedMemberId], memberName: self.selectedMemberName, dimensions: self.selectedDimensions.join(','), goal: self.goal.trim(), planContent: self.generatedPlan }
       saveHealthPlan(data).then(function(res) {
         if (res && res.code === 200) { self.generatedPlan = ''; self.loadPlanList() }
       }).catch(function() {})
@@ -123,7 +123,7 @@ export default {
     },
     loadPlanList: function() {
       var self = this
-      getHealthPlanList({ familyId: uni.getStorageSync('familyId') || null }).then(function(res) {
+      getHealthPlanList({}).then(function(res) {
         if (res && res.code === 200 && res.data) { var list = res.data.list || res.data; self.planList = Array.isArray(list) ? list : [] }
         else self.planList = []
       }).catch(function() { self.planList = [] })

+ 1 - 1
cfc-frontend/pages/health/report-survey.vue

@@ -391,7 +391,7 @@
 </template>
 
 <script>
-import { createReportSurvey, submitReportSurvey, getSurveyStatus } from '../../utils/api.js'
+import { createReportSurvey, submitReportSurvey, getReportSurveyStatus } from '../../utils/api.js'
 
 export default {
   data() {

+ 3 - 17
cfc-frontend/pages/health/report-upload.vue

@@ -74,7 +74,6 @@ export default {
   data() {
     return {
       memberId: null,
-      familyId: null,
       uploading: false,
       selectedFile: null,
       photoPath: null,
@@ -88,22 +87,9 @@ export default {
     this.loadRecommendProducts()
   },
   onShow: function() {
-    // 每次显示时从store获取familyId
-    this.loadFamilyInfo()
+    this.loadRecommendProducts()
   },
   methods: {
-    loadFamilyInfo() {
-      // 从本地存储获取familyId
-      var userInfo = uni.getStorageSync('userInfo')
-      if (userInfo) {
-        try {
-          var info = typeof userInfo === 'string' ? JSON.parse(userInfo) : userInfo
-          this.familyId = info.familyId || null
-        } catch (e) {
-          console.log('解析userInfo失败', e)
-        }
-      }
-    },
     // ===== 拍照上传 =====
     takePhoto() {
       var self = this
@@ -112,7 +98,7 @@ export default {
         sourceType: ['camera', 'album'],
         success: function(res) {
           self.photoPath = res.tempFilePaths[0]
-          // 拍照后直接上传并进入确认页(familyId 为可选)
+          // 拍照后直接上传并进入确认页
           self.uploadAndGoConfirm(self.photoPath, 'image')
         }
       })
@@ -128,7 +114,7 @@ export default {
           var files = res.tempFiles
           if (files && files.length > 0) {
             self.selectedFile = files[0]
-            // 直接上传并进入确认页(familyId 为可选,后端会自行处理成员匹配)
+            // 直接上传并进入确认页(后端会自行处理成员匹配)
             self.uploadAndGoConfirm(files[0].path, '')
           }
         },

+ 85 - 0
cfc-frontend/pages/health/survey-history.vue

@@ -0,0 +1,85 @@
+<template>
+  <view class="container">
+    <view class="empty-state" v-if="historyList.length === 0 && !loading">
+      <text class="empty-icon">📋</text>
+      <text class="empty-text">还没有调研记录</text>
+      <text class="empty-hint">完成首次调研后这里会显示历史</text>
+    </view>
+    <view class="record-list">
+      <view class="record-card" v-for="(item, idx) in historyList" :key="getRecordKey(item)">
+        <view class="record-header">
+          <text class="record-title">{{ getRecordTitle(item) }}</text>
+          <text class="record-date">{{ item.submittedAt && item.submittedAt.substring(0, 10) }}</text>
+        </view>
+        <view class="record-period" v-if="item.periodStart || item.periodEnd">
+          <text class="period-label">周期:</text>
+          <text class="period-text">{{ item.periodStart && item.periodStart.substring(0, 10) }} - {{ item.periodEnd && item.periodEnd.substring(0, 10) }}</text>
+        </view>
+        <view class="record-answers">
+          <text class="answers-label">答案摘要:</text>
+          <text class="answers-text">{{ getAnswerSummary(item) }}</text>
+        </view>
+      </view>
+    </view>
+    <view class="loading-state" v-if="loading"><text class="loading-text">加载中...</text></view>
+  </view>
+</template>
+<script>
+import { getSurveyHistory, getActiveTemplates } from '../../utils/api.js'
+export default {
+  data() { return { memberId: '', loading: false, historyList: [], templateMap: {} } },
+  onLoad(options) {
+    var ui = uni.getStorageSync('userInfo') || {}
+    this.memberId = options.memberId || ui.currentMemberId || ui.memberId
+    this.loadData()
+  },
+  onShow() { if (this.memberId) this.loadData() },
+  methods: {
+    getRecordKey(item) { return item && item.id ? 'r-' + item.id : 'r-' + Math.random() },
+    getRecordTitle(item) { if (item.title) return item.title; if (this.templateMap[item.templateId]) return this.templateMap[item.templateId]; return '调研记录' },
+    getAnswerSummary(item) {
+      if (!item.answersJson) return '无'
+      try {
+        var answers = JSON.parse(item.answersJson)
+        if (answers && answers.length > 0) {
+          var parts = []
+          for (var i = 0; i < Math.min(answers.length, 3); i++) parts.push('Q' + (i + 1) + ': ' + answers[i])
+          return parts.join(' | ')
+        }
+      } catch(e) {}
+      return '已提交'
+    },
+    async loadData() {
+      this.loading = true
+      try {
+        var self = this
+        var tr = await getActiveTemplates()
+        if (tr.code === 200 && tr.data) { for (var i = 0; i < tr.data.length; i++) { var t = tr.data[i]; if (t && t.id && t.title) self.templateMap[t.id] = t.title } }
+        var res = await getSurveyHistory({ memberId: self.memberId })
+        if (res.code === 200) self.historyList = res.data || []
+      } catch(e) { console.error('加载调研历史失败', e) }
+      finally { this.loading = false }
+    }
+  }
+}
+</script>
+<style scoped>
+.container{background:#FFF7ED;min-height:100vh;padding-bottom:40rpx}
+.record-list{padding:0 24rpx}
+.record-card{background:#fff;border-radius:20rpx;padding:24rpx;margin-bottom:16rpx;box-shadow:0 2rpx 12rpx rgba(0,0,0,0.06)}
+.record-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12rpx}
+.record-title{font-size:28rpx;font-weight:bold;color:#333}
+.record-date{font-size:24rpx;color:#999}
+.record-period{display:flex;align-items:center;margin-bottom:8rpx}
+.period-label{font-size:24rpx;color:#999;margin-right:8rpx}
+.period-text{font-size:24rpx;color:#F97316}
+.record-answers{display:flex;align-items:flex-start}
+.answers-label{font-size:24rpx;color:#999;margin-right:8rpx;flex-shrink:0}
+.answers-text{font-size:24rpx;color:#666;flex:1}
+.empty-state{display:flex;flex-direction:column;align-items:center;padding:120rpx 0}
+.empty-icon{font-size:80rpx;margin-bottom:20rpx}
+.empty-text{font-size:30rpx;color:#666;margin-bottom:12rpx}
+.empty-hint{font-size:24rpx;color:#999}
+.loading-state{display:flex;justify-content:center;padding:80rpx 0}
+.loading-text{font-size:26rpx;color:#999}
+</style>

+ 99 - 0
cfc-frontend/pages/health/survey-questionnaire.vue

@@ -0,0 +1,99 @@
+<template>
+  <view class="container">
+    <view class="header-card">
+      <text class="header-title">{{ templateTitle }}</text>
+      <text class="header-dimension" v-if="templateDimension">适用维度: {{ templateDimension }}</text>
+      <text class="header-period" v-if="periodStart || periodEnd">周期: {{ periodStart && formatDate(periodStart) }} - {{ periodEnd && formatDate(periodEnd) }}</text>
+    </view>
+    <view class="question-section" v-for="(q, qIdx) in questions" :key="getQuestionKey(q)">
+      <view class="question-header">
+        <text class="question-num">Q{{ qIdx + 1 }}</text>
+        <text class="question-text">{{ q.text }}</text>
+      </view>
+      <view class="options-list">
+        <view class="option-item" :class="answers[qIdx] === opt ? 'option-selected' : ''" v-for="(opt, oIdx) in q.options" :key="getOptionKey(q, opt)" @click="selectOption(qIdx, opt)">
+          <view class="option-radio" :class="answers[qIdx] === opt ? 'radio-checked' : ''"></view>
+          <text class="option-text">{{ opt }}</text>
+        </view>
+      </view>
+    </view>
+    <view class="footer-area">
+      <button class="btn-submit" :disabled="isSubmitting" @click="handleSubmit">{{ isSubmitting ? '提交中...' : '提交答案' }}</button>
+    </view>
+  </view>
+</template>
+<script>
+import { getActiveTemplates, submitSurvey } from '../../utils/api.js'
+export default {
+  data() { return { templateId: null, templateTitle: '定期调研', templateDimension: '', questions: [], answers: {}, periodStart: '', periodEnd: '', isSubmitting: false } },
+  onLoad(options) {
+    if (options.templateId) this.templateId = parseInt(options.templateId)
+    if (options.templateTitle) this.templateTitle = decodeURIComponent(options.templateTitle)
+    if (options.templateDimension) this.templateDimension = options.templateDimension
+    if (options.periodStart) this.periodStart = options.periodStart
+    if (options.periodEnd) this.periodEnd = options.periodEnd
+    if (options.questions) { try { this.questions = JSON.parse(decodeURIComponent(options.questions)) } catch(e) { this.questions = [] } }
+    if (this.templateId && !this.questions.length) this.loadTemplate()
+  },
+  methods: {
+    getQuestionKey(q) { return q && q.text ? q.text : 'q-' + Math.random() },
+    getOptionKey(q, opt) { return q && q.text ? q.text + '-' + opt : 'opt-' + Math.random() },
+    formatDate(s) { return s ? s.substring(0, 10) : '' },
+    selectOption(qIdx, opt) { this.$set(this.answers, qIdx, opt) },
+    async loadTemplate() {
+      try {
+        var res = await getActiveTemplates()
+        if (res.code === 200 && res.data) {
+          for (var i = 0; i < res.data.length; i++) {
+            var t = res.data[i]
+            if (t && t.id === this.templateId) {
+              this.templateTitle = t.title || '定期调研'
+              this.templateDimension = t.dimension || ''
+              if (t.questionsJson) { try { this.questions = JSON.parse(t.questionsJson) } catch(e) {} }
+              break
+            }
+          }
+        }
+      } catch(e) { console.error('加载模板失败', e) }
+    },
+    async handleSubmit() {
+      var self = this
+      var ui = uni.getStorageSync('userInfo') || {}
+      var memberId = ui.currentMemberId || ui.memberId
+      var answersArr = []
+      for (var i = 0; i < this.questions.length; i++) answersArr.push(this.answers[i] || '')
+      var answersJson = JSON.stringify(answersArr)
+      if (!answersJson) { uni.showToast({ title: '请至少选择一道题', icon: 'none' }); return }
+      this.isSubmitting = true
+      try {
+        var d = { memberId: memberId, templateId: this.templateId, answersJson: answersJson }
+        if (this.periodStart) d.periodStart = this.periodStart
+        if (this.periodEnd) d.periodEnd = this.periodEnd
+        var res = await submitSurvey(d)
+        if (res.code === 200) { uni.showToast({ title: '提交成功', icon: 'success' }); setTimeout(function(){ uni.navigateTo({ url: '/pages/health/survey-history?memberId=' + (memberId || '') }) }, 1200) }
+      } catch(e) { uni.showToast({ title: e.message || '提交失败', icon: 'none' }) }
+      finally { this.isSubmitting = false }
+    }
+  }
+}
+</script>
+<style scoped>
+.container{background:#FFF7ED;min-height:100vh;padding-bottom:120rpx}
+.header-card{background:#fff;margin:20rpx 24rpx;border-radius:20rpx;padding:28rpx;box-shadow:0 2rpx 12rpx rgba(0,0,0,0.06)}
+.header-title{font-size:32rpx;font-weight:bold;color:#333;display:block;margin-bottom:8rpx}
+.header-dimension{font-size:24rpx;color:#999;display:block;margin-bottom:4rpx}
+.header-period{font-size:24rpx;color:#F97316;display:block}
+.question-section{background:#fff;margin:16rpx 24rpx;border-radius:20rpx;padding:24rpx;box-shadow:0 2rpx 12rpx rgba(0,0,0,0.06)}
+.question-header{display:flex;align-items:flex-start;margin-bottom:16rpx}
+.question-num{font-size:24rpx;font-weight:bold;color:#F97316;margin-right:12rpx;flex-shrink:0}
+.question-text{font-size:26rpx;color:#333;flex:1}
+.option-item{display:flex;align-items:center;padding:16rpx 0;border-bottom:1rpx solid #f0f0f0}
+.option-item:last-child{border-bottom:none}
+.option-radio{width:28rpx;height:28rpx;border-radius:50%;border:2rpx solid #ddd;margin-right:16rpx;flex-shrink:0}
+.radio-checked{border-color:#F97316;background:#F97316}
+.option-text{font-size:26rpx;color:#333}
+.option-selected .option-text{color:#F97316;font-weight:bold}
+.footer-area{padding:20rpx 24rpx}
+.btn-submit{background:#F97316;color:#fff;border:none;border-radius:16rpx;font-size:30rpx;font-weight:bold;height:80rpx;line-height:80rpx}
+.btn-submit[disabled]{background:#ffb47a}
+</style>

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

@@ -1937,6 +1937,12 @@ export const healthCheckinList = (data) => request('/api/health/checkin/list', '
 export const healthCheckinCreate = (data) => request('/api/health/checkin/create', 'POST', data)
 export const healthCheckinDelete = (id) => request('/api/health/checkin/delete', 'POST', { id })
 
+// ===== 定期调研 =====
+export const getSurveyStatus = (data) => request('/api/survey/status', 'POST', data)
+export const getActiveTemplates = () => request('/api/survey/template/active', 'POST', {})
+export const submitSurvey = (data) => request('/api/survey/submit', 'POST', data)
+export const getSurveyHistory = (data) => request('/api/survey/history', 'POST', data)
+
 // ===== 寰鍔紙P0 鏈夋晥鍚姩锛� =====
 export const microActionList = () => request('/api/micro-action/list', 'POST', {})
 export const microActionToday = (data) => request('/api/micro-action/today', 'POST', data)
@@ -2154,7 +2160,7 @@ export const discardReportDraft = (draftId) => request('/api/health/report/disca
 // ===== 鎶ュ憡璋冪爺闂嵎 =====
 export const createReportSurvey = (reportId, memberId) => request('/api/health/survey/create', 'POST', { reportId, memberId })
 export const submitReportSurvey = (surveyId, surveyData) => request('/api/health/survey/submit', 'POST', { surveyId, surveyData })
-export const getSurveyStatus = (reportId) => request('/api/health/survey/status', 'POST', { reportId })
+export const getReportSurveyStatus = (reportId) => request('/api/health/survey/status', 'POST', { reportId })
 
 // ===== Report Feedback (gut flora report feedback) =====
 export const submitFeedback = (data) => request('/api/health/feedback/submit', 'POST', data)

+ 17 - 0
cfc-web/src/api/survey.js

@@ -0,0 +1,17 @@
+import request from '@/utils/request'
+
+export function getSurveyTemplates(params) {
+  return request({ url: '/api/admin/survey/template/list', method: 'post', data: params || {} })
+}
+
+export function saveSurveyTemplate(data) {
+  return request({ url: '/api/admin/survey/template/save', method: 'post', data })
+}
+
+export function deleteSurveyTemplate(data) {
+  return request({ url: '/api/admin/survey/template/delete', method: 'post', data })
+}
+
+export function aiGenerateSurveyQuestions(data) {
+  return request({ url: '/api/admin/survey/template/ai-generate', method: 'post', data })
+}

+ 12 - 6
cfc-web/src/router/index.js

@@ -555,12 +555,18 @@ const routes = [
         component: () => import('@/views/admin/Activities.vue'),
         meta: { title: '活动列表', perm: 'activity:list' }
       },
-      {
-        path: 'activity-edit',
-        name: 'ActivityEdit',
-        component: () => import('@/views/admin/ActivityEdit.vue'),
-        meta: { title: '活动编辑', perm: 'activity:list' }
-      },
+        {
+          path: 'activity-edit',
+          name: 'ActivityEdit',
+          component: () => import('@/views/admin/ActivityEdit.vue'),
+          meta: { title: '活动编辑', perm: 'activity:list' }
+        },
+        {
+          path: 'survey-templates',
+          name: 'SurveyTemplates',
+          component: () => import('@/views/admin/SurveyTemplates.vue'),
+          meta: { title: '调研模板管理', perm: 'admin:all' }
+        },
       {
         path: 'activity-review',
         name: 'ActivityReview',

+ 295 - 0
cfc-web/src/views/admin/SurveyTemplates.vue

@@ -0,0 +1,295 @@
+<template>
+  <div class="survey-templates admin-page">
+    <el-card>
+      <div slot="header" class="admin-page-header">
+        <span class="admin-page-title">调研模板管理</span>
+        <el-button type="primary" size="mini" icon="el-icon-plus" @click="openCreate">新增模板</el-button>
+        <el-button type="success" size="mini" icon="el-icon-magic-stick" @click="openAiGenerate">AI生成调研</el-button>
+      </div>
+
+      <el-table :data="templates" v-loading="loading" border stripe>
+        <el-table-column prop="title" label="标题" min-width="150" />
+        <el-table-column prop="dimension" label="维度" width="80" />
+        <el-table-column prop="cycleType" label="周期" width="100">
+          <template slot-scope="scope">
+            {{ cycleLabel(scope.row.cycleType) }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="aiGenerated" label="AI生成" width="80">
+          <template slot-scope="scope">
+            {{ scope.row.aiGenerated === 1 ? '是' : '否' }}
+          </template>
+        </el-table-column>
+        <el-table-column label="启用" width="80" align="center">
+          <template slot-scope="scope">
+            <el-switch
+              v-model="scope.row.enabled"
+              :active-value="1"
+              :inactive-value="0"
+              @change="toggleEnabled(scope.row)"
+            />
+          </template>
+        </el-table-column>
+        <el-table-column prop="createdAt" label="创建时间" width="160">
+          <template slot-scope="scope">
+            {{ scope.row.createdAt && scope.row.createdAt.substring(0, 10) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="160" align="center">
+          <template slot-scope="scope">
+            <el-button type="text" size="mini" @click="openEdit(scope.row)">编辑</el-button>
+            <el-button type="text" size="mini" style="color:#f56c6c" @click="handleDelete(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <el-dialog title="新增/编辑调研模板" :visible.sync="editDialogVisible" width="600px">
+      <el-form :model="form" label-width="80px">
+        <el-form-item label="标题">
+          <el-input v-model="form.title" placeholder="调研标题" />
+        </el-form-item>
+        <el-form-item label="维度">
+          <el-select v-model="form.dimension" placeholder="适用维度" style="width:120px">
+            <el-option label="身" value="body" />
+            <el-option label="智" value="wisdom" />
+            <el-option label="富" value="wealth" />
+            <el-option label="行" value="action" />
+            <el-option label="心" value="mind" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="周期">
+          <el-select v-model="form.cycleType" placeholder="周期" style="width:120px">
+            <el-option label="每周" value="weekly" />
+            <el-option label="两周" value="biweekly" />
+            <el-option label="每月" value="monthly" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="启用">
+          <el-switch v-model="form.enabled" :active-value="1" :inactive-value="0" />
+        </el-form-item>
+        <el-form-item label="题目">
+          <div style="width:100%">
+            <div v-for="(q, qIdx) in form.questions" :key="qIdx" style="margin-bottom:12px;padding:10px;border:1px solid #eee;border-radius:4px;">
+              <el-input v-model="q.text" placeholder="题目文本" style="margin-bottom:8px" />
+              <el-tag
+                v-for="(opt, oIdx) in q.options"
+                :key="oIdx"
+                closable
+                @close="removeOption(q, oIdx)"
+                style="margin-right:4px;margin-bottom:4px"
+              >{{ opt }}</el-tag>
+              <el-input
+                v-if="addOptionText[qIdx]"
+                v-model="addOptionText[qIdx]"
+                placeholder="输入选项后回车添加"
+                size="mini"
+                style="width:200px;margin-top:4px"
+                @keyup.enter.native="addOption(q, qIdx)"
+                @blur="addOption(q, qIdx)"
+              />
+              <el-button type="text" size="mini" @click="addOptionInput(qIdx)">+ 添加选项</el-button>
+              <el-button type="text" size="mini" style="color:#f56c6c" @click="removeQuestion(qIdx)">删除题目</el-button>
+            </div>
+            <el-button type="primary" size="mini" plain @click="addQuestion">+ 添加题目</el-button>
+          </div>
+        </el-form-item>
+      </el-form>
+      <span slot="footer">
+        <el-button @click="editDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSave">保存</el-button>
+      </span>
+    </el-dialog>
+
+    <el-dialog title="AI生成调研题目" :visible.sync="aiDialogVisible" width="500px">
+      <el-form :model="aiForm" label-width="60px">
+        <el-form-item label="维度">
+          <el-select v-model="aiForm.dimension" placeholder="适用维度" style="width:120px">
+            <el-option label="身" value="body" />
+            <el-option label="智" value="wisdom" />
+            <el-option label="富" value="wealth" />
+            <el-option label="行" value="action" />
+            <el-option label="心" value="mind" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="主题">
+          <el-input v-model="aiForm.topic" placeholder="如:饮食习惯、睡眠质量" />
+        </el-form-item>
+      </el-form>
+      <div v-if="aiResult" style="margin-top:12px;padding:10px;background:#f5f5f5;border-radius:4px;max-height:300px;overflow-y:auto;">
+        <pre>{{ aiResult }}</pre>
+      </div>
+      <span slot="footer">
+        <el-button @click="aiDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleAiGenerate" :loading="aiLoading">生成</el-button>
+        <el-button type="success" @click="saveAiResult" :disabled="!aiResult">保存为模板</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getSurveyTemplates, saveSurveyTemplate, deleteSurveyTemplate, aiGenerateSurveyQuestions } from '@/api/survey'
+
+export default {
+  name: 'SurveyTemplates',
+  data() {
+    return {
+      loading: false,
+      templates: [],
+      editDialogVisible: false,
+      aiDialogVisible: false,
+      aiLoading: false,
+      aiResult: null,
+      form: this.emptyForm(),
+      aiForm: { dimension: '', topic: '' },
+      addOptionText: {}
+    }
+  },
+  created() {
+    this.loadTemplates()
+  },
+  methods: {
+    cycleLabel(type) {
+      var map = { weekly: '每周', biweekly: '两周', monthly: '每月' }
+      return map[type] || type || '-'
+    },
+    emptyForm() {
+      return { id: null, title: '', dimension: '', cycleType: 'weekly', enabled: 1, questions: [] }
+    },
+    async loadTemplates() {
+      this.loading = true
+      try {
+        var res = await getSurveyTemplates()
+        this.templates = res.data || []
+      } catch (e) {
+        this.$message.error('加载失败: ' + (e.message || '未知错误'))
+      } finally {
+        this.loading = false
+      }
+    },
+    openCreate() {
+      this.form = this.emptyForm()
+      this.form.questions = [{ text: '', options: [] }]
+      this.editDialogVisible = true
+    },
+    openEdit(row) {
+      this.form = Object.assign({}, row)
+      if (row.questionsJson) {
+        try { this.form.questions = JSON.parse(row.questionsJson) } catch (e) { this.form.questions = [] }
+      } else {
+        this.form.questions = []
+      }
+      this.editDialogVisible = true
+    },
+    addQuestion() {
+      this.form.questions.push({ text: '', options: [] })
+    },
+    removeQuestion(idx) {
+      this.form.questions.splice(idx, 1)
+    },
+    addOptionInput(idx) {
+      this.$set(this.addOptionText, idx, '')
+    },
+    addOption(q, qIdx) {
+      var txt = this.addOptionText[qIdx]
+      if (txt) {
+        q.options.push(txt)
+        this.$set(this.addOptionText, qIdx, '')
+      }
+    },
+    removeOption(q, oIdx) {
+      q.options.splice(oIdx, 1)
+    },
+    async handleSave() {
+      if (!this.form.title) { this.$message.warning('请输入标题'); return }
+      var questionsJson = JSON.stringify(this.form.questions)
+      var data = {
+        title: this.form.title,
+        dimension: this.form.dimension,
+        cycleType: this.form.cycleType,
+        enabled: this.form.enabled,
+        questionsJson: questionsJson,
+        aiGenerated: 0
+      }
+      if (this.form.id) data.id = this.form.id
+      try {
+        await saveSurveyTemplate(data)
+        this.$message.success('保存成功')
+        this.editDialogVisible = false
+        this.loadTemplates()
+      } catch (e) {
+        this.$message.error('保存失败: ' + (e.message || '未知错误'))
+      }
+    },
+    async toggleEnabled(row) {
+      try {
+        await saveSurveyTemplate({ id: row.id, enabled: row.enabled, title: row.title, dimension: row.dimension, cycleType: row.cycleType, questionsJson: row.questionsJson, aiGenerated: row.aiGenerated })
+        this.$message.success('状态已更新')
+      } catch (e) {
+        row.enabled = row.enabled === 1 ? 0 : 1
+        this.$message.error('更新失败')
+      }
+    },
+    async handleDelete(row) {
+      try {
+        await this.$confirm('确认删除此模板?', '提示', { type: 'warning' })
+        await deleteSurveyTemplate({ id: row.id })
+        this.$message.success('删除成功')
+        this.loadTemplates()
+      } catch (e) {
+        if (e !== 'cancel') this.$message.error('删除失败')
+      }
+    },
+    openAiGenerate() {
+      this.aiForm = { dimension: '', topic: '' }
+      this.aiResult = null
+      this.aiDialogVisible = true
+    },
+    async handleAiGenerate() {
+      if (!this.aiForm.dimension || !this.aiForm.topic) {
+        this.$message.warning('请选择维度并输入主题')
+        return
+      }
+      this.aiLoading = true
+      this.aiResult = null
+      try {
+        var res = await aiGenerateSurveyQuestions({ dimension: this.aiForm.dimension, topic: this.aiForm.topic })
+        this.aiResult = typeof res.data === 'string' ? res.data : JSON.stringify(res.data, null, 2)
+      } catch (e) {
+        this.$message.error('AI生成失败: ' + (e.message || '未知错误'))
+      } finally {
+        this.aiLoading = false
+      }
+    },
+    saveAiResult() {
+      if (!this.aiResult) return
+      try {
+        var questions = typeof this.aiResult === 'string' ? JSON.parse(this.aiResult) : this.aiResult
+        this.form = this.emptyForm()
+        this.form.title = this.aiForm.topic + '调研'
+        this.form.dimension = this.aiForm.dimension
+        this.form.questions = questions
+        this.form.aiGenerated = 1
+        this.aiDialogVisible = false
+        this.editDialogVisible = true
+      } catch (e) {
+        this.$message.error('解析AI结果失败,请检查格式')
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.admin-page-header {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+.admin-page-title {
+  font-size: 16px;
+  font-weight: bold;
+  margin-right: auto;
+}
+</style>

+ 2 - 0
docs/superpowers/PROJECT-OVERVIEW.md

@@ -347,6 +347,7 @@
 | `2026-08-07-xaf-internal-communication-design.md` | ✅ 已实施(16 页四层作战手册:一句话共识→标准话术→沟通红线→常见问答;独立目录 `ppt-output/internal/`,渲染验证通过) | 浠艾福对外沟通·统一口径(内部版)PPT 设计(战略对齐·统一口径,每页自包含可查阅) |
 | `2026-08-08-family-challenge-participation-design.md` | ✅ 设计已确认(2026-08-08 拍板:C3 只扣沟通,其余按默认) | 家庭挑战参与机制重构(任意成员发起/可选参与/全体同意后开始/方向性关系评分) |
 | `2026-08-08-health-data-center-design.md` | ✅ 已实施(身维度首页改造+在线测评题库扩展+方案汇总页+health_plans后端全栈) | 健康数据中心 — 看见·主动健康闭环(Tab栏+四维雷达图+数据采集+数据查看+方案汇总+认知/心理/关系测评) |
+| `2026-08-08-growth-intervention-center-design.md` | ✅ 已实施(迁移208-209/2实体/2Mapper/SurveyService/SurveyController/AdminSurveyController/调研问卷页/调研历史页/growth-main重构/SurveyTemplates管理端) | 成长页面重构为干预中心(今日干预进度卡+持续追踪Tab+里程碑+定期调研+后端调研系统全栈) |
 
 ### 实施计划(plans/)
 
@@ -412,6 +413,7 @@
 | `2026-08-08-family-challenge-participation.md` | ✅ 已实施(9 commits:迁移182-185/3实体/ChallengeScoreService/respond端点/评分配置页/小程序同意拒绝+成员多选) | 家庭挑战参与机制重构(任意成员发起/可选参与/同意后开始/方向性评分) |
 | `2026-08-09-lifetime-membership.md` | ✅ 已实施(11 Tasks:实体/DTO/迁移/Mapper/后端API/Web前端/小程序前端/文档) | LIFETIME 终身会员全栈实施计划(方案A:等级扩展+实时聚合+管理员审核) |
 | `health-data-center-implementation-plan.md` | ✅ 已实施(6 Tasks:身维度首页Tab改造/题库扩展/方案汇总页/后端CRUD/API注册/验证) | 健康数据中心实施计划(设计稿:2026-08-08-health-data-center-design.md) |
+| `2026-08-08-growth-intervention-center.md` | ✅ 已实施(迁移208-209/2实体/2Mapper/SurveyService+Impl/SurveyController/AdminSurveyController/调研问卷+历史页/growth-main重构/SurveyTemplates管理端) | 干预中心实施计划(设计稿:2026-08-08-growth-intervention-center-design.md) |
 
 ### 计划与设计文档(specs/)
 

+ 1 - 1
docs/superpowers/plans/2026-08-08-growth-intervention-center.md

@@ -1,7 +1,7 @@
 # 成长页面重构 — 干预中心实施计划
 
 **设计文档:** `specs/2026-08-08-growth-intervention-center-design.md`
-**状态:** in_progress
+**状态:** ✅ completed
 **预计工时:** 8-10小时
 
 ## 概述