Ver Fonte

Merge remote-tracking branch 'origin/cfclub' into cfclub

jiapu há 1 semana atrás
pai
commit
748413169b

+ 11 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -1,4 +1,4 @@
-package com.etotem.cfc.config;
+package com.etotem.cfc.config;
 
 import com.etotem.cfc.mapper.*;
 import javax.annotation.Resource;
@@ -10884,6 +10884,16 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
 
         // 迁移312: 为 gut_flora 补默认指纹规则(空表会导致指纹引擎永远 unknown)
         seedGutFloraFingerprints();
+
+        // 迁移313: 修复 meal_configs 历史双编码 participant_member_ids(共餐配置前端曾 JSON.stringify 二次编码,存成了 "\"[1,2]\"",
+        //          导致再次进入页面时无法识别选中成员)。仅处理以双引号包裹的值,不触碰正常 "[]..." 格式
+        try {
+            jdbcTemplate.execute("UPDATE meal_configs SET participant_member_ids = JSON_UNQUOTE(participant_member_ids) " +
+                    "WHERE participant_member_ids LIKE '\"%\"' ");
+            log.info("迁移313: 已修复 meal_configs 双编码 participant_member_ids");
+        } catch (Exception e) {
+            log.warn("迁移313: 修复 meal_configs participant_member_ids 失败: {}", e.getMessage());
+        }
     }
 
     private void seedGutFloraFingerprints() {

+ 21 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/DietMealConfigService.java

@@ -44,22 +44,40 @@ public class DietMealConfigService {
 
     public void saveConfig(Long familyId, Map<String, Object> request) {
         String dateType = request.get("date_type") != null ? request.get("date_type").toString() : null;
+        if (dateType == null || dateType.isEmpty()) {
+            throw new IllegalArgumentException("缺少必填参数: date_type");
+        }
+
+        Object configsObj = request.get("configs");
+        if (configsObj instanceof Map) {
+            // 批量格式: {"date_type":"weekday","configs":{"breakfast":"[1,2]","lunch":"[]","dinner":"[3]"}}
+            Map<?, ?> configs = (Map<?, ?>) configsObj;
+            for (Map.Entry<?, ?> entry : configs.entrySet()) {
+                if (entry.getKey() == null || entry.getValue() == null) {
+                    continue;
+                }
+                saveOneConfig(familyId, dateType, entry.getKey().toString(), entry.getValue().toString(), null);
+            }
+            return;
+        }
+
+        // 单条格式(兼容旧调用): {"date_type":"weekday","meal_type":"breakfast","participant_member_ids":"[1,2]","notes":"..."}
         String mealType = request.get("meal_type") != null ? request.get("meal_type").toString() : null;
         String participantMemberIds = request.get("participant_member_ids") != null
                 ? request.get("participant_member_ids").toString() : null;
         String notes = request.containsKey("notes") && request.get("notes") != null
                 ? request.get("notes").toString() : null;
 
-        if (dateType == null || dateType.isEmpty()) {
-            throw new IllegalArgumentException("缺少必填参数: date_type");
-        }
         if (mealType == null || mealType.isEmpty()) {
             throw new IllegalArgumentException("缺少必填参数: meal_type");
         }
         if (participantMemberIds == null) {
             throw new IllegalArgumentException("缺少必填参数: participant_member_ids");
         }
+        saveOneConfig(familyId, dateType, mealType, participantMemberIds, notes);
+    }
 
+    private void saveOneConfig(Long familyId, String dateType, String mealType, String participantMemberIds, String notes) {
         LambdaQueryWrapper<MealConfig> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(MealConfig::getFamilyId, familyId)
                .eq(MealConfig::getConfigDateType, dateType)

+ 87 - 55
cfc-frontend/pages/diet/meal-config.vue

@@ -31,8 +31,10 @@
             :class="{'member-selected': member.selected}"
             @tap="toggleMember(meal.value, member.id)">
             <view class="member-check" v-if="member.selected">✓</view>
-            <text class="member-avatar" :class="member._avatarCls">{{ member.nickname ? member.nickname.substring(0, 1) : '?' }}</text>
-            <text class="member-name">{{ member.nickname || '未知' }}</text>
+            <view class="member-info">
+              <text class="member-avatar" :class="member._avatarCls">{{ member.nickname ? member.nickname.substring(0, 1) : '?' }}</text>
+              <text class="member-name">{{ member.nickname || '未知' }}</text>
+            </view>
           </view>
           <view class="member-empty" v-if="meal.members.length === 0">
             <text class="member-empty-text">暂无可选的共餐人</text>
@@ -135,7 +137,7 @@ export default {
       this.mealTypes.forEach(function(meal) {
         getMealConfig({ date_type: self.dateType, meal_type: meal.value }).then(function(config) {
           if (config) {
-            self.mealConfigs[meal.value] = config
+            self.$set(self.mealConfigs, meal.value, config)
           }
         })
       })
@@ -169,18 +171,21 @@ export default {
       var self = this
       this.saving = true
       
-      var promises = this.mealTypes.map(function(meal) {
+      var configs = {}
+      this.mealTypes.forEach(function(meal) {
         var config = self.mealConfigs[meal.value] || {}
-        return saveMealConfig({
-          date_type: self.dateType,
-          meal_type: meal.value,
-          participant_member_ids: JSON.stringify(config.participantMemberIds || '[]')
-        })
+        configs[meal.value] = config.participantMemberIds || '[]'
       })
       
-      Promise.all(promises).then(function() {
+      saveMealConfig({
+        date_type: self.dateType,
+        configs: configs
+      }).then(function() {
         self.saving = false
         uni.showToast({ title: '保存成功', icon: 'success' })
+        setTimeout(function() {
+          uni.navigateBack()
+        }, 1000)
       }).catch(function() {
         self.saving = false
         uni.showToast({ title: '保存失败', icon: 'none' })
@@ -194,13 +199,11 @@ export default {
 </script>
 
 <style scoped>
-/* ===== 页面令牌(暖灶·家的味道) ===== */
+/* ===== 页面令牌(品牌蓝·专业信任) ===== */
 .meal-config-page {
   box-sizing: border-box;
-}
-.meal-config-page {
   min-height: 100vh;
-  background: linear-gradient(180deg, #FFF7ED 0%, #FFF3E6 100%);
+  background: #F5F9FC;
   overflow: hidden;
 }
 .content {
@@ -210,101 +213,112 @@ export default {
   overflow: hidden;
 }
 .date-tabs {
-  display: flex; background: #FFF1E6; border-radius: 999rpx;
+  display: flex; background: #E2E8F0; border-radius: 999rpx;
   padding: 6rpx; margin-bottom: 30rpx;
 }
 .tab-item {
   flex: 1; text-align: center; padding: 18rpx 0;
-  border-radius: 999rpx; font-size: 28rpx; color: #C2410C;
+  border-radius: 999rpx; font-size: 28rpx; color: #64748B;
+  transition: all 0.2s ease;
 }
 .tab-active {
-  background: linear-gradient(135deg, #FF8C42, #FFB366);
+  background: linear-gradient(135deg, #5B9BD5, #8FC5E8);
   color: #FFFFFF; font-weight: 600;
-  box-shadow: 0 4rpx 12rpx rgba(255, 140, 66, 0.3);
+  box-shadow: 0 4rpx 12rpx rgba(91, 155, 213, 0.3);
 }
 .meal-section {
   background-color: #FFFFFF;
-  border-radius: 20rpx;
+  border-radius: 24rpx;
   margin-bottom: 30rpx;
   overflow: hidden;
-  box-shadow: 0 2rpx 12rpx rgba(255, 140, 66, 0.10);
+  box-shadow: 0 4rpx 16rpx rgba(91, 155, 213, 0.08);
 }
 .meal-header {
   display: flex;
   align-items: center;
   padding: 24rpx 30rpx;
-  border-bottom: 1rpx solid #FDE8D0;
+  border-bottom: 1rpx solid #E2E8F0;
 }
 .meal-icon-wrap { width: 72rpx; height: 72rpx; border-radius: 22rpx; display: flex; align-items: center; justify-content: center; margin-right: 16rpx; }
-.meal-icon-breakfast { background: linear-gradient(135deg, #FFB366, #FFD48A); }
+.meal-icon-breakfast { background: linear-gradient(135deg, #8FC5E8, #D6EAF8); }
 .meal-icon-lunch     { background: linear-gradient(135deg, #FBBF24, #FCD34D); }
 .meal-icon-dinner    { background: linear-gradient(135deg, #A78BFA, #C4B5FD); }
-.meal-count { font-size: 24rpx; color: #C2410C; }
+.meal-count { font-size: 24rpx; color: #5B9BD5; margin-left: auto; }
 .meal-name {
   font-size: 28rpx;
   font-weight: 600;
-  color: #333;
+  color: #1E293B;
 }
 .member-list {
   display: flex;
   flex-wrap: wrap;
-  gap: 16rpx;
+  gap: 20rpx;
   padding: 24rpx 30rpx;
 }
 .member-item {
   box-sizing: border-box; position: relative;
-  display: flex; flex-direction: column; align-items: center; justify-content: center;
-  padding: 16rpx 20rpx; border: 2rpx solid #E0E0E0; border-radius: 20rpx;
-  background: #FAFAFA; min-width: 132rpx;
-  transition: all 0.15s ease;
+  display: flex; align-items: center;
+  padding: 16rpx 24rpx; border: 2rpx solid #E2E8F0; border-radius: 20rpx;
+  background: #FFFFFF; min-width: 160rpx;
+  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+}
+.member-info {
+  display: flex; align-items: center; gap: 12rpx;
 }
 .member-selected {
-  border-color: #FF8C42; border-width: 3rpx;
-  background: #FFF5EB; box-shadow: 0 0 0 4rpx rgba(255, 140, 66, 0.18);
+  border-color: #5B9BD5;
+  background: #5B9BD5;
+  box-shadow: 0 4rpx 12rpx rgba(91, 155, 213, 0.3);
+  transform: translateY(-2rpx);
 }
 .member-check {
   position: absolute;
-  top: -10rpx;
-  right: -10rpx;
-  width: 36rpx;
-  height: 36rpx;
+  top: -8rpx;
+  right: -8rpx;
+  width: 32rpx;
+  height: 32rpx;
   border-radius: 50%;
-  background-color: #FF8C42;
-  color: #FFFFFF;
-  font-size: 22rpx;
-  font-weight: 700;
+  background-color: #FFFFFF;
+  color: #5B9BD5;
+  font-size: 20rpx;
+  font-weight: 900;
   display: flex;
   align-items: center;
   justify-content: center;
   z-index: 2;
+  box-shadow: 0 2rpx 4rpx rgba(0,0,0,0.1);
 }
 .member-avatar {
-  width: 60rpx; height: 60rpx; border-radius: 50%;
+  width: 48rpx; height: 48rpx; border-radius: 50%;
   color: #FFFFFF; display: flex; align-items: center; justify-content: center;
-  font-size: 24rpx; font-weight: 600; margin-bottom: 8rpx;
+  font-size: 24rpx; font-weight: 600;
 }
 .member-avatar-male   { background: linear-gradient(135deg, #5B9BD5, #8FC5E8); }
 .member-avatar-female { background: linear-gradient(135deg, #F472B6, #F9A8D4); }
-.member-avatar-unknown{ background: linear-gradient(135deg, #FF8C42, #FFB366); }
+.member-avatar-unknown{ background: linear-gradient(135deg, #CBD5E1, #E2E8F0); }
 .member-name {
-  font-size: 22rpx;
-  color: #666;
-  max-width: 120rpx;
+  font-size: 24rpx;
+  color: #64748B;
+  max-width: 80rpx;
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
 }
 .member-selected .member-name {
-  color: #FF8C42;
+  color: #FFFFFF;
   font-weight: 600;
 }
+.member-selected .member-avatar {
+  border: 2rpx solid #FFFFFF;
+}
 .member-empty {
   width: 100%;
   padding: 20rpx 0;
+  text-align: center;
 }
 .member-empty-text {
   font-size: 24rpx;
-  color: #999;
+  color: #94A3B8;
 }
 .add-member-entry {
   box-sizing: border-box;
@@ -312,35 +326,53 @@ export default {
   align-items: center;
   justify-content: center;
   background-color: #FFFFFF;
-  border: 2rpx dashed #FF8C42;
+  border: 2rpx dashed #5B9BD5;
   border-radius: 20rpx;
   padding: 24rpx 30rpx;
   margin-bottom: 30rpx;
+  transition: all 0.2s ease;
+}
+.add-member-entry:active {
+  background-color: #F0F7FF;
 }
 .add-member-entry-icon {
   font-size: 30rpx;
-  color: #FF8C42;
+  color: #5B9BD5;
   margin-right: 12rpx;
 }
 .add-member-entry-text {
   font-size: 28rpx;
-  color: #FF8C42;
+  color: #5B9BD5;
   font-weight: 600;
 }
 .add-member-entry-arrow {
   font-size: 30rpx;
-  color: #FF8C42;
+  color: #5B9BD5;
   margin-left: 8rpx;
 }
-.tip-card { background: #FFF1E6; border-radius: 16rpx; padding: 24rpx 30rpx; margin-bottom: 30rpx; }
-.tip-text { font-size: 24rpx; color: #C2410C; line-height: 1.6; }
+.tip-card { 
+  background: #D6EAF8; 
+  border-radius: 16rpx; 
+  padding: 24rpx 30rpx; 
+  margin-bottom: 30rpx; 
+}
+.tip-text { 
+  font-size: 24rpx; 
+  color: #3A7CC4; 
+  line-height: 1.6; 
+}
 .save-area {
   padding: 40rpx 30rpx;
 }
 .save-btn {
-  background: linear-gradient(135deg, #FF8C42, #FFB366);
+  background: linear-gradient(135deg, #5B9BD5, #3A7CC4);
   color: #FFFFFF; text-align: center; padding: 28rpx 0; border-radius: 999rpx;
   font-size: 30rpx; font-weight: 600;
-  box-shadow: 0 6rpx 20rpx rgba(255, 140, 66, 0.25);
+  box-shadow: 0 6rpx 20rpx rgba(91, 155, 213, 0.3);
+  transition: all 0.2s ease;
+}
+.save-btn:active {
+  opacity: 0.9;
+  transform: scale(0.98);
 }
 </style>

+ 2 - 1
docs/superpowers/api/API_REFERENCE.md

@@ -658,7 +658,8 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `POST /api/diet/ingredients/suggest` | 食材推荐(返回 `data.ingredients` 列表) |
 | `POST /api/diet/ingredients/recommend` | 换一批(返回 `data.ingredients`,每次不同) |
 | `POST /api/diet/ingredients/search` | 搜索食材(body `{keyword}`,返回 `data.foods:[{id, name, category}]`) |
-| `POST /api/diet/meals/config` | 餐食配置 |
+| `POST /api/diet/meals/config` | 餐食配置(body: `date_type` + `meal_type`,返回 `data.participantMemberIds` 等) |
+| `POST /api/diet/meals/config/save` | 保存餐食配置,**批量格式**(推荐,单次调用):body `{date_type:"weekday", configs:{breakfast:"[1,2]", lunch:"[]", dinner:"[3]"}}`(各餐次值为成员ID数组的 JSON 字符串);亦兼容**单条格式**:body `{date_type, meal_type, participant_member_ids, notes?}`。前端 `saveMealConfig` 位于 `cfc-frontend/utils/api.js`(2772行) |
 
 ### 4.12 内容(`/api/articles`, `/api/content/*`)