Selaa lähdekoodia

docs/plan: 打卡任务联动今日成长 + 饮食餐次多选方案(C1/C2);任务池追加 C1(前端·今日成长补传taskId) C2(全栈·饮食多选)

Agnes Agent 2 viikkoa sitten
vanhempi
sitoutus
6b098fe60f

+ 115 - 0
docs/superpowers/plans/2026-10-26-checkin-task-growth-linkage-and-diet-multi-meal.md

@@ -0,0 +1,115 @@
+# 方案:打卡任务联动今日成长 + 饮食餐次多选
+
+> **需求原文**(2026-10-26 主理人):
+> "通过任务进行睡眠、运动、饮食等今日成长打卡时,应用同步完成今日成长。饮食任务早、中、晚餐可以多选。"
+>
+> **状态**:代码级核查已完成,待主理人拍板决策点 D1–D3 后派发 opencode 执行。
+> **基线**:`origin/cfclub` `fbca0e5f5`(活动四任务收口提交)。
+
+---
+
+## 一、现状事实基线(核查结论)
+
+### A 类链路:任务 → 打卡 → 今日成长
+
+| 环节 | 位置 | 现状 | 结论 |
+|---|---|---|---|
+| 任务列表页 → 打卡页 | `tasks.vue:560-612` `navigateByAction` | diet/sleep/exercise 分发时带 `?taskId=X&memberId=Y` | **已通** |
+| 打卡页 → 后端 | 三个打卡页 `onLoad` 读 `options.taskId`,submit payload 带 `taskId` | 已透传 | **已通** |
+| 后端回写(带 taskId) | `Health{Meal,Sleep,Exercise}Controller.create` | `isDailyProgress==1` → `updateCheckinTaskProgress`;否则 → `completeTask` | **已通** |
+| 后端回写(不带 taskId) | 同上,else 分支 | `updateProgress(userId, member, taskKey, 1)`,按 title 精确或 `likeRight("[KEY]")` 匹配当日 daily-progress 任务 | **弱匹配** |
+| 每日打卡任务生成 | `RepeatTaskGenerator.generateBuiltinCheckinTasks` | 3 条内置任务 `[DIET]/[SLEEP]/[EXERCISE]`,`targetValue=1`、`isDailyProgress=1`、`category="健康打卡"` | **已通** |
+| **今日成长页 → 打卡页** | `growth-main/index.vue:325-332` `handleProgressClick` | 映射 diet/exercise/sleep **不带 taskId**(仅 emotion 带 memberId) | **缺口** |
+| 今日成长完成度判定 | `growth-main/index.vue:398-468` `loadProgressItems` | 基于记录查询(`getHealthMealList` 等按 `formatDateKey(createdAt)===todayDate`),非任务状态 | 已自动 |
+| 今日成长页刷新 | `growth-main/index.vue:310` `onShow()` → `loadAllData()` | 返回即刷新 | 已自动 |
+
+**A 类实质缺口**:今日成长页入口打卡不传 taskId,后端只能走 `updateProgress(taskKey)` 兜底,该路径**仅命中每日进度型任务**(`isDailyProgress==1` + title 前缀 `[KEY]`)。若用户有一件**一次性健康任务**(非每日进度型,如"完成一次 30 分钟快走"),通过今日成长入口打卡**不会完成任务**。此外,即便走兜底路径,若任务已被其他打卡动作完成(completed),`updateProgress` 也会命中并尝试修改已完成任务(需核查是否幂等)。
+
+### B 类现状:饮食打卡单餐次
+
+| 环节 | 位置 | 现状 |
+|---|---|---|
+| 前端 UI | `diet-checkin.vue:24-28` | `TagChip v-for="m in mealTypes"` 单选,`:active="form.mealType === m.value"` |
+| 前端数据 | `diet-checkin.vue:190-205` | `form.mealType: ''` 单值;`mealTypes` 四项 breakfast/lunch/dinner/snack |
+| 前端校验 | `diet-checkin.vue:449+` `submitCheckin` | `if (!self.form.mealType) toast('请选择餐次')` |
+| 前端历史展示 | `diet-checkin.vue:121,283` | `mealTypeLabel(item.mealType)` 单值映射 |
+| 后端实体 | `HealthMealRecord.java:22` | `private String mealType` 单值 |
+| 后端校验 | `HealthMealController.java:224+` `create` | `mealType` 空 → `"请选择餐次"` |
+| **数据库列** | `DatabaseInitializer:7087` | `meal_type VARCHAR(32)` — **足够存逗号拼接多值,无需迁移** |
+| 历史记录列表 | `diet/records.vue:147-148` | `:class="'meal-' + r.mealType"` + `mealLabel(r.mealType)` |
+| 记录详情 | `diet/record-detail.vue:17` | 直接显示 `{{ record.mealType }}`(原始值) |
+| 另一打卡入口 | `health/diet-index.vue:83` | 同为单选(需评估是否同步改) |
+| CF 发放 | `earnMealCheckinCf(memberId, recordId)` | 按记录 ID 幂等,1 记录 = 1 CF |
+
+---
+
+## 二、设计方案
+
+### A 类:今日成长入口补传 taskId
+
+**改动**:`growth-main/index.vue` `handleProgressClick` 为 diet/exercise/sleep 补传当日未完成任务的 taskId。
+
+**取数方案**:在 `loadProgressItems` 阶段一并查询当日该成员对应 actionType 的未完成任务(`status in ('pending','in_progress')`),按 `actionType` 索引到 `progressItems` 上(如 `item.taskId`)。`handleProgressClick` 命中时追加 `?taskId=X&memberId=Y`;无任务时退化为纯记录打卡(不传 taskId,走 `updateProgress` 兜底)。
+
+**后端无需改动**:`updateCheckinTaskProgress` / `completeTask` / `updateProgress` 三条路径均已存在且幂等。
+
+**边界**:
+- 一次性健康任务(非 daily-progress)通过今日成长入口打卡时,`completeTask` 会完成它 → 这是期望行为;
+- 每日进度型任务 `targetValue=1` 时,一次打卡即达标 completed → 幂等跳过;
+- 任务归属成员与打卡成员不一致时,后端 `updateCheckinTaskProgress` 已有归属校验跳过(`log.warn`),不串号。
+
+### B 类:饮食餐次多选
+
+**改动**:前端 `form.mealType` (String) → `form.mealTypes` (Array);`TagChip` 改为 toggle 多选;提交时序列化为逗号拼接字符串写回 `form.mealType`(保持后端契约不变)。
+
+**后端**:`HealthMealController.create` 校验改为「逗号拆分后至少一项非空」;其余逻辑(任务回写、CF 发放)**零改动**——因为:
+- 仍是 1 条记录(不拆 N 条)→ CF 仍 1 次;
+- `mealType` 仍为 String(VARCHAR(32) 够用)→ 无 schema 迁移;
+- 任务进度仍 1 次增量(`targetValue=1` 匹配)。
+
+**展示层**:`mealTypeLabel` 改为接收逗号拼接字符串,内部 `split(',')` → 逐项映射 → `join('、')`。两处定义(`diet-checkin.vue:283`、`growth-main/index.vue:386`)都要改。`diet/records.vue` 的 `:class="'meal-' + r.mealType"` 改为取首项(主餐次)以保留样式语义。`diet/record-detail.vue:17` 改为显示 label 而非原始值。
+
+**计数语义(决策点 D1)**:
+- **一次提交多餐次计 1 次进度**(推荐,与 `targetValue=1` 一致);
+- CF 发放 1 次(按记录 ID 幂等,不变)。
+
+### 不改动项(明确边界)
+
+- `RepeatTaskGenerator` 不动(`targetValue=1` 语义正确);
+- `TaskService.updateCheckinTaskProgress` / `updateProgress` 不动;
+- `health_meal_record` 表结构不动(VARCHAR(32) 够用);
+- `health/diet-index.vue`(另一入口)暂不改(属 health 模块旧入口,与本次需求无直接关联;若主理人要求全量对齐再单独派发);
+- 补打(`isBackfill=1`)不参与任务回写与 CF 发放的既有逻辑不动。
+
+---
+
+## 三、决策点(待主理人拍板)
+
+| # | 决策点 | 推荐 | 理由 |
+|---|---|---|---|
+| **D1** | 一次提交多餐次计几次任务进度 | **计 1 次** | 与 `targetValue=1` 一致;任务语义是"今日是否已打卡"而非"打了几顿" |
+| **D2** | 多餐次一次提交 CF 发放几次 | **1 次**(按记录 ID 幂等,现状不变) | 维持现有语义;若改 N 次需同步改 CF 服务契约,超出本次需求 |
+| **D3** | `health/diet-index.vue`(另一饮食打卡入口)是否同步改多选 | **暂不改** | 属 health 模块旧入口,与今日成长链路无关;若需全量对齐单独派发 |
+
+---
+
+## 四、任务拆分与派发
+
+拆为两条独立任务(避免单会话跨全栈过大):
+
+- **C1**(A 类,仅前端):`growth-main/index.vue` 今日成长入口补传当日对应类型未完成任务的 taskId。
+- **C2**(B 类,前后端):饮食打卡餐次多选(前端 form/TagChip/mealTypeLabel + 后端 create 校验)。
+
+**派发纪律**:按既定纪律由巡检派发(不直派);提交后自行 `git commit + git push -u origin feature/<slug>`,不合主干。
+
+---
+
+## 五、验收标准
+
+- C1:今日成长页点击饮食/运动/睡眠打卡 → 跳转带 `taskId` → 打卡成功 → 任务状态变 completed;无当日任务时退化为纯记录打卡不报错;
+- C1:打卡完成后返回今日成长页,对应 progressItem 变 done(`onShow` 刷新);
+- C2:饮食打卡可多选早/中/晚/加餐,提交后记录 `mealType` 为逗号拼接;
+- C2:历史记录列表与详情页正确显示多餐次标签(`早餐、午餐`);
+- C2:任务进度仍计 1 次,`targetValue=1` 时任务立即 completed;
+- C2:CF 发放 1 次(幂等不变);
+- C2:补打场景不受影响。

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 1 - 1
tools/xaf_agent_hub/tasks_backlog.json


Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä