Просмотр исходного кода

feat(diet): 饮食记录重设计 - 周历、食材预警、营养汇总、高频食材、当日记录、补打支持

iwt 1 месяц назад
Родитель
Сommit
155b507644

+ 228 - 14
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthMealController.java

@@ -3,17 +3,20 @@ package com.etotem.cfc.controller;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.HealthMealRecord;
+import com.etotem.cfc.entity.FoodRecommendIndex;
 import com.etotem.cfc.mapper.HealthMealRecordMapper;
+import com.etotem.cfc.mapper.FoodRecommendIndexMapper;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
-import java.util.Date;
-import java.util.List;
-import java.util.Map;
+import java.sql.Date;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+import java.util.stream.Collectors;
 import com.etotem.cfc.service.PointsService;
-import com.etotem.cfc.util.SortUtil;
 import com.etotem.cfc.util.ParamUtils;
 
 @Tag(name = "饮食打卡")
@@ -24,26 +27,180 @@ public class HealthMealController {
     @Resource
     private HealthMealRecordMapper healthMealRecordMapper;
 
+    @Resource
+    private FoodRecommendIndexMapper foodRecommendIndexMapper;
+
     @Resource
     private PointsService pointsService;
 
     @Resource
     private com.etotem.cfc.service.ProfileComputeService profileComputeService;
 
-    @Operation(summary = "获取饮食打卡列表")
+    @Operation(summary = "获取饮食打卡列表(支持日期范围筛选)")
     @PostMapping("/list")
     public Result<List<HealthMealRecord>> list(
             @RequestBody Map<String, Object> params,
             @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
         Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
         if (memberId == null) return Result.error("memberId不能为空");
+        LocalDate startDate = parseDateParam(params.get("startDate"));
+        LocalDate endDate = parseDateParam(params.get("endDate"));
         LambdaQueryWrapper<HealthMealRecord> wrapper = new LambdaQueryWrapper<HealthMealRecord>()
                 .eq(HealthMealRecord::getMemberId, memberId)
                 .orderByDesc(HealthMealRecord::getCreatedAt);
+        if (startDate != null) {
+            wrapper.ge(HealthMealRecord::getRecordDate, Date.valueOf(startDate));
+        }
+        if (endDate != null) {
+            wrapper.le(HealthMealRecord::getRecordDate, Date.valueOf(endDate));
+        }
         return Result.success(healthMealRecordMapper.selectList(wrapper));
     }
 
-    @Operation(summary = "创建饮食打卡")
+    @Operation(summary = "获取某天的饮食记录(按 record_date)")
+    @PostMapping("/daily")
+    public Result<List<HealthMealRecord>> daily(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
+        if (memberId == null) return Result.error("memberId不能为空");
+        LocalDate date = parseDateParam(params.get("date"));
+        if (date == null) date = LocalDate.now();
+        LambdaQueryWrapper<HealthMealRecord> wrapper = new LambdaQueryWrapper<HealthMealRecord>()
+                .eq(HealthMealRecord::getMemberId, memberId)
+                .eq(HealthMealRecord::getRecordDate, Date.valueOf(date))
+                .orderByDesc(HealthMealRecord::getCreatedAt);
+        List<HealthMealRecord> records = healthMealRecordMapper.selectList(wrapper);
+        return Result.success(records);
+    }
+
+    @Operation(summary = "获取某周的食材频次与营养汇总")
+    @PostMapping("/weekly")
+    public Result<Map<String, Object>> weekly(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
+        if (memberId == null) return Result.error("memberId不能为空");
+        LocalDate startDate = parseDateParam(params.get("startDate"));
+        if (startDate == null) startDate = LocalDate.now().minusDays(6);
+        LocalDate endDate = startDate.plusDays(6);
+
+        LambdaQueryWrapper<HealthMealRecord> wrapper = new LambdaQueryWrapper<HealthMealRecord>()
+                .eq(HealthMealRecord::getMemberId, memberId)
+                .ge(HealthMealRecord::getRecordDate, Date.valueOf(startDate))
+                .le(HealthMealRecord::getRecordDate, Date.valueOf(endDate))
+                .orderByDesc(HealthMealRecord::getCreatedAt);
+        List<HealthMealRecord> records = healthMealRecordMapper.selectList(wrapper);
+
+        // 统计:营养汇总 + 食材频次
+        Map<String, Integer> foodCount = new HashMap<>();
+        int totalCalories = 0, totalProtein = 0, totalFat = 0, totalCarbs = 0, totalFiber = 0;
+        for (HealthMealRecord r : records) {
+            if (r.getTotalCalories() != null) totalCalories += r.getTotalCalories();
+            if (r.getProteinG() != null) totalProtein += r.getProteinG();
+            if (r.getFatG() != null) totalFat += r.getFatG();
+            if (r.getCarbsG() != null) totalCarbs += r.getCarbsG();
+            if (r.getFiberG() != null) totalFiber += r.getFiberG();
+            // 解析 food_items 统计食材频次
+            parseFoodItems(r.getFoodItems()).forEach(name -> foodCount.merge(name, 1, Integer::sum));
+        }
+
+        // 取 top 10 食材
+        List<Map<String, Object>> topFoods = foodCount.entrySet().stream()
+                .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
+                .limit(10)
+                .map(e -> {
+                    Map<String, Object> m = new LinkedHashMap<>();
+                    m.put("foodName", e.getKey());
+                    m.put("count", e.getValue());
+                    return m;
+                })
+                .collect(Collectors.toList());
+
+        Map<String, Object> summary = new LinkedHashMap<>();
+        summary.put("startDate", startDate.toString());
+        summary.put("endDate", endDate.toString());
+        summary.put("recordCount", records.size());
+        summary.put("totalCalories", totalCalories);
+        summary.put("totalProtein", totalProtein);
+        summary.put("totalFat", totalFat);
+        summary.put("totalCarbs", totalCarbs);
+        summary.put("totalFiber", totalFiber);
+        summary.put("activeDays", (int) records.stream().map(HealthMealRecord::getRecordDate).distinct().count());
+        summary.put("topFoods", topFoods);
+        summary.put("foodFrequency", topFoods); // 前端复用
+        return Result.success(summary);
+    }
+
+    @Operation(summary = "获取食材摄入预警(过多/偏少)")
+    @PostMapping("/alerts")
+    public Result<Map<String, Object>> alerts(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
+        if (memberId == null) return Result.error("memberId不能为空");
+        LocalDate startDate = parseDateParam(params.get("startDate"));
+        if (startDate == null) startDate = LocalDate.now().minusDays(6);
+        LocalDate endDate = parseDateParam(params.get("endDate"));
+        if (endDate == null) endDate = LocalDate.now();
+
+        LambdaQueryWrapper<HealthMealRecord> wrapper = new LambdaQueryWrapper<HealthMealRecord>()
+                .eq(HealthMealRecord::getMemberId, memberId)
+                .ge(HealthMealRecord::getRecordDate, Date.valueOf(startDate))
+                .le(HealthMealRecord::getRecordDate, Date.valueOf(endDate));
+        List<HealthMealRecord> records = healthMealRecordMapper.selectList(wrapper);
+
+        // 统计该周期内各食材出现次数
+        Map<String, Integer> foodCount = new HashMap<>();
+        for (HealthMealRecord r : records) {
+            parseFoodItems(r.getFoodItems()).forEach(name -> foodCount.merge(name, 1, Integer::sum));
+        }
+
+        // 获取该成员的食材推荐指数
+        Map<String, Integer> recommendMap = new HashMap<>();
+        List<FoodRecommendIndex> indices = foodRecommendIndexMapper.selectList(
+                new LambdaQueryWrapper<FoodRecommendIndex>().eq(FoodRecommendIndex::getUserId, memberId));
+        for (FoodRecommendIndex idx : indices) {
+            recommendMap.put(idx.getFoodName(), idx.getIndexScore());
+        }
+
+        // 分析预警
+        List<Map<String, Object>> overConsumed = new ArrayList<>();
+        List<Map<String, Object>> underConsumed = new ArrayList<>();
+
+        for (Map.Entry<String, Integer> entry : foodCount.entrySet()) {
+            String foodName = entry.getKey();
+            int count = entry.getValue();
+            Integer score = recommendMap.get(foodName);
+            if (score == null) continue;
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("foodName", foodName);
+            item.put("count", count);
+            item.put("recommendScore", score);
+            if (score < 0 && count >= 1) {
+                // 戒备食材(score < 0)却吃了
+                item.put("level", "caution");
+                item.put("reason", "推荐指数 < 0,建议避免,本周吃了 " + count + " 次");
+                overConsumed.add(item);
+            } else if (score > 30 && count <= 1) {
+                // 强推荐食材(score > 30)却没怎么吃
+                item.put("level", "recommend");
+                item.put("reason", "推荐指数 > 30,建议多吃,本周仅吃了 " + count + " 次");
+                underConsumed.add(item);
+            }
+        }
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("startDate", startDate.toString());
+        result.put("endDate", endDate.toString());
+        result.put("overConsumed", overConsumed);
+        result.put("underConsumed", underConsumed);
+        result.put("totalIngredients", foodCount.size());
+        result.put("activeDays", records.stream().map(HealthMealRecord::getRecordDate).filter(Objects::nonNull).distinct().count());
+        return Result.success(result);
+    }
+
+    @Operation(summary = "创建饮食打卡(支持补打:recordDate + isBackfill)")
     @PostMapping("/create")
     public Result<HealthMealRecord> create(@RequestBody HealthMealRecord record,
                                            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
@@ -53,15 +210,29 @@ public class HealthMealController {
         if (record.getMemberId() == null) return Result.error("memberId不能为空");
         if (record.getMealType() == null || record.getMealType().trim().isEmpty()) return Result.error("请选择餐次");
         if (record.getFoodItems() == null || record.getFoodItems().trim().isEmpty()) return Result.error("请填写食物清单");
-        record.setCreatedAt(new Date());
+
+        // 补打逻辑
+        boolean isBackfill = record.getIsBackfill() != null && record.getIsBackfill() == 1;
+        LocalDate recordDate = record.getRecordDate() != null
+                ? record.getRecordDate().toLocalDate()
+                : LocalDate.now();
+        if (isBackfill && recordDate.isAfter(LocalDate.now())) {
+            return Result.error("补打日期不能晚于今天");
+        }
+        record.setRecordDate(java.sql.Date.valueOf(recordDate));
+        record.setIsBackfill(isBackfill ? 1 : 0);
+        record.setCreatedAt(new java.util.Date());
         healthMealRecordMapper.insert(record);
-        // 饮食打卡返积分:每次记录奖励 5 积分(不影响打卡主流程)
-        try {
-            pointsService.awardCheckinPoints(record.getMemberId(), 5, "饮食打卡");
-        } catch (Exception e) {
-            // 积分发放失败不影响打卡记录
+
+        // 只有非补打才发积分
+        if (!isBackfill) {
+            try {
+                pointsService.awardCheckinPoints(record.getMemberId(), 5, "饮食打卡");
+            } catch (Exception e) {
+                // 积分发放失败不影响打卡记录
+            }
         }
-                // 触发画像重算
+        // 触发画像重算
         try {
             Long triggerMemberId = record.getMemberId();
             if (triggerMemberId != null) {
@@ -72,4 +243,47 @@ public class HealthMealController {
         }
         return Result.success(record);
     }
-}
+
+    private LocalDate parseDateParam(Object val) {
+        if (val == null) return null;
+        try {
+            return LocalDate.parse(val.toString(), DateTimeFormatter.ofPattern("yyyy-MM-dd"));
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private List<String> parseFoodItems(String foodItems) {
+        List<String> result = new ArrayList<>();
+        if (foodItems == null || foodItems.trim().isEmpty()) return result;
+        String txt = foodItems.trim();
+        // 尝试按 JSON 解析
+        if (txt.startsWith("[") || txt.startsWith("{")) {
+            try {
+                com.fasterxml.jackson.databind.ObjectMapper om = new com.fasterxml.jackson.databind.ObjectMapper();
+                com.fasterxml.jackson.databind.JsonNode node = om.readTree(txt);
+                if (node.isArray()) {
+                    for (com.fasterxml.jackson.databind.JsonNode n : node) {
+                        String name = n.has("name") ? n.get("name").asText() : n.asText();
+                        if (name != null && !name.trim().isEmpty()) result.add(normalizeFoodName(name));
+                    }
+                }
+            } catch (Exception ignored) {
+            }
+        }
+        // 回退:纯文本分割(、/,/,/;;)
+        if (result.isEmpty()) {
+            String[] parts = txt.split("[、,,;;]");
+            for (String p : parts) {
+                String name = p.trim().replaceAll("^\\d+[克斤两碗份盘个]", "").trim(); // 去掉数量前缀
+                if (!name.isEmpty()) result.add(normalizeFoodName(name));
+            }
+        }
+        return result;
+    }
+
+    private String normalizeFoodName(String name) {
+        // 简单归一化:去除常见单位/数量后缀
+        return name.replaceAll("\\d+[克斤两碗份盘个]$", "").trim();
+    }
+}

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthMealRecord.java

@@ -36,5 +36,11 @@ public class HealthMealRecord {
 
     private String remark;
 
+    /** 用餐日期(补打时为实际用餐日,null=等于创建日) */
+    private java.sql.Date recordDate;
+
+    /** 是否补打: 1=补打(无能量值), 0=当日打卡 */
+    private Integer isBackfill;
+
     private Date createdAt;
 }

+ 429 - 280
cfc-frontend/pages/diet/records.vue

@@ -1,71 +1,173 @@
 <template>
-  <view class="records-page">
+  <view class="page">
+    <view class="member-bar" v-if="memberList.length > 1">
+      <view class="member-tab" :class="{'active': currentMemberId === (item.id || item.userId)}"
+        v-for="item in memberList" :key="item.id || item.userId"
+        @tap="switchMember(item)">
+        <text class="member-name">{{ item.nickname || item.roleLabel || '全部' }}</text>
+      </view>
+    </view>
 
-    <!-- 日期选择 -->
-    <view class="date-picker">
-      <view class="date-item" :class="{'date-active': selectedDate === item}" 
-        v-for="item in dateRange" :key="item" @tap="selectDate(item)">
-        <text class="date-num">{{ getDayNum(item) }}</text>
-        <text class="date-week">{{ getWeekday(item) }}</text>
+    <!-- 周历 -->
+    <view class="week-strip">
+      <view class="week-day" v-for="(d, i) in weekDays" :key="d.date"
+        :class="{'week-day-today': d.today, 'week-day-active': d.date === selectedDate, 'week-day-has': d.hasRecords}">
+        <text class="week-day-label">{{ d.label }}</text>
+        <text class="week-day-num">{{ d.num }}</text>
+        <view class="week-dot-wrap" v-if="d.hasRecords">
+          <view class="week-dot"></view>
+          <text class="week-day-count">{{ d.count }}</text>
+        </view>
       </view>
     </view>
 
     <scroll-view class="content" scroll-y>
-      <!-- 统计卡片 -->
-      <view class="card stats-card">
+      <!-- 食材摄入提示 -->
+      <view class="alert-card" v-if="alertData.overConsumed.length > 0 || alertData.underConsumed.length > 0">
+        <view class="alert-header">
+          <text class="alert-icon">⚠️</text>
+          <text class="alert-title">饮食提示</text>
+        </view>
+        <view class="alert-section" v-if="alertData.overConsumed.length > 0">
+          <text class="alert-label over">摄入偏多 / 需戒备</text>
+          <view class="alert-tags">
+            <view class="alert-tag tag-over" v-for="(item, i) in alertData.overConsumed" :key="i">
+              <text>{{ item.foodName }}</text>
+              <text class="tag-count">{{ item.count }}次</text>
+            </view>
+          </view>
+          <text class="alert-hint">{{ alertData.overConsumed[0] && alertData.overConsumed[0].reason || '' }}</text>
+        </view>
+        <view class="alert-section" v-if="alertData.underConsumed.length > 0">
+          <text class="alert-label under">摄入偏少</text>
+          <view class="alert-tags">
+            <view class="alert-tag tag-under" v-for="(item, i) in alertData.underConsumed" :key="i">
+              <text>{{ item.foodName }}</text>
+              <text class="tag-count">{{ item.count }}次</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 本周营养汇总 -->
+      <view class="card" v-if="weeklySummary.recordCount > 0">
         <view class="card-accent"></view>
         <view class="card-body">
-          <text class="stat-title">{{ selectedDate }} 饮食统计</text>
-          <view class="stat-grid">
+          <text class="card-title">本周饮食汇总({{ weeklySummary.startDate }} ~ {{ weeklySummary.endDate }})</text>
+          <view class="stat-row">
             <view class="stat-item">
-              <text class="stat-value">{{ stats.calories || 0 }}</text>
+              <text class="stat-val">{{ weeklySummary.totalCalories || 0 }}</text>
               <text class="stat-unit">kcal</text>
               <text class="stat-label">总热量</text>
             </view>
             <view class="stat-item">
-              <text class="stat-value">{{ stats.records || 0 }}</text>
-              <text class="stat-unit">次</text>
-              <text class="stat-label">记录次数</text>
+              <text class="stat-val">{{ weeklySummary.activeDays || 0 }}</text>
+              <text class="stat-unit">天</text>
+              <text class="stat-label">打卡天数</text>
+            </view>
+            <view class="stat-item">
+              <text class="stat-val">{{ weeklySummary.totalProtein || 0 }}</text>
+              <text class="stat-unit">g</text>
+              <text class="stat-label">蛋白质</text>
             </view>
             <view class="stat-item">
-              <text class="stat-value">{{ stats.photos || 0 }}</text>
-              <text class="stat-unit">张</text>
-              <text class="stat-label">拍照记录</text>
+              <text class="stat-val">{{ weeklySummary.totalFat || 0 }}</text>
+              <text class="stat-unit">g</text>
+              <text class="stat-label">脂肪</text>
             </view>
           </view>
         </view>
       </view>
 
-      <!-- 记录列表 -->
-      <view class="record-list" v-if="records.length > 0">
-        <view class="record-item" v-for="record in records" :key="record.id" @tap="goDetail(record)">
-          <view class="record-header">
-            <text class="record-meal">{{ record.mealType }}</text>
-            <text class="record-time">{{ formatTime(record.createdAt) }}</text>
+      <!-- 高频食材 -->
+      <view class="card" v-if="weeklySummary.topFoods.length > 0">
+        <view class="card-accent" style="background: linear-gradient(180deg, #FF8C42, #FFB366);"></view>
+        <view class="card-body">
+          <text class="card-title">本周高频食材(前10)</text>
+          <view class="top-foods-list">
+            <view class="top-food-item" v-for="(f, i) in weeklySummary.topFoods" :key="f.foodName">
+              <text class="top-food-rank">{{ i + 1 }}</text>
+              <text class="top-food-name">{{ f.foodName }}</text>
+              <text class="top-food-count">{{ f.count }}次</text>
+            </view>
           </view>
-          <view class="record-body">
-            <view class="record-photo" v-if="record.imageUrl">
-              <image :src="record.imageUrl" mode="aspectFill" class="photo-img" />
+        </view>
+      </view>
+
+      <!-- 日期切换 -->
+      <view class="date-bar">
+        <view class="date-prev" @tap="prevDay">‹ 前一天</view>
+        <text class="date-display">{{ selectedDate }}</text>
+        <view class="date-next" @tap="nextDay">下一天 ›</view>
+      </view>
+
+      <!-- 当日统计 -->
+      <view class="card daily-summary-card" v-if="dailyStats.totalCalories > 0 || dailyStats.totalProtein > 0">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">{{ selectedDate }} 当日统计</text>
+          <view class="stat-row">
+            <view class="stat-item">
+              <text class="stat-val">{{ dailyStats.totalCalories || 0 }}</text>
+              <text class="stat-unit">kcal</text>
+              <text class="stat-label">热量</text>
             </view>
-            <view class="record-info">
-              <text class="record-foods">{{ getFoodsText(record.userConfirmedFoods) }}</text>
-              <text class="record-source" v-if="record.recordMethod === 'photo'">📷 拍照识别</text>
-              <text class="record-source" v-else>✏️ 手动记录</text>
+            <view class="stat-item">
+              <text class="stat-val">{{ dailyStats.totalProtein || 0 }}</text>
+              <text class="stat-unit">g</text>
+              <text class="stat-label">蛋白</text>
+            </view>
+            <view class="stat-item">
+              <text class="stat-val">{{ dailyStats.totalFat || 0 }}</text>
+              <text class="stat-unit">g</text>
+              <text class="stat-label">脂肪</text>
+            </view>
+            <view class="stat-item">
+              <text class="stat-val">{{ dailyStats.totalCarbs || 0 }}</text>
+              <text class="stat-unit">g</text>
+              <text class="stat-label">碳水</text>
             </view>
           </view>
         </view>
       </view>
 
-      <view class="empty-state" v-else>
-        <text class="empty-icon">📝</text>
-        <text class="empty-text">今天还没有饮食记录</text>
-        <view class="add-btn" @tap="addRecord">
-          <text>+ 添加记录</text>
+      <!-- 记录列表(按日期分组) -->
+      <view class="records-section" v-if="records.length > 0">
+        <view class="records-title">
+          <text class="records-label">{{ selectedDate }} 饮食记录</text>
+          <text class="records-count">{{ records.length }} 条</text>
         </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">
+              <text>{{ mealLabel(r.mealType) }}</text>
+            </view>
+            <view class="record-meta">
+              <text class="record-time">{{ formatTime(r.createdAt) }}</text>
+              <text class="record-backfill" v-if="r.isBackfill">补打</text>
+            </view>
+          </view>
+          <view class="record-body">
+            <text class="record-foods">{{ formatFoodItems(r.foodItems) }}</text>
+          </view>
+          <view class="record-footer" v-if="r.totalCalories > 0 || r.proteinG > 0">
+            <text class="record-cal">🔥 {{ r.totalCalories }}kcal</text>
+            <text class="record-nut" v-if="r.proteinG > 0">蛋白 {{ r.proteinG }}g</text>
+          </view>
+          <view class="record-remark" v-if="r.remark">{{ r.remark }}</view>
+        </view>
+      </view>
+      <view class="empty-state" v-else-if="!loading">
+        <text class="empty-icon">🍽️</text>
+        <text class="empty-text">当天暂无饮食记录</text>
+        <text class="empty-hint">点击右下角「+」打卡记录</text>
       </view>
+      <view class="loading-state" v-if="loading">
+        <text class="loading-text">加载中...</text>
+      </view>
+      <view class="bottom-spacer"></view>
     </scroll-view>
 
-    <!-- 底部浮动按钮 -->
     <view class="fab" @tap="addRecord">
       <text class="fab-icon">+</text>
     </view>
@@ -73,285 +175,332 @@
 </template>
 
 <script>
-import { getDietDailyRecords } from '@/utils/api.js'
-import { parseDate } from '@/utils/format.js'
+import { getMealList, getMealDaily, getMealWeekly, getMealAlerts, getCautionFamilyMembers, createMealRecord } from '../../utils/api.js'
+import { parseDate } from '../../utils/format.js'
 
 export default {
   data() {
     return {
+      memberId: null,
+      currentMemberId: null,
+      memberList: [],
       selectedDate: this.formatDate(new Date()),
-      dateRange: [],
+      weekDays: [],
       records: [],
-      stats: {}
+      dailyStats: {},
+      weeklySummary: {},
+      alertData: { overConsumed: [], underConsumed: [] },
+      loading: false,
+      _lastLoadDate: null
     }
   },
-  onLoad: function() {
-    this.initDateRange()
-    this.loadRecords()
+  onLoad: function(options) {
+    this.memberId = options.memberId ? parseInt(options.memberId) : null
+    this.currentMemberId = this.memberId
+    this.loadMemberList()
+    this.initWeekDays()
+    this.loadWeekly()
+    this.loadAlerts()
+    this.loadDayRecords()
   },
   methods: {
-    goBack: function() {
-      uni.navigateBack()
+    goBack: function() { uni.navigateBack() },
+    mealLabel: function(t) {
+      var m = { breakfast: '早餐', lunch: '午餐', dinner: '晚餐', snack: '加餐' }
+      return m[t] || t || '饮食'
+    },
+    switchMember: function(item) {
+      this.currentMemberId = item.id || item.userId
+      this.loadWeekly()
+      this.loadAlerts()
+      this.loadDayRecords()
+    },
+    async loadMemberList() {
+      try {
+        var res = await getCautionFamilyMembers()
+        if (res && res.code === 200 && res.data && res.data.length > 0) {
+          var allItem = { id: -1, userId: -1, nickname: '全部', roleLabel: '全部' }
+          this.memberList = [allItem].concat(res.data)
+          if (!this.currentMemberId) this.currentMemberId = -1
+        }
+      } catch(e) {}
     },
-    initDateRange: function() {
+    initWeekDays: function() {
       var self = this
       var today = new Date()
-      for (var i = 6; i >= 0; i--) {
-        var d = new Date(today)
-        d.setDate(d.getDate() - i)
-        self.dateRange.push(self.formatDate(d))
+      var dayOfWeek = today.getDay() || 7 // 1=周一 7=周日
+      var monday = new Date(today)
+      monday.setDate(today.getDate() - (dayOfWeek - 1))
+      var days = []
+      var labels = ['一', '二', '三', '四', '五', '六', '日']
+      for (var i = 0; i < 7; i++) {
+        var d = new Date(monday)
+        d.setDate(monday.getDate() + i)
+        days.push({
+          date: self.formatDate(d),
+          label: '周' + labels[i],
+          num: d.getDate(),
+          today: self.formatDate(today) === self.formatDate(d),
+          hasRecords: false,
+          count: 0
+        })
       }
-      this.selectedDate = self.dateRange[6]
-    },
-    selectDate: function(date) {
-      this.selectedDate = date
-      this.loadRecords()
+      this.weekDays = days
     },
-    getDayNum: function(dateStr) {
-      var d = parseDate(dateStr)
-      return d ? d.getDate() : ''
+    updateWeekDots: function(records) {
+      var self = this
+      var countByDate = {}
+      for (var i = 0; i < records.length; i++) {
+        var r = records[i]
+        var d = r.recordDate || self.formatDate(r.createdAt)
+        countByDate[d] = (countByDate[d] || 0) + 1
+      }
+      for (var j = 0; j < self.weekDays.length; j++) {
+        var wd = self.weekDays[j]
+        wd.hasRecords = (countByDate[wd.date] || 0) > 0
+        wd.count = countByDate[wd.date] || 0
+      }
+      self.$forceUpdate()
     },
-    getWeekday: function(dateStr) {
-      var days = ['日', '一', '二', '三', '四', '五', '六']
-      var d = parseDate(dateStr)
-      return d ? '周' + days[d.getDay()] : ''
+    loadWeekly: function() {
+      var self = this
+      var mid = self.currentMemberId > 0 ? self.currentMemberId : null
+      getMealWeekly({ memberId: mid, startDate: self.weekDays[0].date, endDate: self.weekDays[6].date })
+        .then(function(res) {
+          if (res && res.code === 200 && res.data) {
+            self.weeklySummary = res.data
+            self.updateWeekDots([])
+          }
+        }).catch(function() {})
     },
-    loadRecords: function() {
+    loadAlerts: function() {
       var self = this
-      var memberId = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || uni.getStorageSync('userId')
-      if (!memberId) return
-      
-      getDietDailyRecords({ memberId: memberId, date: this.selectedDate }).then(function(res) {
-        if (res && res.records) {
-          self.records = res.records
-          self.stats = res
-        }
-      })
+      var mid = self.currentMemberId > 0 ? self.currentMemberId : null
+      getMealAlerts({ memberId: mid, startDate: self.weekDays[0].date, endDate: self.weekDays[6].date })
+        .then(function(res) {
+          if (res && res.code === 200 && res.data) self.alertData = res.data
+        }).catch(function() {})
     },
-    formatTime: function(timeStr) {
-      if (!timeStr) return ''
-      var d = parseDate(timeStr)
-      if (!d) return ''
-      var h = d.getHours().toString().padStart(2, '0')
-      var m = d.getMinutes().toString().padStart(2, '0')
-      return h + ':' + m
+    loadDayRecords: function() {
+      var self = this
+      self.loading = true
+      var mid = self.currentMemberId > 0 ? self.currentMemberId : null
+      getMealDaily({ memberId: mid, date: self.selectedDate })
+        .then(function(res) {
+          self.loading = false
+          if (res && res.code === 200 && res.data) {
+            self.records = (res.data || [])
+            var stats = self._calcDailyStats(self.records)
+            self.dailyStats = stats
+          } else {
+            self.records = []
+            self.dailyStats = {}
+          }
+        })
+        .catch(function() { self.loading = false })
     },
-    getFoodsText: function(foodsJson) {
-      try {
-        var foods = JSON.parse(foodsJson || '[]')
-        return foods.map(function(f) { return f.name }).join('、')
-      } catch (e) {
-        return ''
+    _calcDailyStats: function(records) {
+      var s = { totalCalories: 0, totalProtein: 0, totalFat: 0, totalCarbs: 0, totalFiber: 0 }
+      for (var i = 0; i < records.length; i++) {
+        var r = records[i]
+        if (r.totalCalories != null) s.totalCalories += r.totalCalories
+        if (r.proteinG != null) s.totalProtein += r.proteinG
+        if (r.fatG != null) s.totalFat += r.fatG
+        if (r.carbsG != null) s.totalCarbs += r.carbsG
+        if (r.fiberG != null) s.totalFiber += r.fiberG
       }
+      return s
+    },
+    prevDay: function() {
+      var d = parseDate(this.selectedDate)
+      if (!d) return
+      d.setDate(d.getDate() - 1)
+      this.selectedDate = this.formatDate(d)
+      this.loadDayRecords()
+    },
+    nextDay: function() {
+      var d = parseDate(this.selectedDate)
+      if (!d) return
+      d.setDate(d.getDate() + 1)
+      this.selectedDate = this.formatDate(d)
+      this.loadDayRecords()
     },
     addRecord: function() {
-      uni.navigateTo({ url: 'pages/diet/record-detail' })
+      var url = '/pages/growth/diet-checkin'
+      if (this.selectedDate) url += '?date=' + this.selectedDate
+      uni.navigateTo({ url: url })
     },
     goDetail: function(record) {
-      var id = record.id
-      uni.navigateTo({ url: 'pages/diet/record-detail?id=' + id })
+      // 饮食记录详情暂时直接展示,不跳转
+      var foods = this.formatFoodItems(record.foodItems)
+      var msg = (this.mealLabel(record.mealType) || '') + ' · ' + this.formatTime(record.createdAt)
+      if (foods) msg += '\n' + foods
+      if (record.remark) msg += '\n' + record.remark
+      if (record.isBackfill) msg += '\n(补打,无能量值)'
+      uni.showModal({ title: msg, content: '', showCancel: false })
+    },
+    formatFoodItems: function(txt) {
+      if (!txt) return ''
+      var clean = txt.replace(/^\[|\]$/g, '').replace(/"/g, '')
+      // 尝试解析 JSON
+      try {
+        var arr = JSON.parse(txt)
+        if (Array.isArray(arr)) {
+          return arr.map(function(f) { return f.name || f }).join('、')
+        }
+      } catch(e) {}
+      // 纯文本:去掉数量单位
+      return clean.replace(/\d+[克斤两碗份盘个]/g, '').replace(/[、,,;;]/g, '、').replace(/\s+/g, ' ').trim()
     },
-    formatDate: function(date) {
-      var d = parseDate(date)
+    formatTime: function(ts) {
+      if (!ts) return ''
+      var d = parseDate(ts)
       if (!d) return ''
-      var m = (d.getMonth() + 1).toString().padStart(2, '0')
-      var day = d.getDate().toString().padStart(2, '0')
-      return d.getFullYear() + '-' + m + '-' + day
+      return (d.getHours() || 0).toString().padStart(2, '0') + ':' + (d.getMinutes() || 0).toString().padStart(2, '0')
+    },
+    formatDate: function(d) {
+      if (!d) return ''
+      var date = typeof d === 'string' ? parseDate(d) : d
+      if (!date) return ''
+      var m = (date.getMonth() + 1).toString().padStart(2, '0')
+      var day = date.getDate().toString().padStart(2, '0')
+      return date.getFullYear() + '-' + m + '-' + day
     }
   }
 }
 </script>
 
 <style scoped>
-.records-page {
-  min-height: 100vh;
-  background-color: #F5F7FA;
-  display: flex;
-  flex-direction: column;
-}
-.nav-placeholder {
-  width: 80rpx;
-}
-.date-picker {
-  display: flex;
-  background-color: #FFFFFF;
-  padding: 20rpx 0;
-  overflow-x: auto;
-  white-space: nowrap;
-}
-.date-item {
-  flex-shrink: 0;
-  width: 120rpx;
-  text-align: center;
-  padding: 16rpx 0;
-  margin: 0 10rpx;
-  border-radius: 16rpx;
-}
-.date-active {
-  background-color: #FF8C42;
-  color: #FFFFFF;
-}
-.date-num {
-  font-size: 32rpx;
-  font-weight: 600;
-  display: block;
-}
-.date-week {
-  font-size: 22rpx;
-  opacity: 0.8;
-  display: block;
-  margin-top: 4rpx;
+.page { min-height: 100vh; background: #FFF7ED; display: flex; flex-direction: column; }
+
+/* 成员切换栏 */
+.member-bar {
+  display: flex; overflow-x: auto; padding: 16rpx 30rpx 8rpx; background: #fff;
+  border-bottom: 1rpx solid #f0f0f0; gap: 16rpx; -webkit-overflow-scrolling: touch;
 }
-.content {
-  flex: 1;
-  padding: 30rpx;
-  padding-bottom: 160rpx;
+.member-tab {
+  flex-shrink: 0; padding: 8rpx 20rpx; border-radius: 20rpx; background: #f5f5f5; color: #666;
+  font-size: 26rpx; font-weight: 500;
 }
+.member-tab.active { background: linear-gradient(135deg, #FF8C42, #FFB366); color: #fff; }
+
+/* 周历条 */
+.week-strip {
+  display: flex; align-items: center; justify-content: space-around;
+  background: #fff; padding: 12rpx 20rpx 16rpx; border-bottom: 1rpx solid #f0f0f0;
+}
+.week-day {
+  display: flex; flex-direction: column; align-items: center;
+  padding: 8rpx 12rpx; border-radius: 16rpx; min-width: 68rpx;
+}
+.week-day-today .week-day-label { color: #FF8C42; font-weight: 700; }
+.week-day-active { background: linear-gradient(135deg, #FF8C42, #FFB366); }
+.week-day-active .week-day-label,
+.week-day-active .week-day-num { color: #fff; }
+.week-day-has .week-dot-wrap { margin-top: 6rpx; }
+.week-day-label { font-size: 20rpx; color: #999; }
+.week-day-num { font-size: 28rpx; font-weight: 600; color: #333; }
+.week-dot-wrap { display: flex; align-items: center; gap: 6rpx; }
+.week-dot { width: 10rpx; height: 10rpx; border-radius: 50%; background: #FF8C42; }
+.week-day-count { font-size: 18rpx; color: #C2410C; }
+
+/* 内容区 */
+.content { flex: 1; padding: 24rpx 24rpx 180rpx; overflow-y: auto; }
+
+/* 通用卡片 */
 .card {
-  display: flex;
-  background-color: #FFFFFF;
-  border-radius: 20rpx;
-  margin-bottom: 30rpx;
-  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
-  overflow: hidden;
-}
-.card-accent {
-  width: 8rpx;
-  background-color: #FF8C42;
-  flex-shrink: 0;
-}
-.card-body {
-  flex: 1;
-  padding: 30rpx;
-}
-.stat-title {
-  font-size: 26rpx;
-  color: #999;
-  margin-bottom: 20rpx;
-  display: block;
-}
-.stat-grid {
-  display: flex;
-  justify-content: space-around;
-}
-.stat-item {
-  text-align: center;
-}
-.stat-value {
-  font-size: 40rpx;
-  font-weight: 600;
-  color: #FF8C42;
-  display: block;
-}
-.stat-unit {
-  font-size: 22rpx;
-  color: #999;
-}
-.stat-label {
-  font-size: 22rpx;
-  color: #666;
-  display: block;
-  margin-top: 4rpx;
-}
-.record-list {
-  display: flex;
-  flex-direction: column;
-  gap: 20rpx;
-}
-.record-item {
-  background-color: #FFFFFF;
-  border-radius: 20rpx;
-  padding: 24rpx 30rpx;
-  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
-}
-.record-header {
-  display: flex;
-  justify-content: space-between;
-  margin-bottom: 16rpx;
-}
-.record-meal {
-  font-size: 26rpx;
-  font-weight: 600;
-  color: #333;
-  padding: 4rpx 16rpx;
-  background-color: #FFF5EB;
-  color: #FF8C42;
-  border-radius: 20rpx;
-}
-.record-time {
-  font-size: 24rpx;
-  color: #999;
-}
-.record-body {
-  display: flex;
-  gap: 20rpx;
-}
-.record-photo {
-  width: 120rpx;
-  height: 120rpx;
-  border-radius: 12rpx;
-  overflow: hidden;
-  flex-shrink: 0;
-}
-.photo-img {
-  width: 100%;
-  height: 100%;
-}
-.record-info {
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  justify-content: center;
-  gap: 8rpx;
-}
-.record-foods {
-  font-size: 26rpx;
-  color: #333;
-  line-height: 1.5;
-}
-.record-source {
-  font-size: 22rpx;
-  color: #999;
-}
-.empty-state {
-  text-align: center;
-  padding: 80rpx 40rpx;
-}
-.empty-icon {
-  font-size: 100rpx;
-  display: block;
-  margin-bottom: 20rpx;
-}
-.empty-text {
-  font-size: 26rpx;
-  color: #999;
-  display: block;
-  margin-bottom: 40rpx;
-}
-.add-btn {
-  background-color: #FF8C42;
-  color: #FFFFFF;
-  padding: 24rpx 60rpx;
-  border-radius: 30rpx;
-  font-size: 28rpx;
-  display: inline-block;
+  display: flex; background: #fff; border-radius: 20rpx; margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05); overflow: hidden;
 }
+.card-accent { width: 8rpx; background: linear-gradient(180deg, #FF8C42, #FFB366); flex-shrink: 0; }
+.card-body { flex: 1; padding: 24rpx 28rpx; }
+.card-title { font-size: 28rpx; font-weight: 600; color: #333; margin-bottom: 16rpx; display: block; }
+
+/* 统计行 */
+.stat-row { display: flex; justify-content: space-around; }
+.stat-item { text-align: center; }
+.stat-val { font-size: 36rpx; font-weight: 700; color: #FF8C42; display: block; }
+.stat-unit { font-size: 20rpx; color: #999; }
+.stat-label { font-size: 22rpx; color: #666; display: block; margin-top: 4rpx; }
+
+/* 食材提示 */
+.alert-card {
+  background: #fff; border-radius: 20rpx; margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.05); overflow: hidden;
+}
+.alert-header { display: flex; align-items: center; gap: 12rpx; padding: 24rpx 28rpx 16rpx; border-bottom: 1rpx solid #FFF3E0; }
+.alert-icon { font-size: 36rpx; }
+.alert-title { font-size: 28rpx; font-weight: 600; color: #333; }
+.alert-section { padding: 16rpx 28rpx; }
+.alert-label { font-size: 24rpx; font-weight: 600; display: block; margin-bottom: 12rpx; }
+.alert-label.over { color: #C62828; }
+.alert-label.under { color: #1565C0; }
+.alert-tags { display: flex; flex-wrap: wrap; gap: 12rpx; }
+.alert-tag { display: flex; align-items: center; gap: 6rpx; padding: 8rpx 16rpx; border-radius: 20rpx; font-size: 24rpx; }
+.tag-over { background: #FFEBEE; color: #C62828; }
+.tag-under { background: #E3F2FD; color: #1565C0; }
+.tag-count { font-size: 20rpx; opacity: 0.8; }
+.alert-hint { font-size: 22rpx; color: #888; margin-top: 8rpx; display: block; }
+
+/* 高频食材 */
+.top-foods-list { display: flex; flex-direction: column; gap: 12rpx; }
+.top-food-item { display: flex; align-items: center; gap: 16rpx; }
+.top-food-rank {
+  width: 36rpx; height: 36rpx; border-radius: 50%; background: #FFF1E6; color: #FF8C42;
+  font-size: 22rpx; font-weight: 700; display: flex; align-items: center; justify-content: center;
+}
+.top-food-name { flex: 1; font-size: 26rpx; color: #333; }
+.top-food-count { font-size: 24rpx; color: #999; }
+
+/* 日期切换栏 */
+.date-bar {
+  display: flex; align-items: center; justify-content: space-between;
+  background: #fff; border-radius: 16rpx; padding: 16rpx 24rpx; margin-bottom: 20rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
+}
+.date-prev, .date-next { font-size: 26rpx; color: #FF8C42; font-weight: 600; padding: 8rpx 16rpx; }
+.date-display { font-size: 28rpx; font-weight: 600; color: #333; }
+
+/* 记录卡片 */
+.records-title { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16rpx; }
+.records-label { font-size: 28rpx; font-weight: 600; color: #333; }
+.records-count { font-size: 24rpx; color: #999; }
+
+.record-card {
+  background: #fff; border-radius: 16rpx; padding: 20rpx 24rpx; margin-bottom: 16rpx;
+  box-shadow: 0 2rpx 8rpx rgba(255,140,66,0.06); border-left: 6rpx solid #FF8C42;
+}
+.record-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10rpx; }
+.meal-badge { padding: 6rpx 18rpx; border-radius: 20rpx; font-size: 24rpx; font-weight: 600; }
+.meal-breakfast { background: #FFF3E0; color: #E65100; }
+.meal-lunch     { background: #FFF8E1; color: #F57C00; }
+.meal-dinner    { background: #F3E5F5; color: #7B1FA2; }
+.meal-snack     { background: #E8F5E9; color: #2E7D32; }
+.record-meta { display: flex; align-items: center; gap: 12rpx; }
+.record-time { font-size: 24rpx; color: #999; }
+.record-backfill { font-size: 20rpx; padding: 2rpx 10rpx; background: #FFF3E0; color: #E65100; border-radius: 8rpx; }
+.record-body { margin-bottom: 8rpx; }
+.record-foods { font-size: 26rpx; color: #333; line-height: 1.5; }
+.record-footer { display: flex; gap: 20rpx; margin-bottom: 6rpx; }
+.record-cal { font-size: 22rpx; color: #FF8C42; font-weight: 600; }
+.record-nut { font-size: 22rpx; color: #999; }
+.record-remark { font-size: 24rpx; color: #888; font-style: italic; }
+
+/* 空态 */
+.empty-state { text-align: center; padding: 80rpx 40rpx; }
+.empty-icon { font-size: 80rpx; display: block; margin-bottom: 20rpx; }
+.empty-text { font-size: 28rpx; color: #333; font-weight: 500; display: block; margin-bottom: 8rpx; }
+.empty-hint { font-size: 24rpx; color: #999; display: block; }
+.loading-state { text-align: center; padding: 80rpx 0; }
+.loading-text { font-size: 26rpx; color: #999; }
+.bottom-spacer { height: 40rpx; }
+
+/* 浮动按钮 */
 .fab {
-  position: fixed;
-  right: 40rpx;
-  bottom: 60rpx;
-  width: 100rpx;
-  height: 100rpx;
-  background-color: #FF8C42;
-  border-radius: 50%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  box-shadow: 0 4rpx 20rpx rgba(255, 140, 66, 0.4);
-}
-.fab-icon {
-  font-size: 48rpx;
-  color: #FFFFFF;
-  font-weight: 300;
+  position: fixed; right: 40rpx; bottom: 60rpx; width: 100rpx; height: 100rpx;
+  background: linear-gradient(135deg, #FF8C42, #FFB366); border-radius: 50%;
+  display: flex; align-items: center; justify-content: center;
+  box-shadow: 0 4rpx 20rpx rgba(255,140,66,0.4);
 }
-</style>
+.fab-icon { font-size: 48rpx; color: #fff; font-weight: 300; }
+</style>

+ 28 - 3
cfc-frontend/pages/growth/diet-checkin.vue

@@ -9,6 +9,12 @@
     </view>
 
     <scroll-view class="content" scroll-y>
+      <!-- 补打提示 -->
+      <view class="backfill-banner" v-if="form.isBackfill">
+        <text class="backfill-icon">⏪</text>
+        <text class="backfill-text">补打模式:{{ form.recordDate }} 的记录,无能量值</text>
+      </view>
+
       <!-- 餐次选择 -->
       <view class="card">
         <view class="card-header">
@@ -156,7 +162,9 @@ export default {
         photos: [],
         voicePath: '',
         voiceText: '',
-        voiceDuration: 0
+        voiceDuration: 0,
+        recordDate: '',
+        isBackfill: false
       }
     }
   },
@@ -167,11 +175,16 @@ export default {
       return '录音 ' + s + '秒'
     }
   },
-  onLoad: function() {
+    onLoad: function(options) {
     var role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
     if (role !== 'child') {
       this.memberId = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || uni.getStorageSync('userId') || null
     }
+    // 支持从 records 页传 date 参数(补打)
+    if (options.date) {
+      this.form.recordDate = options.date
+      this.form.isBackfill = true
+    }
     this.recorderManager = uni.getRecorderManager()
     this.recorderManager.onStart(() => {
       this.isRecording = true
@@ -324,7 +337,9 @@ export default {
             remark: self.form.remark || '',
             photoUrls: photoUrls.join(','),
             voiceUrl: voiceUrl,
-            voiceText: self.form.voiceText || ''
+            voiceText: self.form.voiceText || '',
+            recordDate: self.form.recordDate || undefined,
+            isBackfill: self.form.isBackfill ? 1 : 0
           },
           header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
 success: function(res) {
@@ -415,6 +430,16 @@ success: function(res) {
 .submit-btn:active { transform: scale(0.98); }
 .submitting { opacity: 0.6; }
 
+/* ===== 补打横幅 ===== */
+.backfill-banner {
+  display: flex; align-items: center; gap: 12rpx;
+  background: linear-gradient(135deg, #FFF3E0, #FFE0B2);
+  border-bottom: 1rpx solid #FFE082;
+  padding: 16rpx 24rpx;
+}
+.backfill-icon { font-size: 32rpx; }
+.backfill-text { font-size: 26rpx; color: #E65100; font-weight: 500; }
+
 /* ===== 历史记录 ===== */
 .history-list { display: flex; flex-direction: column; gap: 12rpx; }
 .history-item { display: flex; justify-content: space-between; align-items: center; padding: 14rpx 0; border-bottom: 1rpx solid #f5f5f5; }

+ 7 - 0
cfc-frontend/utils/api.js

@@ -2787,6 +2787,13 @@ export const getGutFloraAnalysis = (reportId) => request('/api/nutrition/beijing
 export const getGrowthRecommendations = (category = 'health', limit = 3) =>
   request('/api/growth/recommendations', 'POST', { category, limit })
 
+// ===== 饮食打卡记录(diet-records 重设计) =====
+export const getMealList = (data) => request('/api/health/meal/list', 'POST', data || {})
+export const getMealDaily = (data) => request('/api/health/meal/daily', 'POST', data || {})
+export const getMealWeekly = (data) => request('/api/health/meal/weekly', 'POST', data || {})
+export const getMealAlerts = (data) => request('/api/health/meal/alerts', 'POST', data || {})
+export const createMealRecord = (data) => request('/api/health/meal/create', 'POST', data || {})
+
 // ===== 食材戒备匹配 =====
 // reportId 可选:传了则按报告返回该次检测的戒备食材(报告详情入口);
 // 不传则按成员最新推荐指数返回(成长页当前戒备入口)

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-f5fbaaaabd91af7b0147ff10357dd9307bc1cda4
+e0e5d2bceaa5304f72ac734a71a80c58697face5

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1365",
+  "version": "1.0.1366",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1365",
+      "version": "1.0.1366",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",