|
|
@@ -0,0 +1,586 @@
|
|
|
+# 家庭挑战断点修复实现计划
|
|
|
+
|
|
|
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
|
|
+
|
|
|
+**目标:** 修复家庭挑战功能的 7 个断点(spec: `docs/superpowers/specs/2026-08-06-family-challenge-flow-design.md` §7),使挑战形成闭环:家长创建 → 孩子打卡(限次)→ 到期结算 → 奖励发放,并补齐角色校验与前端展示修复。
|
|
|
+
|
|
|
+**架构:** 后端 cfc-backend 扩展 `FamilyChallengeService`/`FamilyChallengeController`:加角色校验(复用 JwtInterceptor 已设的 `role`/`familyId`/`currentMemberId` attribute)、打卡每日限次、结算时奖励发放(对接 PointsService 能量点)、列表查询时惰性结算。前端 cfc-frontend:`FamilyChallengeCard.vue` 加打卡按钮 + 修复 cancelled 状态显示、`challenge-manage.vue` 限制家长访问、`api.js` 已有封装不动。
|
|
|
+
|
|
|
+**技术栈:** Java 8、Spring Boot 2.7.18、MyBatis-Plus、Lombok(后端);uni-app Vue 2 Options API(小程序)。后端验证 `mvn clean compile`。
|
|
|
+
|
|
|
+**约束:** 接口统一 `@PostMapping`;DI 用 `@Resource`;小程序禁止可选链 `?.`、禁止 `:key` 表达式、禁止直接 `new Date(string)`(用 `utils/format.js` 的 `parseDate()`);角色检查在 Controller 用 `@RequestAttribute("role")`。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 文件结构
|
|
|
+
|
|
|
+| 文件 | 职责 | 操作 |
|
|
|
+|---|---|---|
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java` | 打卡限次、奖励发放、惰性结算 | 修改 |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyChallengeController.java` | 角色校验(家长/孩子分流) | 修改 |
|
|
|
+| `cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallenge.java` | 新增 `lastSettleCheckAt`(可选,惰性结算防重) | 修改 |
|
|
|
+| `cfc-frontend/components/FamilyChallengeCard.vue` | 打卡按钮 + cancelled 状态显示修复 | 修改 |
|
|
|
+| `cfc-frontend/pages/health/challenge-manage.vue` | 家长权限控制 | 修改 |
|
|
|
+| `cfc-frontend/pages/index-home/index.vue` | 孩子端展示打卡入口(若卡片已含则跳过) | 修改 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 1:后端 — 打卡每日限次 + memberId 归属校验
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java:93-125`(`updateProgress` 方法)
|
|
|
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.java`(新建)
|
|
|
+
|
|
|
+- [ ] **步骤 1:编写失败的测试(限次逻辑)**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.etotem.cfc.entity.FamilyChallenge;
|
|
|
+import com.etotem.cfc.entity.FamilyChallengeProgress;
|
|
|
+import com.etotem.cfc.mapper.FamilyChallengeMapper;
|
|
|
+import com.etotem.cfc.mapper.FamilyChallengeProgressMapper;
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.mockito.InjectMocks;
|
|
|
+import org.mockito.Mock;
|
|
|
+import org.mockito.MockitoAnnotations;
|
|
|
+import org.springframework.test.util.ReflectionTestUtils;
|
|
|
+
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
+
|
|
|
+public class FamilyChallengeServiceTest {
|
|
|
+
|
|
|
+ @Mock private FamilyChallengeMapper familyChallengeMapper;
|
|
|
+ @Mock private FamilyChallengeProgressMapper familyChallengeProgressMapper;
|
|
|
+ @Mock private FamilyMemberMapper familyMemberMapper;
|
|
|
+ @Mock private HealthScoreService healthScoreService;
|
|
|
+ @InjectMocks private FamilyChallengeService service;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ MockitoAnnotations.openMocks(this);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void updateProgress_shouldRejectDuplicateCheckinSameDay() {
|
|
|
+ FamilyChallenge challenge = new FamilyChallenge();
|
|
|
+ challenge.setId(1L);
|
|
|
+ challenge.setFamilyId(10L);
|
|
|
+ challenge.setStatus("active");
|
|
|
+ challenge.setTargetMode("aggregate");
|
|
|
+ challenge.setTargetValue(300);
|
|
|
+ challenge.setEndDate(new Date(System.currentTimeMillis() + 86400000L * 7));
|
|
|
+
|
|
|
+ FamilyChallengeProgress existing = new FamilyChallengeProgress();
|
|
|
+ existing.setChallengeId(1L);
|
|
|
+ existing.setChildId(100L);
|
|
|
+ existing.setProgressValue(5);
|
|
|
+ // 今天已打卡(updatedAt 是今天)
|
|
|
+ existing.setUpdatedAt(new Date());
|
|
|
+
|
|
|
+ when(familyChallengeMapper.selectById(1L)).thenReturn(challenge);
|
|
|
+ when(familyChallengeProgressMapper.selectOne(any())).thenReturn(existing);
|
|
|
+
|
|
|
+ var result = service.updateProgress(1L, 100L, 1);
|
|
|
+
|
|
|
+ assertEquals("error", result.getCode());
|
|
|
+ verify(familyChallengeProgressMapper, never()).updateById(any());
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:运行测试确认失败**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest#updateProgress_shouldRejectDuplicateCheckinSameDay -pl .`
|
|
|
+预期:FAIL(`updateProgress` 目前接受重复打卡,不会返回 error)
|
|
|
+
|
|
|
+- [ ] **步骤 3:实现限次 + 归属校验**
|
|
|
+
|
|
|
+将 `FamilyChallengeService.updateProgress`(L93-125)整体替换为以下实现(合并原有逻辑 + 新增校验,消除重复查询):
|
|
|
+
|
|
|
+```java
|
|
|
+@Transactional
|
|
|
+public Result<Void> updateProgress(Long challengeId, Long childId, int delta) {
|
|
|
+ if (challengeId == null || childId == null) {
|
|
|
+ return Result.error("参数错误");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 1. 挑战必须存在
|
|
|
+ FamilyChallenge challenge = familyChallengeMapper.selectById(challengeId);
|
|
|
+ if (challenge == null) {
|
|
|
+ return Result.error("挑战不存在");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. memberId 归属校验:childId 对应的 family_member 必须属于挑战所属家庭(防刷他人进度)
|
|
|
+ LambdaQueryWrapper<FamilyMember> memberCheck = new LambdaQueryWrapper<FamilyMember>()
|
|
|
+ .eq(FamilyMember::getId, childId)
|
|
|
+ .eq(FamilyMember::getFamilyId, challenge.getFamilyId());
|
|
|
+ if (familyMemberMapper.selectCount(memberCheck) == 0) {
|
|
|
+ return Result.error("无权为该成员打卡");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. 查该成员在本挑战的进度
|
|
|
+ LambdaQueryWrapper<FamilyChallengeProgress> wrapper = new LambdaQueryWrapper<FamilyChallengeProgress>()
|
|
|
+ .eq(FamilyChallengeProgress::getChallengeId, challengeId)
|
|
|
+ .eq(FamilyChallengeProgress::getChildId, childId);
|
|
|
+ FamilyChallengeProgress progress = familyChallengeProgressMapper.selectOne(wrapper);
|
|
|
+
|
|
|
+ // 4. 每日限次:同一 challengeId+childId 当天只能打卡一次(按 updatedAt 判断)
|
|
|
+ if (progress != null && progress.getUpdatedAt() != null) {
|
|
|
+ java.util.Calendar today = java.util.Calendar.getInstance();
|
|
|
+ today.setTime(new Date());
|
|
|
+ java.util.Calendar upd = java.util.Calendar.getInstance();
|
|
|
+ upd.setTime(progress.getUpdatedAt());
|
|
|
+ if (upd.get(java.util.Calendar.YEAR) == today.get(java.util.Calendar.YEAR)
|
|
|
+ && upd.get(java.util.Calendar.DAY_OF_YEAR) == today.get(java.util.Calendar.DAY_OF_YEAR)) {
|
|
|
+ return Result.error("今日已打卡,明天再来吧");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 5. 累加进度(原有逻辑)
|
|
|
+ if (progress == null) {
|
|
|
+ progress = new FamilyChallengeProgress();
|
|
|
+ progress.setChallengeId(challengeId);
|
|
|
+ progress.setChildId(childId);
|
|
|
+ progress.setProgressValue(delta);
|
|
|
+ progress.setCompleted(false);
|
|
|
+ progress.setCreatedAt(new Date());
|
|
|
+ progress.setUpdatedAt(new Date());
|
|
|
+ familyChallengeProgressMapper.insert(progress);
|
|
|
+ } else {
|
|
|
+ progress.setProgressValue(progress.getProgressValue() + delta);
|
|
|
+ progress.setUpdatedAt(new Date());
|
|
|
+ familyChallengeProgressMapper.updateById(progress);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 6. 结算判定(原有逻辑,复用已查出的 challenge,不再重复 selectById)
|
|
|
+ if ("active".equals(challenge.getStatus())) {
|
|
|
+ checkAndSettle(challenge);
|
|
|
+ }
|
|
|
+
|
|
|
+ return Result.success();
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 4:运行测试确认通过**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest -pl .`
|
|
|
+预期:PASS
|
|
|
+
|
|
|
+- [ ] **步骤 5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.java cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java
|
|
|
+git commit -m "fix(backend): 家庭挑战打卡每日限次 + memberId 归属校验"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 2:后端 — 结算时奖励发放(能量点 + 徽章)
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java:134-182`(`checkAndSettle` 方法)
|
|
|
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.java`(追加)
|
|
|
+
|
|
|
+- [ ] **步骤 1:编写失败的测试(结算发放奖励)**
|
|
|
+
|
|
|
+```java
|
|
|
+@Test
|
|
|
+void checkAndSettle_shouldGrantPointsWhenCompleted() {
|
|
|
+ FamilyChallenge challenge = new FamilyChallenge();
|
|
|
+ challenge.setId(1L);
|
|
|
+ challenge.setFamilyId(10L);
|
|
|
+ challenge.setStatus("active");
|
|
|
+ challenge.setTargetMode("aggregate");
|
|
|
+ challenge.setTargetValue(10);
|
|
|
+ challenge.setRewardPoints(50);
|
|
|
+ challenge.setEndDate(new Date(System.currentTimeMillis() - 86400000L)); // 已到期
|
|
|
+
|
|
|
+ FamilyChallengeProgress p = new FamilyChallengeProgress();
|
|
|
+ p.setChallengeId(1L);
|
|
|
+ p.setChildId(100L);
|
|
|
+ p.setProgressValue(12); // ≥10 达成
|
|
|
+ p.setCompleted(false);
|
|
|
+
|
|
|
+ when(familyChallengeMapper.selectById(1L)).thenReturn(challenge);
|
|
|
+ when(familyChallengeProgressMapper.selectList(any())).thenReturn(Arrays.asList(p));
|
|
|
+ when(familyMemberMapper.selectList(any())).thenReturn(java.util.Collections.emptyList());
|
|
|
+
|
|
|
+ service.settleChallenge(1L);
|
|
|
+
|
|
|
+ // 挑战应标记 completed
|
|
|
+ verify(familyChallengeMapper).updateById(argThat(c -> "completed".equals(c.getStatus())));
|
|
|
+ // 进度应标记完成
|
|
|
+ verify(familyChallengeProgressMapper).updateById(argThat(p2 -> Boolean.TRUE.equals(p2.getCompleted())));
|
|
|
+ // 应发放积分(PointsService.awardSystemPoints)
|
|
|
+ verify(pointsService).awardSystemPoints(eq(100L), eq(50), anyString());
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> 已核实:积分发放使用 `PointsService.awardSystemPoints(Long memberId, int amount, String reason)`(`service/PointsService.java:97`),`@Resource` 注入 `PointsService pointsService`。测试需 `@Mock private PointsService pointsService;`。
|
|
|
+
|
|
|
+- [ ] **步骤 2:运行测试确认失败**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest#checkAndSettle_shouldGrantPointsWhenCompleted -pl .`
|
|
|
+预期:FAIL(当前 `checkAndSettle` 不改 progress.completed、不发积分)
|
|
|
+
|
|
|
+- [ ] **步骤 3:实现奖励发放**
|
|
|
+
|
|
|
+在 `checkAndSettle` 中,`isCompleted` 判定通过后、`challenge.setStatus("completed")` 之前插入:
|
|
|
+
|
|
|
+```java
|
|
|
+// 发放奖励:能量点 + 标记成员完成
|
|
|
+if (isCompleted) {
|
|
|
+ int points = challenge.getRewardPoints() != null ? challenge.getRewardPoints() : 0;
|
|
|
+ for (FamilyChallengeProgress p : progresses) {
|
|
|
+ if (p.getProgressValue() != null && p.getProgressValue() >= challenge.getTargetValue()) {
|
|
|
+ if (!Boolean.TRUE.equals(p.getCompleted())) {
|
|
|
+ p.setCompleted(true);
|
|
|
+ p.setUpdatedAt(new Date());
|
|
|
+ familyChallengeProgressMapper.updateById(p);
|
|
|
+ }
|
|
|
+ if (points > 0) {
|
|
|
+ // 对接积分服务发放奖励(PointsService.awardSystemPoints,按 memberId 发放)
|
|
|
+ pointsService.awardSystemPoints(p.getChildId(), points,
|
|
|
+ "家庭挑战完成奖励: " + challenge.getTitle());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 4:运行测试确认通过**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest -pl .`
|
|
|
+预期:PASS
|
|
|
+
|
|
|
+- [ ] **步骤 5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.java
|
|
|
+git commit -m "feat(backend): 家庭挑战结算发放能量点奖励并标记成员完成"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 3:后端 — 到期惰性结算 + 角色校验
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java:35-46`(`getActiveChallenges`)
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyChallengeController.java`
|
|
|
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.java`(追加)
|
|
|
+
|
|
|
+- [ ] **步骤 1:编写失败的测试(查询时惰性结算)**
|
|
|
+
|
|
|
+```java
|
|
|
+@Test
|
|
|
+void getActiveChallenges_shouldSettleExpiredChallenges() {
|
|
|
+ FamilyChallenge expired = new FamilyChallenge();
|
|
|
+ expired.setId(2L);
|
|
|
+ expired.setFamilyId(10L);
|
|
|
+ expired.setStatus("active");
|
|
|
+ expired.setTargetMode("aggregate");
|
|
|
+ expired.setTargetValue(10);
|
|
|
+ expired.setEndDate(new Date(System.currentTimeMillis() - 86400000L)); // 已到期
|
|
|
+
|
|
|
+ when(familyChallengeMapper.selectList(any())).thenReturn(Arrays.asList(expired));
|
|
|
+ when(familyChallengeProgressMapper.selectList(any())).thenReturn(java.util.Collections.emptyList());
|
|
|
+ when(familyMemberMapper.selectList(any())).thenReturn(java.util.Collections.emptyList());
|
|
|
+
|
|
|
+ service.getActiveChallenges(10L);
|
|
|
+
|
|
|
+ // 到期挑战应被结算为 completed
|
|
|
+ verify(familyChallengeMapper).updateById(argThat(c -> "completed".equals(c.getStatus())));
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:运行测试确认失败**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest#getActiveChallenges_shouldSettleExpiredChallenges -pl .`
|
|
|
+预期:FAIL(当前 `getActiveChallenges` 只查不改)
|
|
|
+
|
|
|
+- [ ] **步骤 3:实现惰性结算 + 角色校验**
|
|
|
+
|
|
|
+在 `getActiveChallenges` 中,`selectList` 之后、`fillProgress` 之前插入:
|
|
|
+
|
|
|
+```java
|
|
|
+// 惰性结算:遍历 active 挑战,到期的触发 checkAndSettle
|
|
|
+for (FamilyChallenge c : active) {
|
|
|
+ if (c.getEndDate() != null && new Date().after(c.getEndDate())) {
|
|
|
+ checkAndSettle(c);
|
|
|
+ }
|
|
|
+}
|
|
|
+// 结算后重新查询一次(排除刚结算的)
|
|
|
+active = familyChallengeMapper.selectList(wrapper);
|
|
|
+```
|
|
|
+
|
|
|
+在 `FamilyChallengeController` 各端点加角色校验(模式参考项目其他 controller):
|
|
|
+
|
|
|
+```java
|
|
|
+// list/history:任何登录角色可看(parent/child),保持现状
|
|
|
+// progress:仅 child 可打卡(parent/teacher 拒绝)
|
|
|
+@PostMapping("/progress")
|
|
|
+public Result<Void> updateProgress(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestAttribute(value = "role", required = false) String role,
|
|
|
+ @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
|
|
|
+ if (!"child".equals(role)) {
|
|
|
+ return Result.error("仅孩子可打卡");
|
|
|
+ }
|
|
|
+ // ... 原逻辑
|
|
|
+}
|
|
|
+
|
|
|
+// create/update/delete:仅 parent 可操作
|
|
|
+@PostMapping("/create")
|
|
|
+public Result<Long> createChallenge(@RequestBody Map<String, Object> params,
|
|
|
+ @RequestAttribute(value = "role", required = false) String role,
|
|
|
+ @RequestAttribute(value = "familyId", required = false) Long familyId,
|
|
|
+ @RequestAttribute("userId") Long userId) {
|
|
|
+ if (!"parent".equals(role)) {
|
|
|
+ return Result.error("仅家长可创建挑战");
|
|
|
+ }
|
|
|
+ // ... 原逻辑
|
|
|
+}
|
|
|
+// update/delete 同理加 "仅家长" 校验
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 4:运行测试确认通过**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest -pl .`
|
|
|
+预期:PASS
|
|
|
+
|
|
|
+- [ ] **步骤 5:验证编译**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS(无编译错误)
|
|
|
+
|
|
|
+- [ ] **步骤 6:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyChallengeController.java cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.java
|
|
|
+git commit -m "feat(backend): 家庭挑战到期惰性结算 + 角色校验(家长管理/孩子打卡)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 4:前端 — 打卡按钮 + cancelled 状态显示修复
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-frontend/components/FamilyChallengeCard.vue`
|
|
|
+- 修改:`cfc-frontend/pages/index-home/index.vue`(如需传 `childId`)
|
|
|
+- 修改:`cfc-frontend/pages/home-pages/parent-index.vue`(如需传 `childId`)
|
|
|
+
|
|
|
+- [ ] **步骤 1:修复 cancelled 状态显示**
|
|
|
+
|
|
|
+`FamilyChallengeCard.vue` 第 5-7 行当前:
|
|
|
+
|
|
|
+```html
|
|
|
+<text class="card-status" :class="statusClass">
|
|
|
+ {{ challenge.status === 'active' ? '进行中' : '已完成' }}
|
|
|
+</text>
|
|
|
+```
|
|
|
+
|
|
|
+改为(区分三态):
|
|
|
+
|
|
|
+```html
|
|
|
+<text class="card-status" :class="statusClass">
|
|
|
+ {{ challenge.status === 'active' ? '进行中' : (challenge.status === 'cancelled' ? '已取消' : '已完成') }}
|
|
|
+</text>
|
|
|
+```
|
|
|
+
|
|
|
+并在 `statusClass` 计算属性加 cancelled 分支:
|
|
|
+
|
|
|
+```js
|
|
|
+statusClass: function() {
|
|
|
+ var s = this.challenge && this.challenge.status ? this.challenge.status : 'active'
|
|
|
+ return 'status-' + s
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:加打卡按钮(孩子端显示)**
|
|
|
+
|
|
|
+在卡片底部(`card-detail-row` 之后)加:
|
|
|
+
|
|
|
+```html
|
|
|
+<view class="card-action" v-if="showCheckin && challenge.status === 'active'">
|
|
|
+ <view class="checkin-btn" @click="onCheckin">✅ 打卡</view>
|
|
|
+</view>
|
|
|
+```
|
|
|
+
|
|
|
+在 script 加 props 与方法:
|
|
|
+
|
|
|
+```js
|
|
|
+props: {
|
|
|
+ challenge: { type: Object, default: null },
|
|
|
+ showCheckin: { type: Boolean, default: false },
|
|
|
+ childId: { type: [Number, String], default: null }
|
|
|
+},
|
|
|
+methods: {
|
|
|
+ onCheckin: function() {
|
|
|
+ var self = this
|
|
|
+ if (!this.childId) {
|
|
|
+ uni.showToast({ title: '请先选择家庭成员', icon: 'none' })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ uni.showModal({
|
|
|
+ title: '确认打卡',
|
|
|
+ content: '确认完成今日打卡吗?',
|
|
|
+ success: function(res) {
|
|
|
+ if (res.confirm) {
|
|
|
+ self.$emit('checkin', { challengeId: self.challenge.id, childId: self.childId })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:index-home.vue 传入 childId + 处理打卡事件**
|
|
|
+
|
|
|
+`cfc-frontend/pages/index-home/index.vue` 第 98-105 行当前嵌入卡片未传 childId。改为:
|
|
|
+
|
|
|
+```html
|
|
|
+<view class="section challenge-section" v-if="isLoggedIn() && activeChallenges.length > 0">
|
|
|
+ <FamilyChallengeCard
|
|
|
+ v-for="ch in activeChallenges"
|
|
|
+ :key="getItemKey(ch)"
|
|
|
+ :challenge="ch"
|
|
|
+ :showCheckin="true"
|
|
|
+ :childId="currentChildId"
|
|
|
+ @checkin="onChallengeCheckin" />
|
|
|
+</view>
|
|
|
+```
|
|
|
+
|
|
|
+并在 methods 加(`currentChildId` 从 Vuex/store 取当前孩子成员 id,若为空则用 `currentMemberId` 逻辑):
|
|
|
+
|
|
|
+```js
|
|
|
+onChallengeCheckin: function(payload) {
|
|
|
+ var self = this
|
|
|
+ updateChallengeProgress({ challengeId: payload.challengeId, memberId: payload.childId, delta: 1 })
|
|
|
+ .then(function(res) {
|
|
|
+ if (res && res.code === 0) {
|
|
|
+ uni.showToast({ title: '打卡成功', icon: 'success' })
|
|
|
+ self.loadChallenges()
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: (res && res.message) || '打卡失败', icon: 'none' })
|
|
|
+ }
|
|
|
+ })
|
|
|
+},
|
|
|
+getItemKey: function(item) {
|
|
|
+ return item.id || item.challengeId || ''
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> 注意:`getItemKey` 若已在页面 methods 中存在(用于其他列表),复用;`currentChildId` 若页面已存在直接复用,否则从 Vuex store 读当前成员。
|
|
|
+
|
|
|
+- [ ] **步骤 4:构建验证**
|
|
|
+
|
|
|
+运行:`cd cfc-frontend && npm run build:mp-weixin`
|
|
|
+预期:构建成功,无语法错误(重点检查无可选链 `?.`、无 `:key` 表达式)
|
|
|
+
|
|
|
+- [ ] **步骤 5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/components/FamilyChallengeCard.vue cfc-frontend/pages/index-home/index.vue
|
|
|
+git commit -m "feat(frontend): 家庭挑战孩子端打卡入口 + cancelled 状态显示修复"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 5:前端 — 管理页家长权限控制
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-frontend/pages/health/challenge-manage.vue`
|
|
|
+
|
|
|
+- [ ] **步骤 1:加角色判断**
|
|
|
+
|
|
|
+在 `challenge-manage.vue` 的 `onLoad` 中读取角色,非 parent 时提示并返回:
|
|
|
+
|
|
|
+```js
|
|
|
+onLoad: function() {
|
|
|
+ var role = this.getUserRole()
|
|
|
+ if (role !== 'parent') {
|
|
|
+ uni.showToast({ title: '仅家长可管理挑战', icon: 'none' })
|
|
|
+ setTimeout(function() { uni.navigateBack() }, 1000)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // ... 原有加载逻辑
|
|
|
+},
|
|
|
+getUserRole: function() {
|
|
|
+ // 从 store 读取当前角色
|
|
|
+ return this.$store && this.$store.state && this.$store.state.userInfo && this.$store.state.userInfo.role
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:验证现有加载逻辑未被破坏**
|
|
|
+
|
|
|
+读取 `challenge-manage.vue` 的完整 `onLoad`/`onShow` 及 `created` 钩子,确认插入位置正确、原有逻辑保留。
|
|
|
+
|
|
|
+- [ ] **步骤 3:构建验证**
|
|
|
+
|
|
|
+运行:`cd cfc-frontend && npm run build:mp-weixin`
|
|
|
+预期:构建成功
|
|
|
+
|
|
|
+- [ ] **步骤 4:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/health/challenge-manage.vue
|
|
|
+git commit -m "feat(frontend): 家庭挑战管理页仅家长可访问"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 6:全量验证
|
|
|
+
|
|
|
+- [ ] **步骤 1:后端编译 + 测试**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile && mvn test`
|
|
|
+预期:BUILD SUCCESS,全部测试通过
|
|
|
+
|
|
|
+- [ ] **步骤 2:前端构建**
|
|
|
+
|
|
|
+运行:`cd cfc-frontend && npm run build:mp-weixin`
|
|
|
+预期:构建成功
|
|
|
+
|
|
|
+- [ ] **步骤 3:路由重复检查**
|
|
|
+
|
|
|
+运行:
|
|
|
+```bash
|
|
|
+cd cfc-backend && grep -rn '@Mapping' src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep challenge
|
|
|
+```
|
|
|
+预期:仅 `list`、`history`、`progress`、`templates`、`create`、`update`、`delete` 各出现一次
|
|
|
+
|
|
|
+- [ ] **步骤 4:最终 Commit(若有遗漏改动)**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add -A -- cfc-backend/src/main/java/com/etotem/cfc cfc-frontend/components/FamilyChallengeCard.vue cfc-frontend/pages
|
|
|
+git commit -m "fix: 家庭挑战断点修复收尾"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 自检
|
|
|
+
|
|
|
+**1. 规格覆盖度(spec §7 断点 ↔ 任务映射):**
|
|
|
+
|
|
|
+| spec §7 # | 断点 | 任务 |
|
|
|
+|---|---|---|
|
|
|
+| 1 | 打卡无前端入口 | 任务 4(打卡按钮 + index-home 接入) |
|
|
|
+| 2 | 到期不自动结算 | 任务 3(getActiveChallenges 惰性结算) |
|
|
|
+| 3 | 奖励未发放 | 任务 2(checkAndSettle 发能量点) |
|
|
|
+| 4 | 无角色检查 | 任务 3(Controller 角色校验)+ 任务 5(管理页权限) |
|
|
|
+| 5 | cancelled 显示为已完成 | 任务 4 步骤 1 |
|
|
|
+| 6 | 无每日限次 | 任务 1(updateProgress 限次) |
|
|
|
+| 7 | autoCreate 无调用方 | 不在本次范围(属于"未来"功能,spec §8;如需启用需另行 spec) |
|
|
|
+
|
|
|
+**2. 占位符扫描:** 无 TBD/TODO。任务 2 已核实 `PointsService.awardSystemPoints` 签名并写死到计划中,无"实现时确认"残留。
|
|
|
+
|
|
|
+**3. 类型一致性:** `updateProgress(challengeId, childId, delta)` 签名全计划一致;`onCheckin`/`onChallengeCheckin`/`getItemKey` 命名一致;`getItemKey` 已在项目其他页面存在时复用。
|
|
|
+
|
|
|
+**执行交接:** 计划已保存到 `docs/superpowers/plans/2026-08-06-family-challenge-fix.md`。
|