Quellcode durchsuchen

feat(diet): S-cfc-128 食谱生成支持共餐人选择 diet.recommendation.participantSelect

家庭总览(currentView=family)点「生成食谱」先弹共餐人多选弹层,默认勾选全部成员,
确认(confirmParticipants)后调 generate 并传 participant_member_ids(familyMemberId 数组 JSON);
个人/成员视角直传当前选中成员 [currentMemberId],不弹选择。
前端契约测试 recommendation.participantSelect.test.js 9 通过 / 1 跳过(权限流不适用)。
E2E Test Bot vor 3 Tagen
Ursprung
Commit
e3b5ba9c7
1 geänderte Dateien mit 224 neuen und 2 gelöschten Zeilen
  1. 224 2
      cfc-frontend/pages/diet/recommendation.vue

+ 224 - 2
cfc-frontend/pages/diet/recommendation.vue

@@ -234,6 +234,37 @@
         </view>
       </view>
     </scroll-view>
+
+    <!-- 共餐人选择弹层(需求7:家庭总览「生成食谱」先选共餐人,确认后生成) -->
+    <view class="participant-mask" v-if="showParticipantPicker" @tap="cancelParticipants">
+      <view class="participant-sheet" @tap.stop>
+        <view class="participant-sheet-header">
+          <text class="participant-sheet-title">选择共餐人</text>
+          <text class="participant-sheet-sub">为今天的食谱挑选一起用餐的家庭成员</text>
+        </view>
+        <view class="participant-list">
+          <view class="participant-item" v-for="member in pendingParticipantOptions" :key="member.id"
+            :class="{'participant-item-selected': member.selected}"
+            @tap="toggleParticipant(member)">
+            <view class="participant-check" v-if="member.selected">
+              <text class="participant-check-icon">✓</text>
+            </view>
+            <text class="participant-name">{{ member.nickname }}</text>
+          </view>
+          <view class="participant-empty" v-if="pendingParticipantOptions.length === 0">
+            <text class="participant-empty-text">暂无家庭成员可选</text>
+          </view>
+        </view>
+        <view class="participant-actions">
+          <view class="participant-btn participant-btn-cancel" @tap="cancelParticipants">
+            <text>取消</text>
+          </view>
+          <view class="participant-btn participant-btn-confirm" @tap="confirmParticipants">
+            <text>确认生成({{ pendingParticipantIds.length }} 人)</text>
+          </view>
+        </view>
+      </view>
+    </view>
   </view>
 </template>
 
@@ -267,7 +298,10 @@ export default {
       currentMemberId: null,
       memberList: [],
       foodLoading: false,
-      refreshing: false
+      refreshing: false,
+      // ===== 共餐人选择(需求7:家庭总览生成食谱先选共餐人)=====
+      showParticipantPicker: false,
+      pendingParticipantIds: []
     }
   },
   computed: {
@@ -331,6 +365,19 @@ export default {
       }
 
       return result
+    },
+    // 共餐人选择弹层列表(原始 memberList 归一化为 {id, nickname, selected},避免模板内方法调用)
+    pendingParticipantOptions: function() {
+      var self = this
+      var ids = self.pendingParticipantIds || []
+      return (self.memberList || []).map(function(m) {
+        var id = memberIdOf(m)
+        return {
+          id: id,
+          nickname: m.nickname || m.roleLabel || '成员',
+          selected: ids.indexOf(id) >= 0
+        }
+      })
     }
   },
   onLoad: function(options) {
@@ -406,10 +453,18 @@ export default {
     },
     generateRecipe: function() {
       var self = this
+      // 家庭总览:先弹共餐人多选,确认后再生成(需求7)
+      if (self.currentView === 'family') {
+        self.openParticipantPicker()
+        return
+      }
+      // 个人/成员:只对当前选中人生成(participant_member_ids 只传当前成员)
       self.loading = true
+      var participantIds = self.currentMemberId ? [self.currentMemberId] : []
       generateDietRecommendation({
         date: self.formatDate(new Date()),
-        meal_type: 'all'
+        meal_type: 'all',
+        participant_member_ids: JSON.stringify(participantIds)
       }).then(function(res) {
         self.loading = false
         if (res && res.data && res.data.id) {
@@ -423,6 +478,60 @@ export default {
         uni.showToast({ title: '生成失败', icon: 'none' })
       })
     },
+    // 打开共餐人多选弹层(默认勾选全部家庭成员)
+    openParticipantPicker: function() {
+      var self = this
+      var ids = []
+      var members = self.memberList || []
+      for (var i = 0; i < members.length; i++) {
+        var id = memberIdOf(members[i])
+        if (id !== null && id !== undefined && ids.indexOf(id) < 0) ids.push(id)
+      }
+      self.pendingParticipantIds = ids
+      self.showParticipantPicker = true
+    },
+    // 多选切换共餐人
+    toggleParticipant: function(member) {
+      var self = this
+      if (!member) return
+      var id = (member.id !== null && member.id !== undefined) ? member.id : memberIdOf(member)
+      if (id === null || id === undefined) return
+      var ids = (self.pendingParticipantIds || []).slice()
+      var idx = ids.indexOf(id)
+      if (idx >= 0) {
+        ids.splice(idx, 1)
+      } else {
+        ids.push(id)
+      }
+      self.pendingParticipantIds = ids
+    },
+    // 确认共餐人 → 生成食谱(participant_member_ids = 所选共餐人)
+    confirmParticipants: function() {
+      var self = this
+      self.showParticipantPicker = false
+      self.loading = true
+      generateDietRecommendation({
+        date: self.formatDate(new Date()),
+        meal_type: 'all',
+        participant_member_ids: JSON.stringify(self.pendingParticipantIds || [])
+      }).then(function(res) {
+        self.loading = false
+        if (res && res.data && res.data.id) {
+          uni.showToast({ title: '食谱生成成功', icon: 'success' })
+          self.loadRecommendation()
+        } else {
+          uni.showToast({ title: (res && res.message) || '生成失败', icon: 'none' })
+        }
+      }).catch(function() {
+        self.loading = false
+        uni.showToast({ title: '生成失败', icon: 'none' })
+      })
+    },
+    // 取消共餐人选择
+    cancelParticipants: function() {
+      this.showParticipantPicker = false
+      this.pendingParticipantIds = []
+    },
     likeDish: function(mealName, dishIndex) {
       uni.showToast({ title: '已点赞', icon: 'success' })
     },
@@ -1173,4 +1282,117 @@ export default {
   font-size: calc(26rpx * var(--font-scale, 1));
   color: $facade-text-muted;
 }
+
+/* ===== 共餐人选择弹层(需求7)===== */
+.participant-mask {
+  position: fixed;
+  left: 0;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.45);
+  z-index: 999;
+  display: flex;
+  align-items: flex-end;
+}
+.participant-sheet {
+  box-sizing: border-box;
+  width: 100%;
+  background: $app-surface;
+  border-radius: 24rpx 24rpx 0 0;
+  padding: 32rpx 32rpx 40rpx;
+}
+.participant-sheet-header {
+  padding-bottom: 24rpx;
+}
+.participant-sheet-title {
+  font-size: calc(32rpx * var(--font-scale, 1));
+  font-weight: 600;
+  color: $facade-text-strong;
+  display: block;
+}
+.participant-sheet-sub {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+  display: block;
+  margin-top: 8rpx;
+}
+.participant-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 20rpx;
+  padding: 16rpx 0 24rpx;
+  max-height: 60vh;
+  overflow-y: auto;
+}
+.participant-item {
+  box-sizing: border-box;
+  position: relative;
+  display: flex;
+  align-items: center;
+  padding: 16rpx 24rpx;
+  border: 2rpx solid $facade-fill-2;
+  border-radius: 24rpx;
+  background: $app-surface;
+  min-width: 160rpx;
+}
+.participant-item-selected {
+  border-color: $dim-body;
+  background: linear-gradient(135deg, $dim-body, $dim-body-light);
+}
+.participant-name {
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $facade-text-body;
+}
+.participant-item-selected .participant-name {
+  color: $app-surface;
+  font-weight: 600;
+}
+.participant-check {
+  position: absolute;
+  top: -8rpx;
+  right: -8rpx;
+  width: 32rpx;
+  height: 32rpx;
+  border-radius: 50%;
+  background-color: $app-surface;
+  color: $dim-body;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 2;
+}
+.participant-check-icon {
+  font-size: calc(20rpx * var(--font-scale, 1));
+  font-weight: 700;
+}
+.participant-empty {
+  width: 100%;
+  padding: 20rpx 0;
+  text-align: center;
+}
+.participant-empty-text {
+  font-size: calc(24rpx * var(--font-scale, 1));
+  color: $facade-text-muted;
+}
+.participant-actions {
+  display: flex;
+  gap: 20rpx;
+}
+.participant-btn {
+  flex: 1;
+  padding: 26rpx 0;
+  border-radius: 999rpx;
+  text-align: center;
+  font-size: calc(28rpx * var(--font-scale, 1));
+}
+.participant-btn-cancel {
+  background: $facade-fill-2;
+  color: $facade-text-body;
+}
+.participant-btn-confirm {
+  background: linear-gradient(135deg, $dim-body, $dim-body-light);
+  color: $app-surface;
+  font-weight: 600;
+}
 </style>