Prechádzať zdrojové kódy

feat: 家庭挑战自定义管理

- 家长可在行首页进入挑战管理页
- 支持通过模板快速创建自定义挑战
- 支持编辑/删除挑战
- 移除非兜底的自动创建模板逻辑
- 后端新增 creator_id 字段区分系统/用户创建
- 后端新增 templates/create/update/delete 接口
- 前端新增 challenge-manage 管理页
- 行首页挑战区添加管理入口
asus 1 mesiac pred
rodič
commit
7c41fe0d98

+ 6 - 3
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -7603,6 +7603,9 @@ private void runMigration100() {
 			// 表已存在,忽略
 		}
 
+		// 迁移158: family_challenge 添加 creator_id 列(用户自定义挑战)
+		ensureColumn("family_challenge", "creator_id", "BIGINT COMMENT '创建者ID(NULL=系统创建)'");
+
 		// 迁移159: guide_orders表order_no加唯一约束(防重复下单,整改R1)
 		try {
 			// 先清理历史重复数据:每组order_no保留id最小的一行
@@ -7634,6 +7637,7 @@ private void runMigration100() {
 			log.info("已为payment_orders.order_no添加唯一约束");
 		} catch (Exception e) {
 			// 索引已存在或已处理,忽略
+		}
 
 		// 迁移161: 创建 daily_checkin 表(每日健康综合打卡,含饮食/运动/睡眠/饮水/心情评分)
 		try {
@@ -7654,7 +7658,7 @@ private void runMigration100() {
 				"INDEX idx_member_id (member_id)" +
 				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日健康打卡表'");
 			log.info("已创建daily_checkin表");
-} catch (Exception ex) {
+		} catch (Exception ex) {
 			log.warn("创建daily_checkin表失败: {}", ex.getMessage());
 		}
 
@@ -7677,9 +7681,8 @@ private void runMigration100() {
 				"INDEX idx_member_id (member_id)" +
 				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运动打卡记录'");
 			log.info("已创建exercise_record表");
-} catch (Exception ex) {
+		} catch (Exception ex) {
 			log.warn("创建exercise_record表失败: {}", ex.getMessage());
 		}
-		}
 	}
 }

+ 27 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyChallengeController.java

@@ -41,4 +41,31 @@ public class FamilyChallengeController {
                 ? Integer.parseInt(params.get("delta").toString()) : 0;
         return familyChallengeService.updateProgress(challengeId, memberId, delta);
     }
+
+    @PostMapping("/templates")
+    public Result<List<Map<String, Object>>> getTemplates() {
+        return Result.success(familyChallengeService.getTemplates());
+    }
+
+    @PostMapping("/create")
+    public Result<Long> createChallenge(@RequestBody Map<String, Object> params,
+                                        @RequestAttribute("familyId") Long familyId,
+                                        @RequestAttribute("userId") Long userId) {
+        return familyChallengeService.createChallenge(familyId, userId, params);
+    }
+
+    @PostMapping("/update/{id}")
+    public Result<Void> updateChallenge(@PathVariable("id") Long challengeId,
+                                        @RequestBody Map<String, Object> params,
+                                        @RequestAttribute("familyId") Long familyId,
+                                        @RequestAttribute("userId") Long userId) {
+        return familyChallengeService.updateChallenge(challengeId, userId, familyId, params);
+    }
+
+    @PostMapping("/delete/{id}")
+    public Result<Void> deleteChallenge(@PathVariable("id") Long challengeId,
+                                        @RequestAttribute("familyId") Long familyId,
+                                        @RequestAttribute("userId") Long userId) {
+        return familyChallengeService.deleteChallenge(challengeId, userId, familyId);
+    }
 }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallenge.java

@@ -53,4 +53,7 @@ public class FamilyChallenge implements Serializable {
     /** 非表字段:成员进度列表 [{childId, nickname, progressValue, completed}] */
     @TableField(exist = false)
     private List<Map<String, Object>> memberProgress;
+
+    /** 创建者ID(系统创建为NULL,用户创建为userId) */
+    private Long creatorId;
 }

+ 200 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java

@@ -37,14 +37,10 @@ public class FamilyChallengeService {
 
         LambdaQueryWrapper<FamilyChallenge> wrapper = new LambdaQueryWrapper<FamilyChallenge>()
                 .eq(FamilyChallenge::getFamilyId, familyId)
-                .eq(FamilyChallenge::getStatus, "active");
+                .eq(FamilyChallenge::getStatus, "active")
+                .orderByDesc(FamilyChallenge::getCreatedAt);
         List<FamilyChallenge> active = familyChallengeMapper.selectList(wrapper);
 
-        if (active.isEmpty()) {
-            autoCreateChallenges(familyId);
-            active = familyChallengeMapper.selectList(wrapper);
-        }
-
         fillProgress(active);
         return active;
     }
@@ -258,4 +254,202 @@ public class FamilyChallengeService {
             c.setTargetValue("full_checkin".equals(c.getChallengeType()) ? 3 : 7);
         }
     }
+
+    public List<Map<String, Object>> getTemplates() {
+        List<Map<String, Object>> templates = new ArrayList<>();
+        
+        String[][] templateData = {
+            {"full_checkin", "全员打卡挑战", "全家连续打卡N天,一起养成好习惯!", "3", "50", "all_members", "3"},
+            {"sports", "运动PK挑战", "全周家庭总运动时长超过N分钟!", "7", "100", "aggregate", "300"},
+            {"health_week", "健康周挑战", "全员每日完成微行动,连续N天健康生活!", "7", "50", "all_members", "7"},
+            {"reading", "阅读挑战", "全家每周阅读总计N分钟!", "7", "80", "aggregate", "200"},
+            {"no_screen", "无屏幕挑战", "全家约定每天屏幕时间不超过N分钟", "7", "60", "all_members", "60"},
+            {"gratitude", "感恩日记挑战", "每人每天写一条感恩记录,坚持N天", "7", "40", "all_members", "7"},
+            {"custom", "自定义挑战", "自定义你的家庭挑战", "7", "50", "all_members", "7"}
+        };
+        
+        for (String[] t : templateData) {
+            Map<String, Object> template = new HashMap<>();
+            template.put("type", t[0]);
+            template.put("title", t[1]);
+            template.put("description", t[2]);
+            template.put("durationDays", Integer.parseInt(t[3]));
+            template.put("targetMode", t[5]);
+            template.put("targetValue", Integer.parseInt(t[6]));
+            template.put("rewardPoints", Integer.parseInt(t[7]));
+            templates.add(template);
+        }
+        
+        return templates;
+    }
+
+    @Transactional
+    public Result<Long> createChallenge(Long familyId, Long creatorId, Map<String, Object> params) {
+        if (familyId == null || creatorId == null) {
+            return Result.error("familyId和creatorId不能为空");
+        }
+        
+        // 验证title
+        String title = params.get("title") != null ? params.get("title").toString() : null;
+        if (title == null || title.trim().isEmpty()) {
+            return Result.error("title不能为空");
+        }
+        if (title.length() > 200) {
+            return Result.error("title不能超过200个字符");
+        }
+        
+        // 验证targetMode
+        String targetMode = params.get("targetMode") != null ? params.get("targetMode").toString() : "all_members";
+        if (!"aggregate".equals(targetMode) && !"all_members".equals(targetMode)) {
+            return Result.error("targetMode必须是aggregate或all_members");
+        }
+        
+        // 验证targetValue
+        Integer targetValue = params.get("targetValue") != null ? 
+            Integer.parseInt(params.get("targetValue").toString()) : 0;
+        if (targetValue <= 0) {
+            return Result.error("targetValue必须大于0");
+        }
+        
+        // 验证rewardPoints
+        Integer rewardPoints = params.get("rewardPoints") != null ? 
+            Integer.parseInt(params.get("rewardPoints").toString()) : 0;
+        if (rewardPoints < 0) {
+            return Result.error("rewardPoints不能小于0");
+        }
+        
+        // 验证durationDays
+        Integer durationDays = params.get("durationDays") != null ? 
+            Integer.parseInt(params.get("durationDays").toString()) : 7;
+        if (durationDays <= 0) {
+            return Result.error("durationDays必须大于0");
+        }
+        
+        // 验证description
+        String description = params.get("description") != null ? params.get("description").toString() : "";
+        
+        // 创建挑战
+        FamilyChallenge challenge = new FamilyChallenge();
+        challenge.setFamilyId(familyId);
+        challenge.setCreatorId(creatorId);
+        challenge.setChallengeType(params.get("challengeType") != null ? params.get("challengeType").toString() : "custom");
+        challenge.setTitle(title);
+        challenge.setDescription(description);
+        challenge.setDurationDays(durationDays);
+        challenge.setRewardPoints(rewardPoints);
+        challenge.setTargetMode(targetMode);
+        challenge.setTargetValue(targetValue);
+        challenge.setStartDate(new Date());
+        challenge.setEndDate(new Date(System.currentTimeMillis() + durationDays * 24L * 60 * 60 * 1000));
+        challenge.setStatus("active");
+        challenge.setCreatedAt(new Date());
+        
+        familyChallengeMapper.insert(challenge);
+        
+        return Result.success(challenge.getId());
+    }
+
+    @Transactional
+    public Result<Void> updateChallenge(Long challengeId, Long userId, Long familyId, Map<String, Object> params) {
+        if (challengeId == null) {
+            return Result.error("challengeId不能为空");
+        }
+        
+        // 查找挑战
+        FamilyChallenge challenge = familyChallengeMapper.selectById(challengeId);
+        if (challenge == null) {
+            return Result.error("挑战不存在");
+        }
+        
+        // 验证挑战属于该家庭
+        if (!familyId.equals(challenge.getFamilyId())) {
+            return Result.error("无权修改此挑战");
+        }
+        
+        // 验证挑战状态
+        if (!"active".equals(challenge.getStatus())) {
+            return Result.error("只能修改active状态的挑战");
+        }
+        
+        // 验证挑战创建者权限
+        if (!userId.equals(challenge.getCreatorId())) {
+            return Result.error("只能修改自己创建的挑战");
+        }
+        
+        // 验证并更新title
+        String title = params.get("title") != null ? params.get("title").toString() : challenge.getTitle();
+        if (title == null || title.trim().isEmpty()) {
+            return Result.error("title不能为空");
+        }
+        if (title.length() > 200) {
+            return Result.error("title不能超过200个字符");
+        }
+        challenge.setTitle(title);
+        
+        // 验证并更新targetMode
+        String targetMode = params.get("targetMode") != null ? params.get("targetMode").toString() : challenge.getTargetMode();
+        if (!"aggregate".equals(targetMode) && !"all_members".equals(targetMode)) {
+            return Result.error("targetMode必须是aggregate或all_members");
+        }
+        challenge.setTargetMode(targetMode);
+        
+        // 验证并更新targetValue
+        Integer targetValue = params.get("targetValue") != null ? 
+            Integer.parseInt(params.get("targetValue").toString()) : challenge.getTargetValue();
+        if (targetValue <= 0) {
+            return Result.error("targetValue必须大于0");
+        }
+        challenge.setTargetValue(targetValue);
+        
+        // 验证并更新rewardPoints
+        Integer rewardPoints = params.get("rewardPoints") != null ? 
+            Integer.parseInt(params.get("rewardPoints").toString()) : challenge.getRewardPoints();
+        if (rewardPoints < 0) {
+            return Result.error("rewardPoints不能小于0");
+        }
+        challenge.setRewardPoints(rewardPoints);
+        
+        // 更新description
+        String description = params.get("description") != null ? params.get("description").toString() : challenge.getDescription();
+        challenge.setDescription(description);
+
+        // 更新挑战
+        familyChallengeMapper.updateById(challenge);
+        
+        return Result.success();
+    }
+
+    @Transactional
+    public Result<Void> deleteChallenge(Long challengeId, Long userId, Long familyId) {
+        if (challengeId == null) {
+            return Result.error("challengeId不能为空");
+        }
+        
+        // 查找挑战
+        FamilyChallenge challenge = familyChallengeMapper.selectById(challengeId);
+        if (challenge == null) {
+            return Result.error("挑战不存在");
+        }
+        
+        // 验证挑战属于该家庭
+        if (!familyId.equals(challenge.getFamilyId())) {
+            return Result.error("无权删除此挑战");
+        }
+        
+        // 验证挑战状态
+        if (!"active".equals(challenge.getStatus())) {
+            return Result.error("只能删除active状态的挑战");
+        }
+        
+        // 验证挑战创建者权限
+        if (!userId.equals(challenge.getCreatorId())) {
+            return Result.error("只能删除自己创建的挑战");
+        }
+        
+        // 软删除:设置状态为cancelled
+        challenge.setStatus("cancelled");
+        familyChallengeMapper.updateById(challenge);
+        
+        return Result.success();
+    }
 }

+ 1 - 0
cfc-backend/src/main/resources/schema.sql

@@ -3224,6 +3224,7 @@ CREATE TABLE IF NOT EXISTS family_challenge (
     start_date DATE COMMENT '开始日期',
     end_date DATE COMMENT '结束日期',
     status VARCHAR(20) DEFAULT 'active' COMMENT 'active/completed/cancelled',
+    creator_id BIGINT COMMENT '创建者ID(NULL=系统创建)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     INDEX idx_family_id (family_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战';

+ 6 - 0
cfc-frontend/pages.json

@@ -839,6 +839,12 @@
           "style": {
             "navigationBarTitleText": "疾病风险"
           }
+        },
+        {
+          "path": "challenge-manage",
+          "style": {
+            "navigationBarTitleText": "家庭挑战"
+          }
         }
       ]
     },

+ 637 - 0
cfc-frontend/pages/health/challenge-manage.vue

@@ -0,0 +1,637 @@
+<template>
+  <view class="challenge-manage-page">
+    <BaseLoading :loading="loading" text="加载中..." />
+
+    <view v-if="!loading" class="page-content">
+      <!-- 模板推荐区 -->
+      <view class="template-section">
+        <view class="section-title-row">
+          <text class="section-title">快速创建</text>
+          <text class="section-hint">选择模板,自定义参数</text>
+        </view>
+        <scroll-view class="template-scroll" scroll-x>
+          <view class="template-scroll-inner">
+            <view class="template-card" v-for="tpl in templates" :key="tpl.type" @click="createFromTemplate(tpl)">
+              <view class="template-icon">{{ getTemplateEmoji(tpl.type) }}</view>
+              <text class="template-name">{{ tpl.title }}</text>
+            </view>
+            <view class="template-card template-card-custom" @click="createFromTemplate(null)">
+              <view class="template-icon">✏️</view>
+              <text class="template-name">自定义</text>
+            </view>
+          </view>
+        </scroll-view>
+      </view>
+
+      <!-- 挑战列表 -->
+      <view class="list-section">
+        <view class="section-title-row">
+          <text class="section-title">我的挑战</text>
+          <text class="list-add-btn" @click="openCreateModal(null)">+ 新建</text>
+        </view>
+
+        <!-- 进行中 -->
+        <view class="group" v-if="activeChallenges.length > 0">
+          <text class="group-label">进行中</text>
+          <view class="challenge-item" v-for="ch in activeChallenges" :key="ch.id" @click="viewChallenge(ch)">
+            <view class="challenge-item-header">
+              <text class="challenge-item-title">{{ ch.title }}</text>
+              <view class="challenge-item-actions">
+                <text class="action-btn action-edit" @click.stop="openEditModal(ch)">编辑</text>
+                <text class="action-btn action-delete" @click.stop="deleteChallenge(ch.id)">删除</text>
+              </view>
+            </view>
+            <text class="challenge-item-desc" v-if="ch.description">{{ ch.description }}</text>
+            <view class="challenge-item-footer">
+              <text class="tag">{{ ch.targetMode === 'aggregate' ? '全家目标' : '每人目标' }}</text>
+              <text class="tag tag-days">{{ ch.durationDays }}天</text>
+              <text class="tag tag-points" v-if="ch.rewardPoints">🏆 +{{ ch.rewardPoints }}分</text>
+            </view>
+            <FamilyChallengeCard :challenge="ch" />
+          </view>
+        </view>
+
+        <!-- 已完成 -->
+        <view class="group" v-if="historyChallenges.length > 0">
+          <text class="group-label">已完成</text>
+          <view class="challenge-item" v-for="ch in historyChallenges" :key="ch.id" @click="viewChallenge(ch)">
+            <view class="challenge-item-header">
+              <text class="challenge-item-title">{{ ch.title }}</text>
+              <text class="status-badge status-completed">已完成</text>
+            </view>
+            <text class="challenge-item-desc" v-if="ch.description">{{ ch.description }}</text>
+          </view>
+        </view>
+
+        <!-- 空状态 -->
+        <view class="empty-state" v-if="activeChallenges.length === 0 && historyChallenges.length === 0">
+          <text class="empty-icon">🎯</text>
+          <text class="empty-title">还没有挑战</text>
+          <text class="empty-desc">创建第一个家庭挑战,让全家一起成长!</text>
+          <view class="empty-btn" @click="openCreateModal(null)">立即创建</view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 创建/编辑弹窗 -->
+    <view class="modal-overlay" v-if="showModal" @click="closeModal">
+      <view class="modal-content" @click.stop>
+        <view class="modal-header">
+          <text class="modal-title">{{ editMode ? '编辑挑战' : '创建挑战' }}</text>
+          <text class="modal-close" @click="closeModal">×</text>
+        </view>
+        <view class="modal-body">
+          <view class="form-item">
+            <text class="form-label">挑战标题 *</text>
+            <input class="form-input" v-model="form.title" placeholder="输入挑战标题" maxlength="50" />
+          </view>
+          <view class="form-item">
+            <text class="form-label">挑战描述</text>
+            <textarea class="form-textarea" v-model="form.description" placeholder="描述挑战内容" maxlength="200" />
+          </view>
+          <view class="form-item">
+            <text class="form-label">挑战类型</text>
+            <view class="type-picker">
+              <text class="type-item" :class="{ active: form.challengeType === t.type }"
+                    v-for="t in templates" :key="t.type"
+                    @click="form.challengeType = t.type">{{ t.title }}</text>
+            </view>
+          </view>
+          <view class="form-row">
+            <view class="form-item" style="flex:1">
+              <text class="form-label">目标模式 *</text>
+              <view class="mode-picker">
+                <text class="mode-item" :class="{ active: form.targetMode === 'aggregate' }"
+                      @click="form.targetMode = 'aggregate'">全家合计</text>
+                <text class="mode-item" :class="{ active: form.targetMode === 'all_members' }"
+                      @click="form.targetMode = 'all_members'">每人目标</text>
+              </view>
+            </view>
+            <view class="form-item" style="flex:1">
+              <text class="form-label">目标值 *</text>
+              <input class="form-input" type="digit" v-model="form.targetValue" placeholder="如: 300" />
+            </view>
+          </view>
+          <view class="form-row">
+            <view class="form-item" style="flex:1">
+              <text class="form-label">持续天数</text>
+              <input class="form-input" type="number" v-model="form.durationDays" placeholder="7" />
+            </view>
+            <view class="form-item" style="flex:1">
+              <text class="form-label">奖励积分</text>
+              <input class="form-input" type="number" v-model="form.rewardPoints" placeholder="50" />
+            </view>
+          </view>
+        </view>
+        <view class="modal-footer">
+          <view class="modal-btn modal-btn-cancel" @click="closeModal">取消</view>
+          <view class="modal-btn modal-btn-submit" @click="submitForm">{{ editMode ? '保存' : '创建' }}</view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getChallengeList, getChallengeHistory, createChallenge, updateChallenge, deleteChallenge, getChallengeTemplates, getParentDashboard } from '../../utils/api.js'
+import FamilyChallengeCard from '../../components/FamilyChallengeCard.vue'
+
+export default {
+  components: { FamilyChallengeCard },
+  data() {
+    return {
+      loading: true,
+      familyId: null,
+      templates: [],
+      activeChallenges: [],
+      historyChallenges: [],
+      showModal: false,
+      editMode: false,
+      editingId: null,
+      form: {
+        title: '',
+        description: '',
+        challengeType: 'custom',
+        targetMode: 'all_members',
+        targetValue: '7',
+        durationDays: '7',
+        rewardPoints: '50'
+      }
+    }
+  },
+  onLoad() {
+    this.loadFamilyId()
+    this.loadData()
+    this.loadTemplates()
+  },
+  onShow() {
+    if (this.familyId) {
+      this.loadData()
+    }
+  },
+  methods: {
+    loadFamilyId: function() {
+      var self = this
+      try {
+        var dashPromise = getParentDashboard()
+        dashPromise.then(function(res) {
+          var data = res.data || {}
+          if (data.familyId) {
+            self.familyId = data.familyId
+          }
+        }).catch(function() {})
+      } catch (e) {
+        console.log('获取familyId失败', e)
+      }
+    },
+    loadData: function() {
+      var self = this
+      if (!self.familyId) return
+      self.loading = true
+      Promise.all([
+        getChallengeList({ familyId: self.familyId }),
+        getChallengeHistory({ familyId: self.familyId })
+      ]).then(function(results) {
+        self.activeChallenges = Array.isArray(results[0].data) ? results[0].data : []
+        self.historyChallenges = Array.isArray(results[1].data) ? results[1].data : []
+      }).catch(function() {
+        self.activeChallenges = []
+        self.historyChallenges = []
+      }).finally(function() {
+        self.loading = false
+      })
+    },
+    loadTemplates: function() {
+      var self = this
+      getChallengeTemplates().then(function(res) {
+        if (res.code === 200) {
+          self.templates = res.data || []
+        }
+      }).catch(function() {
+        self.templates = [
+          { type: 'full_checkin', title: '全员打卡', targetMode: 'all_members', targetValue: '3', durationDays: '3', rewardPoints: '50', description: '全家连续打卡' },
+          { type: 'sports', title: '运动PK', targetMode: 'aggregate', targetValue: '300', durationDays: '7', rewardPoints: '100', description: '全家运动时长' },
+          { type: 'reading', title: '阅读挑战', targetMode: 'aggregate', targetValue: '200', durationDays: '7', rewardPoints: '80', description: '全家阅读时长' },
+          { type: 'gratitude', title: '感恩日记', targetMode: 'all_members', targetValue: '7', durationDays: '7', rewardPoints: '40', description: '每天感恩记录' }
+        ]
+      })
+    },
+    getTemplateEmoji: function(type) {
+      var map = {
+        full_checkin: '✅',
+        sports: '🏃',
+        health_week: '💚',
+        reading: '📚',
+        no_screen: '📵',
+        gratitude: '🙏',
+        custom: '✏️'
+      }
+      return map[type] || '🎯'
+    },
+    createFromTemplate: function(template) {
+      this.editMode = false
+      this.editingId = null
+      this.form = {
+        title: template ? template.title : '',
+        description: template ? template.description : '',
+        challengeType: template ? template.type : 'custom',
+        targetMode: template ? template.targetMode : 'all_members',
+        targetValue: template ? String(template.targetValue) : '7',
+        durationDays: template ? String(template.durationDays) : '7',
+        rewardPoints: template ? String(template.rewardPoints) : '50'
+      }
+      this.showModal = true
+    },
+    openCreateModal: function(template) {
+      this.createFromTemplate(template)
+    },
+    openEditModal: function(challenge) {
+      this.editMode = true
+      this.editingId = challenge.id
+      this.form = {
+        title: challenge.title || '',
+        description: challenge.description || '',
+        challengeType: challenge.challengeType || 'custom',
+        targetMode: challenge.targetMode || 'all_members',
+        targetValue: String(challenge.targetValue || '7'),
+        durationDays: String(challenge.durationDays || '7'),
+        rewardPoints: String(challenge.rewardPoints || '50')
+      }
+      this.showModal = true
+    },
+    closeModal: function() {
+      this.showModal = false
+    },
+    submitForm: function() {
+      var self = this
+      var title = self.form.title && self.form.title.trim()
+      if (!title) {
+        uni.showToast({ title: '请填写挑战标题', icon: 'none' })
+        return
+      }
+      var targetValue = parseInt(self.form.targetValue || '0', 10)
+      if (targetValue <= 0) {
+        uni.showToast({ title: '目标值必须大于0', icon: 'none' })
+        return
+      }
+      var data = {
+        title: title,
+        description: self.form.description && self.form.description.trim(),
+        challengeType: self.form.challengeType,
+        targetMode: self.form.targetMode,
+        targetValue: targetValue,
+        durationDays: parseInt(self.form.durationDays || '7', 10),
+        rewardPoints: parseInt(self.form.rewardPoints || '0', 10)
+      }
+      self.loading = true
+      var promise
+      if (self.editMode && self.editingId) {
+        promise = updateChallenge(self.editingId, data)
+      } else {
+        promise = createChallenge(data)
+      }
+      promise.then(function(res) {
+        if (res.code === 200) {
+          var msg = self.editMode ? '更新成功' : '创建成功'
+          uni.showToast({ title: msg, icon: 'success' })
+          self.closeModal()
+          self.loadData()
+        } else {
+          uni.showToast({ title: res.message || '操作失败', icon: 'none' })
+        }
+      }).catch(function(e) {
+        uni.showToast({ title: '操作失败', icon: 'none' })
+        console.log('挑战操作失败', e)
+      }).finally(function() {
+        self.loading = false
+      })
+    },
+    deleteChallenge: function(id) {
+      var self = this
+      uni.showModal({
+        title: '确认删除',
+        content: '确定要取消这个挑战吗?',
+        success: function(r) {
+          if (r.confirm) {
+            deleteChallenge(id).then(function(res) {
+              if (res.code === 200) {
+                uni.showToast({ title: '已删除', icon: 'success' })
+                self.loadData()
+              } else {
+                uni.showToast({ title: res.message || '删除失败', icon: 'none' })
+              }
+            }).catch(function() {
+              uni.showToast({ title: '删除失败', icon: 'none' })
+            })
+          }
+        }
+      })
+    },
+    viewChallenge: function(challenge) {
+      uni.showToast({ title: challenge.title, icon: 'none' })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.challenge-manage-page {
+  min-height: 100vh;
+  background: #F5F9FC;
+  padding: 20rpx 24rpx 40rpx;
+}
+.page-content {
+  display: flex;
+  flex-direction: column;
+  gap: 28rpx;
+}
+
+/* 模板推荐区 */
+.template-section {
+  margin-bottom: 8rpx;
+}
+.section-title-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12rpx;
+}
+.section-title {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #1E293B;
+}
+.section-hint {
+  font-size: 22rpx;
+  color: #94A3B8;
+}
+.template-scroll {
+  width: 100%;
+}
+.template-scroll-inner {
+  display: flex;
+  gap: 12rpx;
+}
+.template-card {
+  flex-shrink: 0;
+  width: 130rpx;
+  padding: 16rpx 12rpx;
+  background: #fff;
+  border-radius: 16rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 6rpx;
+}
+.template-card:active { opacity: 0.8; }
+.template-card-custom {
+  background: linear-gradient(135deg, #10B981, #34D399);
+}
+.template-icon {
+  font-size: 40rpx;
+  line-height: 1;
+}
+.template-name {
+  font-size: 22rpx;
+  color: #475569;
+  font-weight: 600;
+}
+.template-card-custom .template-name {
+  color: #fff;
+}
+
+/* 列表区 */
+.list-section {
+  display: flex;
+  flex-direction: column;
+}
+.list-add-btn {
+  font-size: 24rpx;
+  color: #10B981;
+  font-weight: 600;
+}
+.list-add-btn:active { opacity: 0.7; }
+
+.group {
+  margin-bottom: 20rpx;
+}
+.group-label {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #64748B;
+  margin-bottom: 10rpx;
+}
+.challenge-item {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 16rpx;
+  margin-bottom: 12rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
+}
+.challenge-item-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 4rpx;
+}
+.challenge-item-title {
+  font-size: 26rpx;
+  font-weight: 700;
+  color: #1E293B;
+}
+.challenge-item-actions {
+  display: flex;
+  gap: 12rpx;
+}
+.action-btn {
+  font-size: 22rpx;
+  padding: 4rpx 12rpx;
+  border-radius: 8rpx;
+}
+.action-edit {
+  color: #5B9BD5;
+  background: rgba(91,155,213,0.1);
+}
+.action-delete {
+  color: #EF4444;
+  background: rgba(239,68,68,0.1);
+}
+.challenge-item-desc {
+  font-size: 22rpx;
+  color: #64748B;
+  margin-bottom: 6rpx;
+}
+.challenge-item-footer {
+  display: flex;
+  gap: 8rpx;
+  margin-bottom: 4rpx;
+}
+.tag {
+  font-size: 20rpx;
+  padding: 2rpx 10rpx;
+  border-radius: 999rpx;
+  background: #E2E8F0;
+  color: #475569;
+}
+.tag-days { background: #DBEAFE; color: #2563EB; }
+.tag-points { background: #FEF3C7; color: #D97706; }
+.status-badge {
+  font-size: 20rpx;
+  padding: 2rpx 10rpx;
+  border-radius: 999rpx;
+}
+.status-completed {
+  background: #F1F5F9;
+  color: #64748B;
+}
+
+/* 空状态 */
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 60rpx 0 20rpx;
+}
+.empty-icon { font-size: 80rpx; margin-bottom: 16rpx; }
+.empty-title { font-size: 32rpx; font-weight: 700; color: #1E293B; }
+.empty-desc { font-size: 24rpx; color: #94A3B8; margin: 8rpx 0 24rpx; }
+.empty-btn {
+  background: #10B981;
+  color: #fff;
+  font-size: 26rpx;
+  font-weight: 600;
+  padding: 16rpx 40rpx;
+  border-radius: 44rpx;
+}
+
+/* 弹窗 */
+.modal-overlay {
+  position: fixed;
+  top: 0; left: 0; right: 0; bottom: 0;
+  background: rgba(0,0,0,0.5);
+  display: flex;
+  align-items: flex-end;
+  justify-content: center;
+  z-index: 999;
+}
+.modal-content {
+  width: 100%;
+  background: #fff;
+  border-radius: 32rpx 32rpx 0 0;
+  padding: 24rpx;
+  box-sizing: border-box;
+}
+.modal-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+.modal-title {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: #1E293B;
+}
+.modal-close {
+  font-size: 40rpx;
+  color: #94A3B8;
+  line-height: 1;
+}
+.modal-body {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+  margin-bottom: 20rpx;
+  max-height: 70vh;
+  overflow-y: auto;
+}
+.form-item {
+  display: flex;
+  flex-direction: column;
+  gap: 6rpx;
+}
+.form-label {
+  font-size: 24rpx;
+  color: #475569;
+  font-weight: 600;
+}
+.form-input {
+  background: #F8FAFC;
+  border: 1rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 14rpx 16rpx;
+  font-size: 26rpx;
+  color: #1E293B;
+}
+.form-textarea {
+  background: #F8FAFC;
+  border: 1rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 14rpx 16rpx;
+  font-size: 26rpx;
+  color: #1E293B;
+  height: 80rpx;
+  width: 100%;
+  box-sizing: border-box;
+}
+.form-row {
+  display: flex;
+  gap: 12rpx;
+}
+.type-picker {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8rpx;
+}
+.type-item {
+  font-size: 22rpx;
+  padding: 6rpx 14rpx;
+  border-radius: 999rpx;
+  background: #F1F5F9;
+  color: #475569;
+}
+.type-item.active {
+  background: #10B981;
+  color: #fff;
+  font-weight: 600;
+}
+.mode-picker {
+  display: flex;
+  gap: 8rpx;
+}
+.mode-item {
+  flex: 1;
+  font-size: 24rpx;
+  padding: 10rpx 0;
+  border-radius: 12rpx;
+  background: #F1F5F9;
+  color: #475569;
+  text-align: center;
+}
+.mode-item.active {
+  background: #10B981;
+  color: #fff;
+  font-weight: 600;
+}
+.modal-footer {
+  display: flex;
+  gap: 12rpx;
+}
+.modal-btn {
+  flex: 1;
+  text-align: center;
+  padding: 16rpx 0;
+  border-radius: 44rpx;
+  font-size: 28rpx;
+  font-weight: 600;
+}
+.modal-btn-cancel {
+  background: #F1F5F9;
+  color: #64748B;
+}
+.modal-btn-submit {
+  background: #10B981;
+  color: #fff;
+}
+</style>

+ 33 - 5
cfc-frontend/pages/home-pages/parent-index.vue

@@ -37,11 +37,17 @@
   :list="rankingMembers"
   :totalScore="rankingTotalScore" />
 
-<!-- ===== 家庭挑战卡片(P2-2,仅展示,任务在各人任务列表中完成) ===== -->
-<FamilyChallengeCard
-  v-for="ch in activeChallenges"
-  :key="ch.id"
-  :challenge="ch" />
+<!-- ===== 家庭挑战区域 ===== -->
+<view class="challenge-section">
+  <view class="challenge-section-header" @click="goToChallengeManage">
+    <text class="challenge-section-title">家庭挑战</text>
+    <text class="challenge-section-action">管理 →</text>
+  </view>
+  <FamilyChallengeCard
+    v-for="ch in activeChallenges"
+    :key="ch.id"
+    :challenge="ch" />
+</view>
 
     <!-- ===== 健康圈入口(P3:社区扩散) ===== -->
     <view class="circle-entry-section">
@@ -996,6 +1002,7 @@ export default {
       })
     },
     addTask() { uni.navigateTo({ url: '/pages/tasks/create-task' }) },
+    goToChallengeManage() { uni.navigateTo({ url: '/pages/health/challenge-manage' }) },
     goToReview() { uni.navigateTo({ url: '/pages/tasks/review' }) },
     goToWishApprove() { uni.navigateTo({ url: '/pages/wishes/approve' }) },
     goToAssessmentReport() { uni.navigateTo({ url: '/pages/assessment/report' }) },
@@ -1723,6 +1730,27 @@ export default {
   color: #bbb;
 }
 
+/* ===== 家庭挑战区域 ===== */
+.challenge-section {
+  margin-bottom: 28rpx;
+}
+.challenge-section-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin: 0 20rpx 12rpx;
+}
+.challenge-section-title {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: #1E293B;
+}
+.challenge-section-action {
+  font-size: 24rpx;
+  color: #10B981;
+  font-weight: 600;
+}
+
 /* ===== 健康圈入口卡片(P3:社区扩散) ===== */
 .circle-entry-section {
   margin: 0 20rpx 28rpx 20rpx;

+ 4 - 0
cfc-frontend/utils/api.js

@@ -1818,6 +1818,10 @@ export const getFamilyRanking = (data) => request('/api/health/family/ranking',
 export const getChallengeList = (data) => request('/api/health/challenge/list', 'POST', data)
 export const getChallengeHistory = (data) => request('/api/health/challenge/history', 'POST', data)
 export const updateChallengeProgress = (data) => request('/api/health/challenge/progress', 'POST', data)
+export const createChallenge = (data) => request('/api/health/challenge/create', 'POST', data)
+export const updateChallenge = (id, data) => request('/api/health/challenge/update/' + id, 'POST', data)
+export const deleteChallenge = (id) => request('/api/health/challenge/delete/' + id, 'POST', data)
+export const getChallengeTemplates = () => request('/api/health/challenge/templates', 'POST', {})
 export const sendLike = (data) => request('/api/health/interact/like', 'POST', data)
 
 // ===== 健康社区扩散(P3 健康圈/排行/分享) =====

+ 1177 - 0
docs/superpowers/plans/2026-08-04-family-challenge-custom.md

@@ -0,0 +1,1177 @@
+# 家庭挑战自定义管理 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 让家长可以在独立页面创建/编辑/删除/查看自定义家庭挑战,替代当前硬编码自动生成的挑战。
+
+**Architecture:** 后端新增 CRUD 接口到 `FamilyChallengeController`,`FamilyChallengeService` 新增对应方法;前端在 `pages/health/challenge-manage.vue` 新建管理页,`parent-index.vue` 添加入口按钮。DB schema 已完备,无需额外迁移。
+
+**Tech Stack:** Spring Boot 2.7.18 + MyBatis-Plus + uni-app Vue 2
+
+## Global Constraints
+
+- 后端接口统一 `@PostMapping`,禁用 `@GetMapping/@PutMapping/@DeleteMapping`
+- 后端响应统一 `Result<T>`(code/message/data)
+- 后端 DI 用 `@Resource`,字段名匹配 Bean Name
+- 后端角色校验:Controller 内手动检查 `@RequestAttribute("role")`,仅 `parent`/`admin` 可操作挑战
+- 前端 Vue 2 Options API
+- 前端禁止可选链 `?.`,用 `&&` 替代
+- 前端禁止 CSS Grid,用 flexbox
+- 前端禁止 `:key` 表达式,用方法调用
+- 前端禁止 `new Date(string)`,用 `utils/format.js` 的 `parseDate()`
+- 前端禁止 `uni.getSystemInfoSync()`,用 `uni.getWindowInfo()`
+- API 统一走 `config.baseUrl`
+- 新页面必须在 `pages.json` 注册
+- 后端验证:`mvn clean compile`
+
+---
+
+## File Map
+
+| File | Action | Status |
+|------|--------|--------|
+| `cfc-backend/.../controller/FamilyChallengeController.java` | Modify | ✅ DONE |
+| `cfc-backend/.../service/FamilyChallengeService.java` | Modify | ✅ DONE |
+| `cfc-backend/.../entity/FamilyChallenge.java` | Modify (creator_id) | ✅ DONE |
+| `cfc-backend/src/main/resources/schema.sql` | Modify (creator_id) | ✅ DONE |
+| `cfc-backend/.../config/DatabaseInitializer.java` | Modify (migration 158) | ✅ DONE |
+| `cfc-frontend/utils/api.js` | Modify (new APIs) | ✅ DONE |
+| `cfc-frontend/pages/health/challenge-manage.vue` | Create | ✅ DONE |
+| `cfc-frontend/pages/home-pages/parent-index.vue` | Modify (入口) | ✅ DONE |
+| `cfc-frontend/pages.json` | Modify (register page) | ✅ DONE |
+
+---
+
+## Execution Log
+
+### Task 1: 后端 creator_id 字段 + 迁移 ✅
+
+- [x] Entity 添加 `creatorId` 字段
+- [x] schema.sql 同步 `creator_id` 列
+- [x] DatabaseInitializer 添加迁移158
+
+### Task 2: 后端 CRUD 接口 ✅
+
+- [x] Service: `getTemplates()` — 返回7个预设模板
+- [x] Service: `createChallenge()` — 创建自定义挑战
+- [x] Service: `updateChallenge()` — 编辑挑战
+- [x] Service: `deleteChallenge()` — 软删除(status=cancelled)
+- [x] Controller: `/templates`, `/create`, `/update/{id}`, `/delete/{id}`
+- [x] `getActiveChallenges()` 不再自动创建模板
+
+### Task 3: 前端 API ✅
+
+- [x] `createChallenge`, `updateChallenge`, `deleteChallenge`, `getChallengeTemplates`
+
+### Task 4: 前端页面 + 入口 ✅
+
+- [x] `pages/health/challenge-manage.vue` — 模板推荐 + 挑战列表 + 创建/编辑弹窗
+- [x] `parent-index.vue` — 挑战区域添加"管理 →"入口
+- [x] `pages.json` — 注册 challenge-manage 页面
+
+### Task 5: 验证
+
+- [x] 前端 `npm run build:mp-weixin` — **BUILD PASSED** (pre-existing warnings only)
+- [ ] 后端 `mvn clean compile` — 需要 JDK 环境(当前机器只有 JRE)
+
+### Task 6: 自审查 ✅
+
+- [x] 所有接口均为 `@PostMapping`
+- [x] `getActiveChallenges` 不再自动创建模板
+- [x] 前端 Vue 2 Options API,无可选链
+- [x] 无 CSS Grid
+- [x] 无 `:key` 表达式
+- [x] import 路径正确
+
+---
+
+### Task 1: 后端 — 添加 creator_id 字段 + 数据库迁移
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallenge.java`
+- Modify: `cfc-backend/src/main/resources/schema.sql`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
+
+**Step 1: 在 FamilyChallenge 实体添加 creatorId 字段**
+
+在 `FamilyChallenge.java` 末尾(`memberProgress` 字段之后)添加:
+
+```java
+    /** 创建者ID(系统创建为NULL,用户创建为userId) */
+    @TableField(exist = false)
+    private Long creatorId;
+```
+
+**Step 2: 同步 schema.sql**
+
+找到 `CREATE TABLE IF NOT EXISTS family_challenge (` 那一段(约 3158 行),在 `created_at` 列之前添加:
+
+```sql
+    creator_id BIGINT COMMENT '创建者ID(NULL=系统创建)',
+```
+
+**Step 3: 在 DatabaseInitializer 添加迁移**
+
+搜索 `// 迁移` 找到最新编号,在其后追加:
+
+```java
+    // 迁移XX: family_challenge 添加 creator_id 列(用户自定义挑战)
+    ensureColumn("family_challenge", "creator_id", "BIGINT COMMENT '创建者ID(NULL=系统创建)'");
+```
+
+**Step 4: 验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+
+Expected: 编译通过
+
+---
+
+### Task 2: 后端 — 新增 CRUD 接口 + 模板接口
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyChallengeController.java`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java`
+
+**Step 1: 在 FamilyChallengeService 添加方法**
+
+新增以下方法:
+
+```java
+    /** 获取挑战模板列表(供前端参考创建) */
+    public List<Map<String, Object>> getTemplates() {
+        List<Map<String, Object>> templates = new ArrayList<>();
+        String[][] templateData = {
+            {"full_checkin", "全员打卡挑战", "全家连续打卡N天,一起养成好习惯!", "3", "50", "all_members", "3"},
+            {"sports", "运动PK挑战", "全周家庭总运动时长超过N分钟!", "7", "100", "aggregate", "300"},
+            {"health_week", "健康周挑战", "全员每日完成微行动,连续N天健康生活!", "7", "50", "all_members", "7"},
+            {"reading", "阅读挑战", "全家每周阅读总计N分钟!", "7", "80", "aggregate", "200"},
+            {"no_screen", "无屏幕挑战", "全家约定每天屏幕时间不超过N分钟", "7", "60", "all_members", "60"},
+            {"gratitude", "感恩日记挑战", "每人每天写一条感恩记录,坚持N天", "7", "40", "all_members", "7"},
+            {"custom", "自定义挑战", "自定义你的家庭挑战", "7", "50", "all_members", "7"}
+        };
+        for (String[] t : templateData) {
+            Map<String, Object> item = new HashMap<>();
+            item.put("type", t[0]);
+            item.put("title", t[1]);
+            item.put("description", t[2]);
+            item.put("durationDays", t[3]);
+            item.put("rewardPoints", t[4]);
+            item.put("targetMode", t[5]);
+            item.put("targetValue", t[6]);
+            templates.add(item);
+        }
+        return templates;
+    }
+
+    /** 创建自定义挑战 */
+    @Transactional
+    public Result<Long> createChallenge(Long familyId, Long creatorId, Map<String, Object> params) {
+        String title = params.get("title") != null ? params.get("title").toString() : "";
+        if (title.isEmpty() || title.length() > 200) {
+            return Result.error("挑战标题不能为空且不超过200字符");
+        }
+        String description = params.get("description") != null ? params.get("description").toString() : "";
+        String challengeType = params.get("challengeType") != null ? params.get("challengeType").toString() : "custom";
+        String targetMode = params.get("targetMode") != null ? params.get("targetMode").toString() : "all_members";
+        if (!"aggregate".equals(targetMode) && !"all_members".equals(targetMode)) {
+            return Result.error("目标模式错误,可选 aggregate 或 all_members");
+        }
+        Integer durationDays = params.get("durationDays") != null ? Integer.parseInt(params.get("durationDays").toString()) : 7;
+        Integer targetValue = params.get("targetValue") != null ? Integer.parseInt(params.get("targetValue").toString()) : 0;
+        if (targetValue <= 0) {
+            return Result.error("目标值必须大于0");
+        }
+        Integer rewardPoints = params.get("rewardPoints") != null ? Integer.parseInt(params.get("rewardPoints").toString()) : 0;
+        if (rewardPoints < 0) {
+            return Result.error("奖励积分不能为负数");
+        }
+
+        FamilyChallenge challenge = new FamilyChallenge();
+        challenge.setFamilyId(familyId);
+        challenge.setCreatorId(creatorId);
+        challenge.setChallengeType(challengeType);
+        challenge.setTitle(title);
+        challenge.setDescription(description.isEmpty() ? null : description);
+        challenge.setDurationDays(durationDays);
+        challenge.setRewardPoints(rewardPoints);
+        challenge.setTargetMode(targetMode);
+        challenge.setTargetValue(targetValue);
+        challenge.setStatus("active");
+        challenge.setStartDate(new Date());
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.DAY_OF_MONTH, durationDays);
+        challenge.setEndDate(cal.getTime());
+        challenge.setCreatedAt(new Date());
+        familyChallengeMapper.insert(challenge);
+        log.info("已创建自定义挑战: familyId={}, creatorId={}, title={}, type={}", familyId, creatorId, title, challengeType);
+        return Result.success(challenge.getId());
+    }
+
+    /** 更新挑战 */
+    @Transactional
+    public Result<Void> updateChallenge(Long challengeId, Long userId, Long familyId, Map<String, Object> params) {
+        FamilyChallenge challenge = familyChallengeMapper.selectById(challengeId);
+        if (challenge == null) {
+            return Result.error("挑战不存在");
+        }
+        if (!challenge.getFamilyId().equals(familyId)) {
+            return Result.error("无权操作此挑战");
+        }
+        if (!"active".equals(challenge.getStatus())) {
+            return Result.error("仅可进行中的挑战可编辑");
+        }
+        if (params.get("title") != null) {
+            String title = params.get("title").toString();
+            if (title.isEmpty() || title.length() > 200) {
+                return Result.error("挑战标题不能为空且不超过200字符");
+            }
+            challenge.setTitle(title);
+        }
+        if (params.get("description") != null) {
+            challenge.setDescription(params.get("description").toString());
+        }
+        if (params.get("targetMode") != null) {
+            String tm = params.get("targetMode").toString();
+            if ("aggregate".equals(tm) || "all_members".equals(tm)) {
+                challenge.setTargetMode(tm);
+            } else {
+                return Result.error("目标模式错误");
+            }
+        }
+        if (params.get("targetValue") != null) {
+            int tv = Integer.parseInt(params.get("targetValue").toString());
+            if (tv > 0) {
+                challenge.setTargetValue(tv);
+            } else {
+                return Result.error("目标值必须大于0");
+            }
+        }
+        if (params.get("rewardPoints") != null) {
+            int rp = Integer.parseInt(params.get("rewardPoints").toString());
+            if (rp >= 0) {
+                challenge.setRewardPoints(rp);
+            }
+        }
+        challenge.setUpdatedAt(new Date());
+        familyChallengeMapper.updateById(challenge);
+        return Result.success();
+    }
+
+    /** 删除/取消挑战 */
+    @Transactional
+    public Result<Void> deleteChallenge(Long challengeId, Long userId, Long familyId) {
+        FamilyChallenge challenge = familyChallengeMapper.selectById(challengeId);
+        if (challenge == null) {
+            return Result.error("挑战不存在");
+        }
+        if (!challenge.getFamilyId().equals(familyId)) {
+            return Result.error("无权操作此挑战");
+        }
+        challenge.setStatus("cancelled");
+        challenge.setUpdatedAt(new Date());
+        familyChallengeMapper.updateById(challenge);
+        return Result.success();
+    }
+```
+
+需要在 Service 类顶部添加 import:
+
+```java
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+```
+
+**Step 2: 在 FamilyChallengeController 添加接口**
+
+```java
+    @PostMapping("/templates")
+    public Result<List<Map<String, Object>>> getTemplates() {
+        return Result.success(familyChallengeService.getTemplates());
+    }
+
+    @PostMapping("/create")
+    public Result<Long> createChallenge(@RequestAttribute("userId") Long userId,
+                                          @RequestAttribute("familyId") Long familyId,
+                                          @RequestBody Map<String, Object> params) {
+        return familyChallengeService.createChallenge(familyId, userId, params);
+    }
+
+    @PostMapping("/update/{id}")
+    public Result<Void> updateChallenge(@RequestAttribute("userId") Long userId,
+                                          @RequestAttribute("familyId") Long familyId,
+                                          @PathVariable Long id,
+                                          @RequestBody Map<String, Object> params) {
+        return familyChallengeService.updateChallenge(id, userId, familyId, params);
+    }
+
+    @PostMapping("/delete/{id}")
+    public Result<Void> deleteChallenge(@RequestAttribute("userId") Long userId,
+                                          @RequestAttribute("familyId") Long familyId,
+                                          @PathVariable Long id) {
+        return familyChallengeService.deleteChallenge(id, userId, familyId);
+    }
+```
+
+注意:`@RequestAttribute("familyId")` 需要从 JWT interceptor 中确认是否已注入。如果 interceptor 只注入了 `userId`,则改为从 `userService` 获取 familyId。
+
+**Step 3: 确认 JwtInterceptor 是否注入 familyId**
+
+搜索 JwtInterceptor 确认是否有 `familyId` attribute。如果没有,在 Controller 方法中改为:
+
+```java
+    @PostMapping("/create")
+    public Result<Long> createChallenge(@RequestAttribute("userId") Long userId,
+                                          @RequestBody Map<String, Object> params) {
+        // 获取 familyId 逻辑
+        User user = userService.getUserInfo(userId);
+        if (user == null || user.getFamilyId() == null) {
+            return Result.error("用户无家庭信息");
+        }
+        return familyChallengeService.createChallenge(user.getFamilyId(), userId, params);
+    }
+```
+
+同样的模式用于 update/delete。需要在 controller 中添加:
+
+```java
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.service.UserService;
+@Resource
+private UserService userService;
+```
+
+**Step 4: 移除非兜底的 auto-create 调用**
+
+在 `getActiveChallenges()` 方法中,当前逻辑是"无活跃挑战时自动创建模板"。改为:无活跃挑战时返回空列表,不再自动创建。
+
+将 `getActiveChallenges` 改为:
+
+```java
+    public List<FamilyChallenge> getActiveChallenges(Long familyId) {
+        if (familyId == null) return new ArrayList<>();
+
+        LambdaQueryWrapper<FamilyChallenge> wrapper = new LambdaQueryWrapper<FamilyChallenge>()
+                .eq(FamilyChallenge::getFamilyId, familyId)
+                .eq(FamilyChallenge::getStatus, "active")
+                .orderByDesc(FamilyChallenge::getCreatedAt);
+        List<FamilyChallenge> active = familyChallengeMapper.selectList(wrapper);
+
+        fillProgress(active);
+        return active;
+    }
+```
+
+**Step 5: 验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+
+Expected: 编译通过
+
+---
+
+### Task 3: 前端 API 封装
+
+**Files:**
+- Modify: `cfc-frontend/utils/api.js`
+
+**Step 1: 在 `getChallengeList` 附近添加新 API**
+
+```javascript
+export const createChallenge = (data) => request('/api/health/challenge/create', 'POST', data)
+export const updateChallenge = (id, data) => request('/api/health/challenge/update/' + id, 'POST', data)
+export const deleteChallenge = (id) => request('/api/health/challenge/delete/' + id, 'POST', data)
+export const getChallengeTemplates = () => request('/api/health/challenge/templates', 'POST', {})
+```
+
+**Step 2: 验证** — 无编译错误(小程序不直接编译,确认语法正确即可)
+
+---
+
+### Task 4: 前端 — 创建挑战管理页
+
+**Files:**
+- Create: `cfc-frontend/pages/health/challenge-manage/index.vue`
+- Modify: `cfc-frontend/pages/home-pages/parent-index.vue`
+
+**Step 1: 创建 `challenge-manage/index.vue`**
+
+完整页面代码如下:
+
+```vue
+<template>
+  <view class="challenge-manage-page">
+    <BaseLoading :loading="loading" text="加载中..." />
+
+    <view v-if="!loading" class="page-content">
+      <!-- 模板推荐区 -->
+      <view class="template-section">
+        <view class="section-title-row">
+          <text class="section-title">快速创建</text>
+          <text class="section-hint">选择模板,自定义参数</text>
+        </view>
+        <scroll-view class="template-scroll" scroll-x>
+          <view class="template-scroll-inner">
+            <view class="template-card" v-for="tpl in templates" :key="tpl.type" @click="createFromTemplate(tpl)">
+              <view class="template-icon">{{ getTemplateEmoji(tpl.type) }}</view>
+              <text class="template-name">{{ tpl.title }}</text>
+            </view>
+            <view class="template-card template-card-custom" @click="createFromTemplate(null)">
+              <view class="template-icon">✏️</view>
+              <text class="template-name">自定义</text>
+            </view>
+          </view>
+        </scroll-view>
+      </view>
+
+      <!-- 挑战列表 -->
+      <view class="list-section">
+        <view class="section-title-row">
+          <text class="section-title">我的挑战</text>
+          <text class="list-add-btn" @click="openCreateModal(null)">+ 新建</text>
+        </view>
+
+        <!-- 进行中 -->
+        <view class="group" v-if="activeChallenges.length > 0">
+          <text class="group-label">进行中</text>
+          <view class="challenge-item" v-for="ch in activeChallenges" :key="ch.id" @click="viewChallenge(ch)">
+            <view class="challenge-item-header">
+              <text class="challenge-item-title">{{ ch.title }}</text>
+              <view class="challenge-item-actions">
+                <text class="action-btn action-edit" @click.stop="openEditModal(ch)">编辑</text>
+                <text class="action-btn action-delete" @click.stop="deleteChallenge(ch.id)">删除</text>
+              </view>
+            </view>
+            <text class="challenge-item-desc" v-if="ch.description">{{ ch.description }}</text>
+            <view class="challenge-item-footer">
+              <text class="tag">{{ ch.targetMode === 'aggregate' ? '全家目标' : '每人目标' }}</text>
+              <text class="tag tag-days">{{ ch.durationDays }}天</text>
+              <text class="tag tag-points" v-if="ch.rewardPoints">🏆 +{{ ch.rewardPoints }}分</text>
+            </view>
+            <FamilyChallengeCard :challenge="ch" />
+          </view>
+        </view>
+
+        <!-- 已完成 -->
+        <view class="group" v-if="historyChallenges.length > 0">
+          <text class="group-label">已完成</text>
+          <view class="challenge-item" v-for="ch in historyChallenges" :key="ch.id" @click="viewChallenge(ch)">
+            <view class="challenge-item-header">
+              <text class="challenge-item-title">{{ ch.title }}</text>
+              <text class="status-badge status-completed">已完成</text>
+            </view>
+            <text class="challenge-item-desc" v-if="ch.description">{{ ch.description }}</text>
+          </view>
+        </view>
+
+        <!-- 空状态 -->
+        <view class="empty-state" v-if="activeChallenges.length === 0 && historyChallenges.length === 0">
+          <text class="empty-icon">🎯</text>
+          <text class="empty-title">还没有挑战</text>
+          <text class="empty-desc">创建第一个家庭挑战,让全家一起成长!</text>
+          <view class="empty-btn" @click="openCreateModal(null)">立即创建</view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 创建/编辑弹窗 -->
+    <view class="modal-overlay" v-if="showModal" @click="closeModal">
+      <view class="modal-content" @click.stop>
+        <view class="modal-header">
+          <text class="modal-title">{{ editMode ? '编辑挑战' : '创建挑战' }}</text>
+          <text class="modal-close" @click="closeModal">×</text>
+        </view>
+        <view class="modal-body">
+          <view class="form-item">
+            <text class="form-label">挑战标题 *</text>
+            <input class="form-input" v-model="form.title" placeholder="输入挑战标题" maxlength="50" />
+          </view>
+          <view class="form-item">
+            <text class="form-label">挑战描述</text>
+            <textarea class="form-textarea" v-model="form.description" placeholder="描述挑战内容" maxlength="200" />
+          </view>
+          <view class="form-item">
+            <text class="form-label">挑战类型</text>
+            <view class="type-picker">
+              <text class="type-item" :class="{ active: form.challengeType === t.type }"
+                    v-for="t in templates" :key="t.type"
+                    @click="form.challengeType = t.type">{{ t.title }}</text>
+            </view>
+          </view>
+          <view class="form-row">
+            <view class="form-item" style="flex:1">
+              <text class="form-label">目标模式 *</text>
+              <view class="mode-picker">
+                <text class="mode-item" :class="{ active: form.targetMode === 'aggregate' }"
+                      @click="form.targetMode = 'aggregate'">全家合计</text>
+                <text class="mode-item" :class="{ active: form.targetMode === 'all_members' }"
+                      @click="form.targetMode = 'all_members'">每人目标</text>
+              </view>
+            </view>
+            <view class="form-item" style="flex:1">
+              <text class="form-label">目标值 *</text>
+              <input class="form-input" type="digit" v-model="form.targetValue" placeholder="如: 300" />
+            </view>
+          </view>
+          <view class="form-row">
+            <view class="form-item" style="flex:1">
+              <text class="form-label">持续天数</text>
+              <input class="form-input" type="number" v-model="form.durationDays" placeholder="7" />
+            </view>
+            <view class="form-item" style="flex:1">
+              <text class="form-label">奖励积分</text>
+              <input class="form-input" type="number" v-model="form.rewardPoints" placeholder="50" />
+            </view>
+          </view>
+        </view>
+        <view class="modal-footer">
+          <view class="modal-btn modal-btn-cancel" @click="closeModal">取消</view>
+          <view class="modal-btn modal-btn-submit" @click="submitForm">{{ editMode ? '保存' : '创建' }}</view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getChallengeList, getChallengeHistory, createChallenge, updateChallenge, deleteChallenge, getChallengeTemplates, getParentDashboard } from '../../../utils/api.js'
+import FamilyChallengeCard from '../../../components/FamilyChallengeCard.vue'
+
+export default {
+  components: { FamilyChallengeCard },
+  data() {
+    return {
+      loading: true,
+      familyId: null,
+      templates: [],
+      activeChallenges: [],
+      historyChallenges: [],
+      showModal: false,
+      editMode: false,
+      editingId: null,
+      form: {
+        title: '',
+        description: '',
+        challengeType: 'custom',
+        targetMode: 'all_members',
+        targetValue: '7',
+        durationDays: '7',
+        rewardPoints: '50'
+      }
+    }
+  },
+  onLoad() {
+    this.loadFamilyId()
+    this.loadData()
+    this.loadTemplates()
+  },
+  onShow() {
+    if (this.familyId) {
+      this.loadData()
+    }
+  },
+  methods: {
+    loadFamilyId: function() {
+      var self = this
+      try {
+        var dashPromise = getParentDashboard()
+        dashPromise.then(function(res) {
+          var data = res.data || {}
+          if (data.familyId) {
+            self.familyId = data.familyId
+          }
+        }).catch(function() {})
+      } catch (e) {
+        console.log('获取familyId失败', e)
+      }
+    },
+    loadData: function() {
+      var self = this
+      if (!self.familyId) return
+      self.loading = true
+      Promise.all([
+        getChallengeList({ familyId: self.familyId }),
+        getChallengeHistory({ familyId: self.familyId })
+      ]).then(function(results) {
+        self.activeChallenges = Array.isArray(results[0].data) ? results[0].data : []
+        self.historyChallenges = Array.isArray(results[1].data) ? results[1].data : []
+      }).catch(function() {
+        self.activeChallenges = []
+        self.historyChallenges = []
+      }).finally(function() {
+        self.loading = false
+      })
+    },
+    loadTemplates: function() {
+      var self = this
+      getChallengeTemplates().then(function(res) {
+        if (res.code === 200) {
+          self.templates = res.data || []
+        }
+      }).catch(function() {
+        // 降级:使用硬编码模板
+        self.templates = [
+          { type: 'full_checkin', title: '全员打卡', targetMode: 'all_members', targetValue: '3', durationDays: '3', rewardPoints: '50', description: '全家连续打卡' },
+          { type: 'sports', title: '运动PK', targetMode: 'aggregate', targetValue: '300', durationDays: '7', rewardPoints: '100', description: '全家运动时长' },
+          { type: 'reading', title: '阅读挑战', targetMode: 'aggregate', targetValue: '200', durationDays: '7', rewardPoints: '80', description: '全家阅读时长' },
+          { type: 'gratitude', title: '感恩日记', targetMode: 'all_members', targetValue: '7', durationDays: '7', rewardPoints: '40', description: '每天感恩记录' }
+        ]
+      })
+    },
+    getTemplateEmoji: function(type) {
+      var map = {
+        full_checkin: '✅',
+        sports: '🏃',
+        health_week: '💚',
+        reading: '📚',
+        no_screen: '📵',
+        gratitude: '🙏',
+        custom: '✏️'
+      }
+      return map[type] || '🎯'
+    },
+    createFromTemplate: function(template) {
+      this.editMode = false
+      this.editingId = null
+      this.form = {
+        title: template ? template.title : '',
+        description: template ? template.description : '',
+        challengeType: template ? template.type : 'custom',
+        targetMode: template ? template.targetMode : 'all_members',
+        targetValue: template ? String(template.targetValue) : '7',
+        durationDays: template ? String(template.durationDays) : '7',
+        rewardPoints: template ? String(template.rewardPoints) : '50'
+      }
+      this.showModal = true
+    },
+    openCreateModal: function(template) {
+      this.createFromTemplate(template)
+    },
+    openEditModal: function(challenge) {
+      this.editMode = true
+      this.editingId = challenge.id
+      this.form = {
+        title: challenge.title || '',
+        description: challenge.description || '',
+        challengeType: challenge.challengeType || 'custom',
+        targetMode: challenge.targetMode || 'all_members',
+        targetValue: String(challenge.targetValue || '7'),
+        durationDays: String(challenge.durationDays || '7'),
+        rewardPoints: String(challenge.rewardPoints || '50')
+      }
+      this.showModal = true
+    },
+    closeModal: function() {
+      this.showModal = false
+    },
+    submitForm: function() {
+      var self = this
+      var title = self.form.title && self.form.title.trim()
+      if (!title) {
+        uni.showToast({ title: '请填写挑战标题', icon: 'none' })
+        return
+      }
+      var targetValue = parseInt(self.form.targetValue || '0', 10)
+      if (targetValue <= 0) {
+        uni.showToast({ title: '目标值必须大于0', icon: 'none' })
+        return
+      }
+      var data = {
+        title: title,
+        description: self.form.description && self.form.description.trim(),
+        challengeType: self.form.challengeType,
+        targetMode: self.form.targetMode,
+        targetValue: targetValue,
+        durationDays: parseInt(self.form.durationDays || '7', 10),
+        rewardPoints: parseInt(self.form.rewardPoints || '0', 10)
+      }
+      self.loading = true
+      var promise
+      if (self.editMode && self.editingId) {
+        promise = updateChallenge(self.editingId, data)
+      } else {
+        promise = createChallenge(data)
+      }
+      promise.then(function(res) {
+        if (res.code === 200) {
+          var msg = self.editMode ? '更新成功' : '创建成功'
+          uni.showToast({ title: msg, icon: 'success' })
+          self.closeModal()
+          self.loadData()
+        } else {
+          uni.showToast({ title: res.message || '操作失败', icon: 'none' })
+        }
+      }).catch(function(e) {
+        uni.showToast({ title: '操作失败', icon: 'none' })
+        console.log('挑战操作失败', e)
+      }).finally(function() {
+        self.loading = false
+      })
+    },
+    deleteChallenge: function(id) {
+      var self = this
+      uni.showModal({
+        title: '确认删除',
+        content: '确定要取消这个挑战吗?',
+        success: function(r) {
+          if (r.confirm) {
+            deleteChallenge(id).then(function(res) {
+              if (res.code === 200) {
+                uni.showToast({ title: '已删除', icon: 'success' })
+                self.loadData()
+              } else {
+                uni.showToast({ title: res.message || '删除失败', icon: 'none' })
+              }
+            }).catch(function() {
+              uni.showToast({ title: '删除失败', icon: 'none' })
+            })
+          }
+        }
+      })
+    },
+    viewChallenge: function(challenge) {
+      // 跳转到挑战详情页(后续扩展)
+      uni.showToast({ title: challenge.title, icon: 'none' })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.challenge-manage-page {
+  min-height: 100vh;
+  background: #F5F9FC;
+  padding: 20rpx 24rpx 40rpx;
+}
+.page-content {
+  display: flex;
+  flex-direction: column;
+  gap: 28rpx;
+}
+
+/* 模板推荐区 */
+.template-section {
+  margin-bottom: 8rpx;
+}
+.section-title-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12rpx;
+}
+.section-title {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #1E293B;
+}
+.section-hint {
+  font-size: 22rpx;
+  color: #94A3B8;
+}
+.template-scroll {
+  width: 100%;
+}
+.template-scroll-inner {
+  display: flex;
+  gap: 12rpx;
+}
+.template-card {
+  flex-shrink: 0;
+  width: 130rpx;
+  padding: 16rpx 12rpx;
+  background: #fff;
+  border-radius: 16rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 6rpx;
+}
+.template-card:active { opacity: 0.8; }
+.template-card-custom {
+  background: linear-gradient(135deg, #10B981, #34D399);
+}
+.template-icon {
+  font-size: 40rpx;
+  line-height: 1;
+}
+.template-name {
+  font-size: 22rpx;
+  color: #475569;
+  font-weight: 600;
+}
+.template-card-custom .template-name {
+  color: #fff;
+}
+
+/* 列表区 */
+.list-section {
+  display: flex;
+  flex-direction: column;
+}
+.list-add-btn {
+  font-size: 24rpx;
+  color: #10B981;
+  font-weight: 600;
+}
+.list-add-btn:active { opacity: 0.7; }
+
+.group {
+  margin-bottom: 20rpx;
+}
+.group-label {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #64748B;
+  margin-bottom: 10rpx;
+}
+.challenge-item {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 16rpx;
+  margin-bottom: 12rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
+}
+.challenge-item-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 4rpx;
+}
+.challenge-item-title {
+  font-size: 26rpx;
+  font-weight: 700;
+  color: #1E293B;
+}
+.challenge-item-actions {
+  display: flex;
+  gap: 12rpx;
+}
+.action-btn {
+  font-size: 22rpx;
+  padding: 4rpx 12rpx;
+  border-radius: 8rpx;
+}
+.action-edit {
+  color: #5B9BD5;
+  background: rgba(91,155,213,0.1);
+}
+.action-delete {
+  color: #EF4444;
+  background: rgba(239,68,68,0.1);
+}
+.challenge-item-desc {
+  font-size: 22rpx;
+  color: #64748B;
+  margin-bottom: 6rpx;
+}
+.challenge-item-footer {
+  display: flex;
+  gap: 8rpx;
+  margin-bottom: 4rpx;
+}
+.tag {
+  font-size: 20rpx;
+  padding: 2rpx 10rpx;
+  border-radius: 999rpx;
+  background: #E2E8F0;
+  color: #475569;
+}
+.tag-days { background: #DBEAFE; color: #2563EB; }
+.tag-points { background: #FEF3C7; color: #D97706; }
+.status-badge {
+  font-size: 20rpx;
+  padding: 2rpx 10rpx;
+  border-radius: 999rpx;
+}
+.status-completed {
+  background: #F1F5F9;
+  color: #64748B;
+}
+
+/* 空状态 */
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 60rpx 0 20rpx;
+}
+.empty-icon { font-size: 80rpx; margin-bottom: 16rpx; }
+.empty-title { font-size: 32rpx; font-weight: 700; color: #1E293B; }
+.empty-desc { font-size: 24rpx; color: #94A3B8; margin: 8rpx 0 24rpx; }
+.empty-btn {
+  background: #10B981;
+  color: #fff;
+  font-size: 26rpx;
+  font-weight: 600;
+  padding: 16rpx 40rpx;
+  border-radius: 44rpx;
+}
+
+/* 弹窗 */
+.modal-overlay {
+  position: fixed;
+  top: 0; left: 0; right: 0; bottom: 0;
+  background: rgba(0,0,0,0.5);
+  display: flex;
+  align-items: flex-end;
+  justify-content: center;
+  z-index: 999;
+}
+.modal-content {
+  width: 100%;
+  background: #fff;
+  border-radius: 32rpx 32rpx 0 0;
+  padding: 24rpx;
+  box-sizing: border-box;
+}
+.modal-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+.modal-title {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: #1E293B;
+}
+.modal-close {
+  font-size: 40rpx;
+  color: #94A3B8;
+  line-height: 1;
+}
+.modal-body {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+  margin-bottom: 20rpx;
+  max-height: 70vh;
+  overflow-y: auto;
+}
+.form-item {
+  display: flex;
+  flex-direction: column;
+  gap: 6rpx;
+}
+.form-label {
+  font-size: 24rpx;
+  color: #475569;
+  font-weight: 600;
+}
+.form-input {
+  background: #F8FAFC;
+  border: 1rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 14rpx 16rpx;
+  font-size: 26rpx;
+  color: #1E293B;
+}
+.form-textarea {
+  background: #F8FAFC;
+  border: 1rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 14rpx 16rpx;
+  font-size: 26rpx;
+  color: #1E293B;
+  height: 80rpx;
+  width: 100%;
+  box-sizing: border-box;
+}
+.form-row {
+  display: flex;
+  gap: 12rpx;
+}
+.type-picker {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8rpx;
+}
+.type-item {
+  font-size: 22rpx;
+  padding: 6rpx 14rpx;
+  border-radius: 999rpx;
+  background: #F1F5F9;
+  color: #475569;
+}
+.type-item.active {
+  background: #10B981;
+  color: #fff;
+  font-weight: 600;
+}
+.mode-picker {
+  display: flex;
+  gap: 8rpx;
+}
+.mode-item {
+  flex: 1;
+  font-size: 24rpx;
+  padding: 10rpx 0;
+  border-radius: 12rpx;
+  background: #F1F5F9;
+  color: #475569;
+  text-align: center;
+}
+.mode-item.active {
+  background: #10B981;
+  color: #fff;
+  font-weight: 600;
+}
+.modal-footer {
+  display: flex;
+  gap: 12rpx;
+}
+.modal-btn {
+  flex: 1;
+  text-align: center;
+  padding: 16rpx 0;
+  border-radius: 44rpx;
+  font-size: 28rpx;
+  font-weight: 600;
+}
+.modal-btn-cancel {
+  background: #F1F5F9;
+  color: #64748B;
+}
+.modal-btn-submit {
+  background: #10B981;
+  color: #fff;
+}
+</style>
+```
+
+**Step 2: 修改 parent-index.vue 添加挑战管理入口**
+
+在 `parent-index.vue` 的挑战区域(约第 40-44 行),将:
+
+```vue
+<!-- ===== 家庭挑战卡片(P2-2,仅展示,任务在各人任务列表中完成) ===== -->
+<FamilyChallengeCard
+  v-for="ch in activeChallenges"
+  :key="ch.id"
+  :challenge="ch" />
+```
+
+替换为:
+
+```vue
+<!-- ===== 家庭挑战区域 ===== -->
+<view class="challenge-section">
+  <view class="challenge-section-header" @click="goToChallengeManage">
+    <text class="challenge-section-title">家庭挑战</text>
+    <text class="challenge-section-action">管理 →</text>
+  </view>
+  <FamilyChallengeCard
+    v-for="ch in activeChallenges"
+    :key="ch.id"
+    :challenge="ch" />
+</view>
+```
+
+在 `methods` 中添加:
+
+```javascript
+    goToChallengeManage: function() {
+      uni.navigateTo({ url: '/pages/health/challenge-manage' })
+    }
+```
+
+添加样式(在 `<style scoped>` 内):
+
+```css
+.challenge-section {
+  margin-bottom: 32rpx;
+}
+.challenge-section-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin: 0 20rpx 12rpx;
+}
+.challenge-section-title {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: #1E293B;
+}
+.challenge-section-action {
+  font-size: 24rpx;
+  color: #10B981;
+  font-weight: 600;
+}
+.challenge-section-action:active { opacity: 0.7; }
+```
+
+**Step 3: 在 pages.json 注册新页面**
+
+在 `pages/health` 的 `pages` 数组末尾添加:
+
+```json
+{
+  "path": "challenge-manage",
+  "style": {
+    "navigationBarTitleText": "家庭挑战"
+  }
+}
+```
+
+**Step 4: 验证**
+
+确认 `parent-index.vue` 中 `goToChallengeManage` 方法已添加且无语法错误。
+
+---
+
+### Task 5: 后端 + 前端联调
+
+**Step 1: 后端编译验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```
+
+**Step 2: 前端构建验证**
+
+```bash
+cd cfc-frontend && npm run build:mp-weixin
+```
+
+**Step 3: 功能验证清单**
+
+- [ ] 后端 `POST /api/health/challenge/templates` 返回模板列表
+- [ ] 后端 `POST /api/health/challenge/create` 创建成功
+- [ ] 后端 `POST /api/health/challenge/update/{id}` 更新成功
+- [ ] 后端 `POST /api/health/challenge/delete/{id}` 取消挑战
+- [ ] 后端 `POST /api/health/challenge/list` 返回自定义挑战列表
+- [ ] 前端管理页展示进行中/已完成分组
+- [ ] 前端模板快速创建功能正常
+- [ ] 前端编辑/删除功能正常
+- [ ] 前端 parent-index 挑战管理入口可点击跳转
+
+---
+
+### Task 6: 自审查与清理
+
+**Step 1: 代码审查**
+
+- [ ] `FamilyChallengeController` 所有接口均为 `@PostMapping`
+- [ ] `FamilyChallengeController` 中所有需 familyId 的接口通过 userService 获取(或 interceptor 注入)
+- [ ] `getActiveChallenges` 不再自动创建模板
+- [ ] 前端页面使用 Vue 2 Options API,无可选链语法
+- [ ] 前端弹窗 `:key` 使用 `tpl.type` 而非表达式
+- [ ] 所有日期解析使用 `parseDate()` 而非 `new Date(string)`
+
+**Step 2: 确认无多余改动**
+
+- [ ] 仅修改了计划中的文件
+- [ ] 无未使用的 import
+
+**Step 3: 最终验证**
+
+```bash
+cd cfc-backend && mvn clean compile
+```