Răsfoiți Sursa

feat(checkin): C2 饮食打卡餐次多选(早/中/晚/加餐可多选,逗号拼接单值,零迁移)

方案:docs/superpowers/plans/2026-10-26-checkin-task-growth-linkage-and-diet-multi-meal.md §一.B / §二.B(C2)

背景:diet-checkin.vue 餐次为 TagChip 单选(form.mealType 单值),一次只能选一餐。
关键约束:health_meal_record.meal_type VARCHAR(32) 足够存多餐次逗号拼接值,
无需 schema 迁移 —— 1 条记录承载多餐次,后端契约(mealType 单值 String)保持不变。

改动清单(5 文件):
- cfc-frontend/pages/growth/diet-checkin.vue
  · form.mealType(单值) → form.mealTypes(数组,初始 []);新增计算属性 mealType
    序列化为逗号拼接字符串(固定按 早餐→午餐→晚餐→加餐 排序,避免点击顺序漂移)
  · TagChip 改 toggle 多选::active 判数组包含,@select 调 toggleMealType
  · submitCheckin 校验改为「mealTypes 至少一项」;payload.mealType 写入序列化字符串
  · mealTypeLabel 改逗号兼容版(split(',') 逐项映射后 join('、'))
  · 卡片标题微调「选择餐次(可多选)」以提示可多选
- cfc-frontend/pages/growth-main/index.vue
  · mealTypeLabel 同步改逗号兼容版(保留 extra→零食 映射)
- cfc-frontend/pages/diet/records.vue
  · :class 取逗号拼接首项 split(',')[0],保留 meal-* 样式语义
  · mealLabel 改逗号兼容版(goDetail 弹窗同步受益)
- cfc-frontend/pages/diet/record-detail.vue
  · 餐次由原始值 {{ record.mealType }} 改为中文标签 {{ mealLabel(record.mealType) }}
- cfc-backend/src/main/java/com/etotem/cfc/controller/HealthMealController.java
  · create 校验改为「按逗号拆分 trim 去空后至少一项」,并回写归一化值
  · 其余逻辑零改动:任务回写、CF 发放(按记录 ID 幂等)、补打校验、成员归属修正

计数语义(不变):一次提交多餐次计 1 次任务进度(targetValue=1 即 completed),
CF 发放 1 次,补打(isBackfill=1)不参与任务回写与 CF 发放。

明确不做:今日成长入口 taskId 补传(属 C1);health/diet-index.vue 另一入口多选
(另案评估);不改 RepeatTaskGenerator / TaskService;不拆 N 条记录;不改补打逻辑;
不新增 DB 迁移。

验证:cfc-backend mvn compile BUILD SUCCESS(1948 源文件);
小程序 4 文件 node --check 全通过、vue-template-compiler 模板编译全通过;
audit-mp-antipatterns ERROR 0;序列化/标签/归一化逻辑 22 项断言全通过。
Sisyphus 2 săptămâni în urmă
părinte
comite
df15b7ac9f

+ 9 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthMealController.java

@@ -225,7 +225,15 @@ public class HealthMealController {
             record.setMemberId(currentMemberId);
         }
         if (record.getMemberId() == null) return Result.error("memberId不能为空");
-        if (record.getMealType() == null || record.getMealType().trim().isEmpty()) return Result.error("请选择餐次");
+        // 餐次支持逗号拼接多选(如 "breakfast,lunch"):拆分 trim 去空后至少一项;
+        // 归一化后回写,保证入库值不含多余空格与空段(meal_type VARCHAR(32) 够用,无需迁移)
+        String mealTypeRaw = record.getMealType() == null ? "" : record.getMealType();
+        String mealType = Arrays.stream(mealTypeRaw.split(","))
+                .map(String::trim)
+                .filter(s -> !s.isEmpty())
+                .collect(Collectors.joining(","));
+        if (mealType.isEmpty()) return Result.error("请选择餐次");
+        record.setMealType(mealType);
         // 内容校验:文字(食物清单/备注)、图片、语音 至少有一项即可
         boolean hasContent = (record.getFoodItems() != null && !record.getFoodItems().trim().isEmpty())
                 || (record.getRemark() != null && !record.getRemark().trim().isEmpty())

+ 14 - 1
cfc-frontend/pages/diet/record-detail.vue

@@ -14,7 +14,7 @@
           <text class="card-title">基本信息</text>
           <view class="info-row">
             <text class="info-label">餐次</text>
-            <text class="info-value">{{ record.mealType }}</text>
+            <text class="info-value">{{ mealLabel(record.mealType) }}</text>
           </view>
           <view class="info-row">
             <text class="info-label">记录方式</text>
@@ -137,6 +137,19 @@ export default {
       var m = d.getMinutes().toString().padStart(2, '0')
       return h + ':' + m
     },
+    mealLabel: function(t) {
+      var m = { breakfast: '早餐', lunch: '午餐', dinner: '晚餐', snack: '加餐' }
+      if (!t) return '饮食'
+      // 兼容逗号拼接多餐次(breakfast,lunch → 早餐、午餐);未知值原样输出
+      var parts = String(t).split(',')
+      var labels = []
+      for (var i = 0; i < parts.length; i++) {
+        var v = parts[i] && parts[i].trim()
+        if (!v) continue
+        labels.push(m[v] || v)
+      }
+      return labels.length > 0 ? labels.join('、') : '饮食'
+    },
     formatDate: function(date) {
       var d = date
       var m = (d.getMonth() + 1).toString().padStart(2, '0')

+ 11 - 2
cfc-frontend/pages/diet/records.vue

@@ -144,7 +144,7 @@
         </view>
         <view class="record-card" v-for="r in records" :key="r.id" @tap="goDetail(r)">
           <view class="record-card-header">
-            <view class="meal-badge" :class="'meal-' + r.mealType">
+            <view class="meal-badge" :class="'meal-' + r.mealType.split(',')[0]">
               <text>{{ mealLabel(r.mealType) }}</text>
             </view>
             <view class="record-meta">
@@ -219,7 +219,16 @@ export default {
     goBack: function() { uni.navigateBack() },
     mealLabel: function(t) {
       var m = { breakfast: '早餐', lunch: '午餐', dinner: '晚餐', snack: '加餐' }
-      return m[t] || t || '饮食'
+      if (!t) return '饮食'
+      // 兼容逗号拼接多餐次(breakfast,lunch → 早餐、午餐);未知值原样输出
+      var parts = String(t).split(',')
+      var labels = []
+      for (var i = 0; i < parts.length; i++) {
+        var v = parts[i] && parts[i].trim()
+        if (!v) continue
+        labels.push(m[v] || v)
+      }
+      return labels.length > 0 ? labels.join('、') : '饮食'
     },
     // 统一组件选中回调:member 已由 memberNormalize 规范化,携带 canonical memberId
     onMemberSelect: function(member) {

+ 10 - 1
cfc-frontend/pages/growth-main/index.vue

@@ -386,7 +386,16 @@ export default {
     },
     mealTypeLabel: function(type) {
       var m = { breakfast: '早餐', lunch: '午餐', dinner: '晚餐', snack: '加餐', extra: '零食' }
-      return m[type] || type || '饮食'
+      if (!type) return '饮食'
+      // 兼容逗号拼接多餐次(breakfast,lunch → 早餐、午餐);未知值原样输出
+      var parts = String(type).split(',')
+      var labels = []
+      for (var i = 0; i < parts.length; i++) {
+        var v = parts[i] && parts[i].trim()
+        if (!v) continue
+        labels.push(m[v] || v)
+      }
+      return labels.length > 0 ? labels.join('、') : '饮食'
     },
     async loadAllData() {
       var self = this

+ 34 - 7
cfc-frontend/pages/growth/diet-checkin.vue

@@ -19,13 +19,13 @@
       <view class="card">
         <view class="card-header">
           <text class="card-icon">🍽️</text>
-          <text class="card-title">选择餐次</text>
+          <text class="card-title">选择餐次(可多选)</text>
         </view>
         <view class="card-body">
           <view class="tag-row">
             <TagChip v-for="m in mealTypes" :key="m.value" :label="m.label"
-              :active="form.mealType === m.value"
-              @select="form.mealType = m.value" />
+              :active="form.mealTypes.indexOf(m.value) > -1"
+              @select="toggleMealType(m.value)" />
           </view>
         </view>
       </view>
@@ -194,7 +194,7 @@ export default {
         { label: '加餐', value: 'snack' }
       ],
       form: {
-        mealType: '',
+        mealTypes: [],
         foodItems: '',
         remark: '',
         photos: [],
@@ -207,6 +207,18 @@ export default {
     }
   },
   computed: {
+    // 餐次多选 → 逗号拼接字符串(后端契约不变:mealType 仍为单值 String,VARCHAR(32) 够用)
+    // 固定按早餐→午餐→晚餐→加餐输出,避免点击顺序影响 records 页首项样式语义
+    mealType: function() {
+      var arr = this.form.mealTypes || []
+      if (arr.length <= 1) return arr.join(',')
+      var order = { breakfast: 0, lunch: 1, dinner: 2, snack: 3 }
+      return arr.slice().sort(function(a, b) {
+        var ia = order[a] === undefined ? 9 : order[a]
+        var ib = order[b] === undefined ? 9 : order[b]
+        return ia - ib
+      }).join(',')
+    },
     recordDurationText: function() {
       if (!this.form.voicePath) return ''
       var s = this.recordSeconds
@@ -280,9 +292,24 @@ export default {
       return d.getFullYear() + '年' + (d.getMonth() + 1) + '月' + d.getDate() + '日 周' + weekdays[d.getDay()]
     },
     goBack: function() { uni.navigateBack() },
+    // 餐次 toggle 多选:已选则移除,未选则追加
+    toggleMealType: function(value) {
+      var arr = this.form.mealTypes
+      var i = arr.indexOf(value)
+      if (i > -1) { arr.splice(i, 1) } else { arr.push(value) }
+    },
     mealTypeLabel: function(type) {
       var m = { breakfast: '早餐', lunch: '午餐', dinner: '晚餐', snack: '加餐' }
-      return m[type] || type || '饮食'
+      if (!type) return '饮食'
+      // 兼容逗号拼接多餐次(breakfast,lunch → 早餐、午餐);未知值原样输出
+      var parts = String(type).split(',')
+      var labels = []
+      for (var i = 0; i < parts.length; i++) {
+        var v = parts[i] && parts[i].trim()
+        if (!v) continue
+        labels.push(m[v] || v)
+      }
+      return labels.length > 0 ? labels.join('、') : '饮食'
     },
     formatDate: function(dateStr) {
       if (!dateStr) return ''
@@ -448,7 +475,7 @@ export default {
     },
     submitCheckin: async function() {
       var self = this
-      if (!self.form.mealType) { uni.showToast({ title: '请选择餐次', icon: 'none' }); return }
+      if (!self.form.mealTypes || self.form.mealTypes.length === 0) { uni.showToast({ title: '请选择餐次', icon: 'none' }); return }
       // 内容校验:文字(食物清单/备注)、图片、语音 至少有一项
       var hasText = (self.form.foodItems && self.form.foodItems.trim()) || (self.form.remark && self.form.remark.trim())
       var hasPhoto = self.form.photos && self.form.photos.length > 0
@@ -475,7 +502,7 @@ export default {
           data: {
             memberId: self.memberId,
             taskId: self.taskId || undefined,
-            mealType: self.form.mealType,
+            mealType: self.mealType,
             foodItems: self.form.foodItems || '',
             remark: self.form.remark || '',
             photoUrls: photoUrls.join(','),