# 家庭挑战自定义管理 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`(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> getTemplates() { List> 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 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 createChallenge(Long familyId, Long creatorId, Map 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 updateChallenge(Long challengeId, Long userId, Long familyId, Map 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 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>> getTemplates() { return Result.success(familyChallengeService.getTemplates()); } @PostMapping("/create") public Result createChallenge(@RequestAttribute("userId") Long userId, @RequestAttribute("familyId") Long familyId, @RequestBody Map params) { return familyChallengeService.createChallenge(familyId, userId, params); } @PostMapping("/update/{id}") public Result updateChallenge(@RequestAttribute("userId") Long userId, @RequestAttribute("familyId") Long familyId, @PathVariable Long id, @RequestBody Map params) { return familyChallengeService.updateChallenge(id, userId, familyId, params); } @PostMapping("/delete/{id}") public Result 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 createChallenge(@RequestAttribute("userId") Long userId, @RequestBody Map 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 getActiveChallenges(Long familyId) { if (familyId == null) return new ArrayList<>(); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(FamilyChallenge::getFamilyId, familyId) .eq(FamilyChallenge::getStatus, "active") .orderByDesc(FamilyChallenge::getCreatedAt); List 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