# 「今日任务」首页卡片 + 只读聚合接口 实施计划 > **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:** 后端新增 1 个只读聚合接口 `POST /api/daily-task/overview`(`DailyTaskController` + `DailyOverviewService` + `DailyOverviewVO`),复用现有 service/mapper 查询逻辑,零表结构改动;前端新增 `components/daily-task-card.vue` 组件(自带 onShow 拉取),挂载到首页 index-home。 **Tech Stack:** Spring Boot 2.7.18 + MyBatis-Plus + Java 8;uni-app Vue 2(Options API)微信小程序。 ## Global Constraints - 接口统一 `@PostMapping`,禁止 `@GetMapping/@PutMapping/@DeleteMapping`(AGENTS.md) - 响应统一 `Result`(code=200/message/data),`Result.noFamily` code=5001(Result.java) - DI 用 `@Resource`,字段名与类型默认 Bean Name 一致(AGENTS.md) - 认证:JWT Bearer Token;当前用户 = `@RequestAttribute("userId")`,当前家庭成员 = `@RequestAttribute(value="currentMemberId", required=false)`,家庭 = `@RequestAttribute(value="familyId", required=false)`;**禁止使用 `@RequestAttribute("memberId")`(代码库无此 attribute)**(JwtInterceptor.java:131-165) - memberId 回退链:请求体 `memberId` → `currentMemberId`(JwtInterceptor 注入)→ 成长任务层用 `userId`(GrowthTaskService.getTaskList 以 userId 为参数) - 聚合接口只读:**不写库、不改任何表结构**(spec §4.2) - 小程序限制:禁止可选链 `?.`(用 `&&`/逐字段判断)、禁止 CSS Grid(用 flexbox)、禁止 `:key` 表达式(用方法调用 `:key="getXxxKey(item)"`)、禁止 `new Date(string)`(用 `utils/format.js` 的 `parseDate()`)、数组 props 必须 `default: function() { return [] }`、:`class` 绑定禁用方法调用(cfc-frontend/AGENTS.md) - 前端打包禁止 npm build 命令,只做 `node --check` 语法校验,打包走 HBuilderX(cfc-frontend/AGENTS.md) - 后端验证:`cd cfc-backend && mvn clean compile`;测试:`mvn test`(无 H2/Testcontainers,靠 @MockBean 隔离 DB) --- ### Task 1: 后端聚合核心 — DailyOverviewVO + DailyOverviewService **Files:** - Create: `cfc-backend/src/main/java/com/etotem/cfc/dto/DailyOverviewVO.java` - Create: `cfc-backend/src/main/java/com/etotem/cfc/service/DailyOverviewService.java` - Test: `cfc-backend/src/test/java/com/etotem/cfc/service/DailyOverviewServiceTest.java` **Interfaces:** - Consumes: 现有 service 方法(本文件列出签名,任务内逐一使用) - Produces: `DailyOverviewService.aggregate(Long userId, Long memberId, Long familyId)` → `DailyOverviewVO`(Task 2 的 Controller 调用) - Produces: `DailyOverviewVO` 结构(checkins/tasks/activities/assessments/hasMore),Task 3 前端按此渲染 **前提校验步骤(开始编码前执行):** - [ ] 步骤 0: 确认实体字段名(若与下方代码不同则替换字段名,方法签名不变): - `Resource: HealthCheckin.getCheckinDate()`(java.util.Date,列 checkin_date,MicroActionService.java:187-193 已用 `DATE(checkin_date)`) - `Resource: FinanceCheckin.getCheckinDate()`(java.util.Date,同列名约定) - `Resource: EmotionCheckinVO.getCheckinDate()`(java.util.Date,dto/EmotionCheckinVO.java:18,与 health/finance 统一用 checkinDate) - `Resource: ArticleReadingRecord.getChildId()`(列 child_id)与 `getReadAt()`(列 read_at,ArticleService.java:321 已用 `DATE(read_at)=CURDATE()`) - `Resource: AssessmentRecord.getStatus()/getChildId()`、`DanReportUploadVO.getDraftStatus()`(dto/DanReportUploadVO.java:26,常量 `DanReportUpload.DRAFT_PENDING="pending"`,entity/DanReportUpload.java:71) - 若某字段名不同,改下方代码中对应字段引用,其余不动 - [ ] **Step 1: 写失败测试** 创建 `DailyOverviewServiceTest.java`: ```java package com.etotem.cfc.service; import com.etotem.cfc.common.Result; import com.etotem.cfc.dto.DailyOverviewVO; import com.etotem.cfc.dto.DanReportUploadVO; import com.etotem.cfc.entity.AssessmentRecord; import com.etotem.cfc.entity.Task; import com.etotem.cfc.mapper.ArticleReadingRecordMapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import javax.annotation.Resource; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.when; @SpringBootTest public class DailyOverviewServiceTest { @Resource private DailyOverviewService service; @MockBean private HealthCheckinService healthCheckinService; @MockBean private FinanceCheckinService financeCheckinService; @MockBean private EmotionCheckinService emotionCheckinService; @MockBean private GrowthTaskService growthTaskService; @MockBean private TaskService taskService; @MockBean private ArticleReadingRecordMapper articleReadingRecordMapper; @MockBean private AssessmentService assessmentService; @MockBean private DanReportUploadService danReportUploadService; @Test public void aggregate_四类汇总_全部正确() { // 打卡:全部空列表 → 均未完成 when(healthCheckinService.getCheckins(any(), any(), any())).thenReturn(Collections.emptyList()); when(financeCheckinService.getCheckins(any(), any(), any())).thenReturn(Collections.emptyList()); when(emotionCheckinService.getCheckinList(any(), anyInt(), anyInt(), any(), anyBoolean())) .thenReturn(Collections.emptyList()); // 任务:待接受 1 条;成长任务 已完成1 + 待完成1 when(taskService.getMyPendingTasks(10L)) .thenReturn(Collections.singletonList(new Task())); Map doneTask = new HashMap<>(); doneTask.put("completed", 1); Map todoTask = new HashMap<>(); todoTask.put("completed", 0); when(growthTaskService.getTaskList(eq(1L), anyMap())) .thenReturn(Arrays.asList(doneTask, todoTask)); // 阅读:今日已读 2 条 when(articleReadingRecordMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(2); // 测评:进行中 1(pending 且 childId 匹配);待上传报告 0 AssessmentRecord rec = new AssessmentRecord(); rec.setStatus("pending"); rec.setChildId(10L); when(assessmentService.getFamilyRecords(100L)) .thenReturn(Collections.singletonList(rec)); when(danReportUploadService.listByMember(10L, null)) .thenReturn(Result.success(Collections.emptyList())); DailyOverviewVO vo = service.aggregate(1L, 10L, 100L); assertEquals(10L, vo.getMemberId().longValue()); assertEquals(3, vo.getCheckins().size()); assertFalse(vo.getCheckins().get(0).getDone()); assertEquals(Integer.valueOf(1), vo.getTasks().getPendingAccept()); assertEquals(Integer.valueOf(1), vo.getTasks().getTodoToday()); assertEquals(Integer.valueOf(1), vo.getTasks().getDoneToday()); assertEquals(Integer.valueOf(2), vo.getActivities().getArticleReadToday()); assertEquals(Integer.valueOf(1), vo.getAssessments().getOngoing()); assertEquals(Integer.valueOf(0), vo.getAssessments().getReportPending()); } } ``` - [ ] **Step 2: 运行测试确认失败** ```bash cd cfc-backend && mvn test -Dtest=DailyOverviewServiceTest ``` Expected: FAIL(`DailyOverviewService` 不存在,编译错误)。 - [ ] **Step 3: 创建 DailyOverviewVO** 创建 `cfc-backend/src/main/java/com/etotem/cfc/dto/DailyOverviewVO.java`: ```java package com.etotem.cfc.dto; import lombok.Data; import java.util.List; /** * 今日任务聚合视图(只读) */ @Data public class DailyOverviewVO { private Long memberId; private String date; // yyyy-MM-dd private List checkins; private TaskSummary tasks; private ActivitySummary activities; private AssessmentSummary assessments; private Boolean hasMore = false; // 二期「查看全部」扩展位 @Data public static class CheckinItem { private String key; // health / wealth / mind private String label; // 健康打卡 / 财商打卡 / 情绪打卡 private Boolean done; private String route; // 由后端下发,前端直接 navigateTo } @Data public static class TaskSummary { private Integer pendingAccept; // 家庭任务待接受数 private Integer todoToday; // 今日待完成数(成长任务 DAILY) private Integer doneToday; // 今日已完成数 private String route; // /pages/tasks/tasks } @Data public static class ActivitySummary { private Integer articleReadToday; // 今日已读文章数 private Integer activityAvailable; // v1 恒为 0(预留) private String articleRoute; // /pages/article-center/index private String activityRoute; // /pages/activity/index } @Data public static class AssessmentSummary { private Integer ongoing; // 进行中测评数 private Integer reportPending; // 待上传/待确认报告数 private String route; // /pages/assessment/results } } ``` - [ ] **Step 4: 创建 DailyOverviewService** 创建 `cfc-backend/src/main/java/com/etotem/cfc/service/DailyOverviewService.java`: ```java package com.etotem.cfc.service; import com.etotem.cfc.dto.DailyOverviewVO; import com.etotem.cfc.entity.ArticleReadingRecord; import com.etotem.cfc.entity.AssessmentRecord; import com.etotem.cfc.entity.Task; import com.etotem.cfc.mapper.ArticleReadingRecordMapper; import com.etotem.cfc.dto.DanReportUploadVO; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.text.SimpleDateFormat; import java.util.*; /** * 今日任务聚合服务(只读,零新业务规则,全部复用现有查询) */ @Service public class DailyOverviewService { @Resource private HealthCheckinService healthCheckinService; @Resource private FinanceCheckinService financeCheckinService; @Resource private EmotionCheckinService emotionCheckinService; @Resource private GrowthTaskService growthTaskService; @Resource private TaskService taskService; @Resource private ArticleReadingRecordMapper articleReadingRecordMapper; @Resource private AssessmentService assessmentService; @Resource private DanReportUploadService danReportUploadService; private static final String TODAY_FMT = "yyyy-MM-dd"; public DailyOverviewVO aggregate(Long userId, Long memberId, Long familyId) { String today = new SimpleDateFormat(TODAY_FMT).format(new Date()); DailyOverviewVO vo = new DailyOverviewVO(); vo.setMemberId(memberId); vo.setDate(today); vo.setCheckins(buildCheckins(userId, memberId, today)); vo.setTasks(buildTasks(userId, memberId)); vo.setActivities(buildActivities(memberId)); vo.setAssessments(buildAssessments(memberId, familyId)); vo.setHasMore(false); return vo; } // ---------- 打卡类(最多 3 项:健康/财商/情绪) ---------- private List buildCheckins(Long userId, Long memberId, String today) { List list = new ArrayList<>(); list.add(buildCheckin("health", "健康打卡", "/pages/health/daily-checkin", hasTodayRecord(healthCheckinService.getCheckins(userId, memberId, null), today, o -> ((com.etotem.cfc.entity.HealthCheckin) o).getCheckinDate()))); list.add(buildCheckin("wealth", "财商打卡", "/pages/wealth-sub/checkin", hasTodayRecord(financeCheckinService.getCheckins(userId, memberId, null), today, o -> ((com.etotem.cfc.entity.FinanceCheckin) o).getCheckinDate()))); list.add(buildCheckin("mind", "情绪打卡", "/pages/mind-detail/emotion-checkin", hasTodayRecord(emotionCheckinService.getCheckinList(memberId, 1, 1, userId, true), today, o -> ((com.etotem.cfc.dto.EmotionCheckinVO) o).getCheckinDate()))); return list; } private DailyOverviewVO.CheckinItem buildCheckin(String key, String label, String route, boolean done) { DailyOverviewVO.CheckinItem item = new DailyOverviewVO.CheckinItem(); item.setKey(key); item.setLabel(label); item.setRoute(route); item.setDone(done); return item; } /** 今日过滤:dateGetter 返回 java.util.Date 或 String,统一格式化为 yyyy-MM-dd 后与 today 比较 */ private boolean hasTodayRecord(List records, String today, java.util.function.Function dateGetter) { if (records == null || records.isEmpty()) return false; SimpleDateFormat fmt = new SimpleDateFormat(TODAY_FMT); for (Object rec : records) { Object d = dateGetter.apply(rec); if (d == null) continue; String ds; if (d instanceof java.util.Date) { ds = fmt.format((java.util.Date) d); } else { ds = String.valueOf(d); } if (ds != null && ds.startsWith(today)) return true; } return false; } // ---------- 任务类 ---------- private DailyOverviewVO.TaskSummary buildTasks(Long userId, Long memberId) { DailyOverviewVO.TaskSummary s = new DailyOverviewVO.TaskSummary(); List pending = taskService.getMyPendingTasks(memberId); s.setPendingAccept(pending == null ? 0 : pending.size()); Map params = new HashMap<>(); params.put("type", "DAILY"); List> dailyList = growthTaskService.getTaskList(userId, params); int todo = 0; int done = 0; if (dailyList != null) { for (Map t : dailyList) { Object c = t.get("completed"); boolean isDone = c != null && ("1".equals(String.valueOf(c)) || Boolean.TRUE.equals(c)); if (isDone) done++; else todo++; } } s.setTodoToday(todo); s.setDoneToday(done); s.setRoute("/pages/tasks/tasks"); return s; } // ---------- 阅读活动类 ---------- private DailyOverviewVO.ActivitySummary buildActivities(Long memberId) { DailyOverviewVO.ActivitySummary s = new DailyOverviewVO.ActivitySummary(); Integer count = articleReadingRecordMapper.selectCount( new LambdaQueryWrapper() .eq(ArticleReadingRecord::getChildId, memberId) .apply("DATE(read_at) = CURDATE()")); s.setArticleReadToday(count == null ? 0 : count); s.setActivityAvailable(0); // v1 无现成可参与活动数查询,预留 s.setArticleRoute("/pages/article-center/index"); s.setActivityRoute("/pages/activity/index"); return s; } // ---------- 测评报告类 ---------- private DailyOverviewVO.AssessmentSummary buildAssessments(Long memberId, Long familyId) { DailyOverviewVO.AssessmentSummary s = new DailyOverviewVO.AssessmentSummary(); int ongoing = 0; if (familyId != null) { List records = assessmentService.getFamilyRecords(familyId); if (records != null) { for (AssessmentRecord r : records) { if ("pending".equals(r.getStatus()) && memberId.equals(r.getChildId())) { ongoing++; } } } } s.setOngoing(ongoing); int reportPending = 0; try { com.etotem.cfc.common.Result> uploadRes = danReportUploadService.listByMember(memberId, null); if (uploadRes != null && uploadRes.getCode() == 200 && uploadRes.getData() != null) { for (DanReportUploadVO vo : uploadRes.getData()) { if ("pending".equals(vo.getDraftStatus())) reportPending++; } } } catch (Exception e) { reportPending = 0; // DAN 报告子模块异常不阻断聚合 } s.setReportPending(reportPending); s.setRoute("/pages/assessment/results"); return s; } } ``` - [ ] **Step 5: 运行测试确认通过** ```bash cd cfc-backend && mvn test -Dtest=DailyOverviewServiceTest ``` Expected: PASS(若个别 field 名与步骤 0 校验不符,按步骤 0 结论修正引用后重跑)。 - [ ] **Step 6: 提交** ```bash git add cfc-backend/src/main/java/com/etotem/cfc/dto/DailyOverviewVO.java \ cfc-backend/src/main/java/com/etotem/cfc/service/DailyOverviewService.java \ cfc-backend/src/test/java/com/etotem/cfc/service/DailyOverviewServiceTest.java git commit -m "feat(backend): 新增今日任务聚合服务 DailyOverviewService + DailyOverviewVO - 聚合打卡/任务/阅读活动/测评报告四类今日状态 - 纯只读,全部复用现有 service/mapper 查询逻辑 - 单元测试 DailyOverviewServiceTest(@SpringBootTest + @MockBean)" ``` --- ### Task 2: 后端聚合接口 — DailyTaskController **Files:** - Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/DailyTaskController.java` - Test: `cfc-backend/src/test/java/com/etotem/cfc/controller/DailyTaskControllerTest.java` **Interfaces:** - Consumes: `DailyOverviewService.aggregate(userId, memberId, familyId)`(Task 1) - Produces: `POST /api/daily-task/overview` → `Result`(Task 3 前端调用) - [ ] **Step 1: 前置检查(AGENTS.md 强制)** ```bash cd cfc-backend && mvn clean compile # 检查路由重复(确认 /api/daily-task/overview 未被占用) grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | Select-String 'daily-task' # 检查 Bean 命名冲突(确认无同名 DailyTaskController/DailyOverviewService) Get-ChildItem -Recurse cfc-backend/src/main/java -Filter 'DailyTaskController.java' | Select-Object FullName Get-ChildItem -Recurse cfc-backend/src/main/java -Filter 'DailyOverviewService.java' | Select-Object FullName ``` Expected: 编译通过;路由无占用;Bean 无重名。 - [ ] **Step 2: 写失败测试** 创建 `cfc-backend/src/test/java/com/etotem/cfc/controller/DailyTaskControllerTest.java`: ```java package com.etotem.cfc.controller; import com.etotem.cfc.common.Result; import com.etotem.cfc.dto.DailyOverviewVO; import com.etotem.cfc.service.DailyOverviewService; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import javax.annotation.Resource; import java.util.Collections; import java.util.HashMap; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; @SpringBootTest public class DailyTaskControllerTest { @Resource private DailyTaskController controller; @MockBean private DailyOverviewService dailyOverviewService; @Test public void overview_成功_返回聚合视图() { DailyOverviewVO vo = new DailyOverviewVO(); vo.setMemberId(10L); vo.setCheckins(Collections.emptyList()); when(dailyOverviewService.aggregate(eq(1L), eq(10L), eq(100L))).thenReturn(vo); Map params = new HashMap<>(); params.put("memberId", 10L); Result result = controller.overview(params, 1L, 10L, 100L); assertEquals(200, result.getCode()); assertEquals(10L, result.getData().getMemberId().longValue()); } @Test public void overview_memberId为null_返回错误() { Map params = new HashMap<>(); Result result = controller.overview(params, 1L, null, 100L); assertEquals(500, result.getCode()); assertEquals("memberId不能为空", result.getMessage()); } } ``` - [ ] **Step 3: 运行测试确认失败** ```bash cd cfc-backend && mvn test -Dtest=DailyTaskControllerTest ``` Expected: FAIL(`DailyTaskController` 不存在)。 - [ ] **Step 4: 创建 DailyTaskController** 创建 `cfc-backend/src/main/java/com/etotem/cfc/controller/DailyTaskController.java`(参考 TaskController.getTodayTasks 与 DailyCheckinController.list 的骨架): ```java package com.etotem.cfc.controller; import com.etotem.cfc.common.Result; import com.etotem.cfc.dto.DailyOverviewVO; import com.etotem.cfc.service.DailyOverviewService; import io.swagger.v3.oas.annotations.Operation; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestAttribute; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import javax.annotation.Resource; import java.util.Map; /** * 今日任务聚合接口(只读) */ @RestController @RequestMapping("/api/daily-task") public class DailyTaskController { @Resource private DailyOverviewService dailyOverviewService; @Operation(summary = "今日任务总览(打卡/任务/阅读活动/测评报告四类)") @PostMapping("/overview") public Result overview( @RequestBody(required = false) Map params, @RequestAttribute("userId") Long userId, @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId, @RequestAttribute(value = "familyId", required = false) Long familyId) { Long memberId = (params != null && params.get("memberId") != null) ? Long.valueOf(params.get("memberId").toString()) : currentMemberId; if (memberId == null) { return Result.error("memberId不能为空"); } return Result.success(dailyOverviewService.aggregate(userId, memberId, familyId)); } } ``` - [ ] **Step 5: 运行测试确认通过** ```bash cd cfc-backend && mvn test -Dtest=DailyTaskControllerTest ``` Expected: PASS。 - [ ] **Step 6: 全量验证** ```bash cd cfc-backend && mvn clean compile && mvn test ``` Expected: 编译通过 + 全部测试通过(含既有 70 个测试文件的回归)。 - [ ] **Step 7: 提交** ```bash git add cfc-backend/src/main/java/com/etotem/cfc/controller/DailyTaskController.java \ cfc-backend/src/test/java/com/etotem/cfc/controller/DailyTaskControllerTest.java git commit -m "feat(backend): 新增今日任务聚合接口 POST /api/daily-task/overview - memberId 回退链:请求体 memberId → currentMemberId → userId(成长任务) - 统一 @PostMapping + Result,只读不写库 - 单元测试 DailyTaskControllerTest" ``` --- ### Task 3: 前端 API 封装 + 今日任务卡片组件 **Files:** - Modify: `cfc-frontend/utils/api.js`(末尾追加导出函数) - Create: `cfc-frontend/components/daily-task-card.vue` - Test: `node --check`(语法校验,AGENTS.md:禁止 npm build) **Interfaces:** - Consumes: 后端 `POST /api/daily-task/overview`(Task 2),响应 `DailyOverviewVO` 结构 - Produces: `getDailyTaskOverview()`(api.js)、`` 组件(Task 4 挂载首页) - [ ] **Step 1: api.js 追加导出函数** 在 `cfc-frontend/utils/api.js` 末尾追加(参考现有 `getTodayTasks` 第 463-465 行风格): ```js /** * 今日任务总览(首页卡片聚合:打卡/任务/阅读活动/测评报告) */ export const getDailyTaskOverview = () => { return request('/api/daily-task/overview', 'POST', {}) } ``` - [ ] **Step 2: 创建 daily-task-card.vue** 创建 `cfc-frontend/components/daily-task-card.vue`(参考 ProfileGrowth.vue 的 onShow 拉数据 + uni.navigateTo 模式): ```vue ``` - [ ] **Step 3: 语法校验** ```bash # 提取 script 块做 node --check(AGENTS.md:不打包) node -e "const fs=require('fs');const s=fs.readFileSync('cfc-frontend/components/daily-task-card.vue','utf8');const m=s.match(/