2026-08-04-family-challenge-custom.md 38 KB

家庭挑战自定义管理 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 字段 + 迁移 ✅

  • Entity 添加 creatorId 字段
  • schema.sql 同步 creator_id 列
  • DatabaseInitializer 添加迁移158

Task 2: 后端 CRUD 接口 ✅

  • Service: getTemplates() — 返回7个预设模板
  • Service: createChallenge() — 创建自定义挑战
  • Service: updateChallenge() — 编辑挑战
  • Service: deleteChallenge() — 软删除(status=cancelled)
  • Controller: /templates, /create, /update/{id}, /delete/{id}
  • getActiveChallenges() 不再自动创建模板

Task 3: 前端 API ✅

  • createChallenge, updateChallenge, deleteChallenge, getChallengeTemplates

Task 4: 前端页面 + 入口 ✅

  • pages/health/challenge-manage.vue — 模板推荐 + 挑战列表 + 创建/编辑弹窗
  • parent-index.vue — 挑战区域添加"管理 →"入口
  • pages.json — 注册 challenge-manage 页面

Task 5: 验证

  • 前端 npm run build:mp-weixin — BUILD PASSED (pre-existing warnings only)
  • 后端 mvn clean compile — 需要 JDK 环境(当前机器只有 JRE)

Task 6: 自审查 ✅

  • 所有接口均为 @PostMapping
  • getActiveChallenges 不再自动创建模板
  • 前端 Vue 2 Options API,无可选链
  • 无 CSS Grid
  • 无 :key 表达式
  • 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 字段之后)添加:

    /** 创建者ID(系统创建为NULL,用户创建为userId) */
    @TableField(exist = false)
    private Long creatorId;

Step 2: 同步 schema.sql

找到 CREATE TABLE IF NOT EXISTS family_challenge ( 那一段(约 3158 行),在 created_at 列之前添加:

    creator_id BIGINT COMMENT '创建者ID(NULL=系统创建)',

Step 3: 在 DatabaseInitializer 添加迁移

搜索 // 迁移 找到最新编号,在其后追加:

    // 迁移XX: family_challenge 添加 creator_id 列(用户自定义挑战)
    ensureColumn("family_challenge", "creator_id", "BIGINT COMMENT '创建者ID(NULL=系统创建)'");

Step 4: 验证

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 添加方法

新增以下方法:

    /** 获取挑战模板列表(供前端参考创建) */
    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:

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

Step 2: 在 FamilyChallengeController 添加接口

    @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 方法中改为:

    @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 中添加:

import com.etotem.cfc.entity.User;
import com.etotem.cfc.service.UserService;
@Resource
private UserService userService;

Step 4: 移除非兜底的 auto-create 调用

在 getActiveChallenges() 方法中,当前逻辑是"无活跃挑战时自动创建模板"。改为:无活跃挑战时返回空列表,不再自动创建。

将 getActiveChallenges 改为:

    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: 验证

cd cfc-backend && mvn clean compile

Expected: 编译通过


Task 3: 前端 API 封装

Files:

  • Modify: cfc-frontend/utils/api.js

Step 1: 在 getChallengeList 附近添加新 API

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

完整页面代码如下:

<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 行),将:

<!-- ===== 家庭挑战卡片(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>

在 methods 中添加:

    goToChallengeManage: function() {
      uni.navigateTo({ url: '/pages/health/challenge-manage' })
    }

添加样式(在 <style scoped> 内):

.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 数组末尾添加:

{
  "path": "challenge-manage",
  "style": {
    "navigationBarTitleText": "家庭挑战"
  }
}

Step 4: 验证

确认 parent-index.vue 中 goToChallengeManage 方法已添加且无语法错误。


Task 5: 后端 + 前端联调

Step 1: 后端编译验证

cd cfc-backend && mvn clean compile

Step 2: 前端构建验证

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: 最终验证

cd cfc-backend && mvn clean compile