Browse Source

feat: 饮食模块前端页面(首页+调研表)

iwt 1 month ago
parent
commit
421d1c42ea

+ 47 - 0
cfc-frontend/pages.json

@@ -1445,6 +1445,53 @@
       }
     ]
   },
+  {
+    "root": "pages/diet",
+    "pages": [
+      {
+        "path": "index",
+        "style": {
+          "navigationBarTitleText": "饮食"
+        }
+      },
+      {
+        "path": "preferences",
+        "style": {
+          "navigationBarTitleText": "饮食偏好调研"
+        }
+      },
+      {
+        "path": "food-query",
+        "style": {
+          "navigationBarTitleText": "食材查询"
+        }
+      },
+      {
+        "path": "meal-config",
+        "style": {
+          "navigationBarTitleText": "共餐配置"
+        }
+      },
+      {
+        "path": "recommendation",
+        "style": {
+          "navigationBarTitleText": "食谱推荐"
+        }
+      },
+      {
+        "path": "records",
+        "style": {
+          "navigationBarTitleText": "饮食记录"
+        }
+      },
+      {
+        "path": "record-detail",
+        "style": {
+          "navigationBarTitleText": "记录详情"
+        }
+      }
+    ]
+  },
   "easycom": {
     "auto": "components/@dcloudio/uni-$2/components/uni-$1/uni-$1.vue",
     "pages": {

+ 536 - 0
cfc-frontend/pages/diet/index.vue

@@ -0,0 +1,536 @@
+<template>
+  <view class="diet-page">
+    <!-- 顶部导航 -->
+    <view class="nav-bar">
+      <view class="nav-back" @tap="goBack">
+        <text class="back-text">‹ 返回</text>
+      </view>
+      <text class="nav-title">饮食</text>
+      <view class="nav-placeholder"></view>
+    </view>
+
+    <scroll-view class="content" scroll-y>
+      <!-- 今日共餐摘要 -->
+      <view class="card meal-summary-card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">今日共餐</text>
+          <view class="meal-row" v-if="mealSummary.breakfast">
+            <text class="meal-label">早餐</text>
+            <text class="meal-members">{{ mealSummary.breakfast }}</text>
+          </view>
+          <view class="meal-row" v-if="mealSummary.lunch">
+            <text class="meal-label">午餐</text>
+            <text class="meal-members">{{ mealSummary.lunch }}</text>
+          </view>
+          <view class="meal-row" v-if="mealSummary.dinner">
+            <text class="meal-label">晚餐</text>
+            <text class="meal-members">{{ mealSummary.dinner }}</text>
+          </view>
+          <text class="empty-hint" v-if="!mealSummary.breakfast && !mealSummary.lunch && !mealSummary.dinner">
+            尚未配置共餐,请先前往配置
+          </text>
+        </view>
+      </view>
+
+      <!-- 食材推荐器 -->
+      <view class="card ingredient-card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <view class="section-header">
+            <text class="section-title">今日食材推荐</text>
+            <view class="action-btns">
+              <view class="btn-secondary" @tap="refreshIngredients">换一批</view>
+              <view class="btn-primary" @tap="showFoodPicker">添加食材</view>
+              <view class="btn-primary" @tap="generateRecipe" :class="{'disabled': selectedIngredients.length === 0}">
+                生成食谱
+              </view>
+            </view>
+          </view>
+          
+          <view class="ingredient-list" v-if="ingredients.length > 0">
+            <view class="ingredient-item" v-for="(item, index) in ingredients" :key="'ing-' + index">
+              <view class="ingredient-info">
+                <text class="ingredient-name">{{ item.name }}</text>
+                <text class="ingredient-reason" v-if="item.reason">{{ item.reason }}</text>
+              </view>
+              <view class="ingredient-remove" @tap="removeIngredient(index)">✕</view>
+            </view>
+          </view>
+          <view class="empty-state" v-else>
+            <text>{{ ingredientsLoading ? '加载中...' : '暂无推荐食材' }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 快捷入口 -->
+      <view class="nav-grid">
+        <view class="nav-item" @tap="navTo('pages/diet/food-query')">
+          <text class="nav-icon">🥗</text>
+          <text class="nav-label">食材查询</text>
+        </view>
+        <view class="nav-item" @tap="navTo('pages/diet/records')">
+          <text class="nav-icon">📝</text>
+          <text class="nav-label">饮食记录</text>
+        </view>
+        <view class="nav-item" @tap="navTo('pages/diet/preferences')">
+          <text class="nav-icon">📋</text>
+          <text class="nav-label">调研表</text>
+        </view>
+        <view class="nav-item" @tap="navTo('pages/diet/meal-config')">
+          <text class="nav-icon">👨‍👩‍👧</text>
+          <text class="nav-label">共餐配置</text>
+        </view>
+        <view class="nav-item" @tap="navTo('pages/diet/recommendation')">
+          <text class="nav-icon">🍽️</text>
+          <text class="nav-label">食谱推荐</text>
+        </view>
+      </view>
+
+      <!-- 本周趋势 -->
+      <view class="card trend-card" v-if="trendData.days > 0">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">本周饮食</text>
+          <view class="trend-stats">
+            <text class="trend-num">{{ trendData.totalCalories || 0 }}</text>
+            <text class="trend-unit">kcal</text>
+            <text class="trend-label">总摄入</text>
+          </view>
+          <view class="trend-stats">
+            <text class="trend-num">{{ trendData.days }}</text>
+            <text class="trend-unit">天</text>
+            <text class="trend-label">记录天数</text>
+          </view>
+        </view>
+      </view>
+    </scroll-view>
+
+    <!-- 食材选择弹窗 -->
+    <view class="modal" v-if="showPicker" @tap="closePicker">
+      <view class="modal-content" @tap.stop>
+        <view class="modal-header">
+          <text class="modal-title">选择食材</text>
+          <text class="modal-close" @tap="closePicker">✕</text>
+        </view>
+        <input class="search-input" placeholder="搜索食材..." v-model="searchQuery" @input="searchFood" />
+        <scroll-view class="food-list" scroll-y>
+          <view class="food-item" v-for="food in searchResults" :key="food.id" @tap="addFood(food)">
+            <text class="food-name">{{ food.name }}</text>
+            <text class="food-category">{{ food.category }}</text>
+          </view>
+        </scroll-view>
+        <view class="empty-state" v-if="searchResults.length === 0 && searchQuery">
+          <text>未找到匹配食材</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getDietIngredients, refreshDietIngredients, addDietIngredient, removeDietIngredient, generateDietRecommendation, getMealConfig } from '@/utils/api.js'
+import { parseDate } from '@/utils/format.js'
+
+export default {
+  data() {
+    return {
+      familyId: null,
+      familyMemberId: null,
+      ingredients: [],
+      ingredientsLoading: false,
+      mealSummary: {},
+      trendData: {},
+      showPicker: false,
+      searchQuery: '',
+      searchResults: []
+    }
+  },
+  onLoad() {
+    this.familyId = uni.getStorageSync('familyId')
+    this.familyMemberId = uni.getStorageSync('currentMemberId')
+    this.loadIngredients()
+    this.loadMealSummary()
+  },
+  onShow() {
+    this.loadIngredients()
+    this.loadMealSummary()
+  },
+  methods: {
+    goBack() {
+      uni.navigateBack()
+    },
+    navTo(url) {
+      uni.navigateTo({ url: url })
+    },
+    loadIngredients() {
+      this.ingredientsLoading = true
+      var self = this
+      getDietIngredients().then(function(res) {
+        self.ingredientsLoading = false
+        if (res && res.ingredients) {
+          self.ingredients = res.ingredients
+        }
+      }).catch(function() {
+        self.ingredientsLoading = false
+      })
+    },
+    refreshIngredients() {
+      var self = this
+      refreshDietIngredients().then(function(res) {
+        if (res && res.ingredients) {
+          self.ingredients = res.ingredients
+        }
+      })
+    },
+    addIngredient(index) {
+      var item = this.ingredients[index]
+      addDietIngredient({ food_id: item.foodId, name: item.name }).then(function(res) {
+        self.loadIngredients()
+      })
+    },
+    removeIngredient(index) {
+      var item = this.ingredients[index]
+      removeDietIngredient({ food_id: item.foodId }).then(function(res) {
+        self.loadIngredients()
+      })
+    },
+    generateRecipe() {
+      var self = this
+      var foodIds = this.ingredients.map(function(item) { return { food_id: item.foodId, name: item.name } })
+      generateDietRecommendation({ 
+        date: self.formatDate(new Date()),
+        meal_type: 'all',
+        selected_foods: JSON.stringify(foodIds)
+      }).then(function(res) {
+        if (res && res.id) {
+          uni.showToast({ title: '食谱生成成功', icon: 'success' })
+          setTimeout(function() {
+            self.navTo('pages/diet/recommendation')
+          }, 1000)
+        }
+      })
+    },
+    formatDate(date) {
+      var d = parseDate(date)
+      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
+    },
+    loadMealSummary() {
+      var self = this
+      var dateType = self.isWeekend(new Date()) ? 'weekend' : 'weekday'
+      Promise.all([
+        getMealConfig({ date_type: dateType, meal_type: 'breakfast' }),
+        getMealConfig({ date_type: dateType, meal_type: 'lunch' }),
+        getMealConfig({ date_type: dateType, meal_type: 'dinner' })
+      ]).then(function(results) {
+        self.mealSummary.breakfast = self.parseMembers(results[0])
+        self.mealSummary.lunch = self.parseMembers(results[1])
+        self.mealSummary.dinner = self.parseMembers(results[2])
+      })
+    },
+    parseMembers(config) {
+      if (!config || !config.participantMemberIds) return ''
+      try {
+        var ids = JSON.parse(config.participantMemberIds)
+        return '共 ' + ids.length + ' 人'
+      } catch (e) {
+        return ''
+      }
+    },
+    isWeekend(date) {
+      var day = date.getDay()
+      return day === 0 || day === 6
+    },
+    showFoodPicker() {
+      this.showPicker = true
+    },
+    closePicker() {
+      this.showPicker = false
+      this.searchQuery = ''
+      this.searchResults = []
+    },
+    searchFood() {
+      if (!this.searchQuery) {
+        this.searchResults = []
+        return
+      }
+      // 简化:实际应调用搜索API
+      this.searchResults = []
+    },
+    addFood(food) {
+      addDietIngredient({ food_id: food.id, name: food.name }).then(function(res) {
+        self.closePicker()
+        self.loadIngredients()
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.diet-page {
+  min-height: 100vh;
+  background-color: #F5F7FA;
+  display: flex;
+  flex-direction: column;
+}
+.nav-bar {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 88rpx;
+  padding-top: env(safe-area-inset-top);
+  background-color: #FFFFFF;
+  position: relative;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+.nav-back {
+  position: absolute;
+  left: 30rpx;
+  top: 50%;
+  transform: translateY(-50%);
+  padding: 10rpx;
+}
+.back-text {
+  font-size: 28rpx;
+  color: #333;
+}
+.nav-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+}
+.nav-placeholder {
+  width: 80rpx;
+}
+.content {
+  flex: 1;
+  padding: 30rpx;
+}
+.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;
+}
+.card-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 20rpx;
+  display: block;
+}
+.section-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+.section-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #333;
+}
+.action-btns {
+  display: flex;
+  gap: 16rpx;
+}
+.btn-secondary {
+  padding: 12rpx 24rpx;
+  border: 1rpx solid #ddd;
+  border-radius: 30rpx;
+  font-size: 24rpx;
+  color: #666;
+}
+.btn-primary {
+  padding: 12rpx 24rpx;
+  background-color: #FF8C42;
+  color: #FFFFFF;
+  border-radius: 30rpx;
+  font-size: 24rpx;
+}
+.btn-primary.disabled {
+  background-color: #ccc;
+}
+.ingredient-list {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+.ingredient-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 16rpx 0;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+.ingredient-info {
+  display: flex;
+  flex-direction: column;
+  gap: 4rpx;
+}
+.ingredient-name {
+  font-size: 26rpx;
+  color: #333;
+  font-weight: 500;
+}
+.ingredient-reason {
+  font-size: 22rpx;
+  color: #999;
+}
+.ingredient-remove {
+  width: 40rpx;
+  height: 40rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 24rpx;
+  color: #999;
+}
+.meal-row {
+  display: flex;
+  justify-content: space-between;
+  padding: 12rpx 0;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+.meal-label {
+  font-size: 26rpx;
+  color: #666;
+}
+.meal-members {
+  font-size: 26rpx;
+  color: #FF8C42;
+  font-weight: 500;
+}
+.empty-hint {
+  font-size: 24rpx;
+  color: #999;
+  text-align: center;
+  padding: 20rpx 0;
+}
+.empty-state {
+  text-align: center;
+  padding: 40rpx 0;
+  font-size: 26rpx;
+  color: #999;
+}
+.nav-grid {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+  margin-bottom: 30rpx;
+}
+.nav-item {
+  width: 30%;
+  background-color: #FFFFFF;
+  border-radius: 20rpx;
+  padding: 30rpx 0;
+  margin-bottom: 20rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
+}
+.nav-icon {
+  font-size: 48rpx;
+  margin-bottom: 12rpx;
+}
+.nav-label {
+  font-size: 24rpx;
+  color: #333;
+}
+.trend-stats {
+  display: flex;
+  align-items: baseline;
+  gap: 8rpx;
+  margin-bottom: 16rpx;
+}
+.trend-num {
+  font-size: 48rpx;
+  font-weight: 600;
+  color: #FF8C42;
+}
+.trend-unit {
+  font-size: 24rpx;
+  color: #999;
+}
+.trend-label {
+  font-size: 24rpx;
+  color: #666;
+  margin-left: auto;
+}
+.modal {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 999;
+}
+.modal-content {
+  width: 90%;
+  max-height: 80%;
+  background-color: #FFFFFF;
+  border-radius: 20rpx;
+  padding: 30rpx;
+}
+.modal-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+.modal-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+}
+.modal-close {
+  font-size: 32rpx;
+  color: #999;
+}
+.search-input {
+  border: 1rpx solid #ddd;
+  border-radius: 30rpx;
+  padding: 16rpx 24rpx;
+  font-size: 26rpx;
+  background-color: #FAFAFA;
+  margin-bottom: 20rpx;
+}
+.food-list {
+  max-height: 500rpx;
+  overflow-y: auto;
+}
+.food-item {
+  display: flex;
+  justify-content: space-between;
+  padding: 20rpx 0;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+.food-name {
+  font-size: 26rpx;
+  color: #333;
+}
+.food-category {
+  font-size: 24rpx;
+  color: #999;
+}
+</style>

+ 400 - 0
cfc-frontend/pages/diet/preferences.vue

@@ -0,0 +1,400 @@
+<template>
+  <view class="preferences-page">
+    <view class="nav-bar">
+      <view class="nav-back" @tap="goBack">
+        <text class="back-text">‹ 返回</text>
+      </view>
+      <text class="nav-title">饮食偏好调研</text>
+      <view class="nav-placeholder"></view>
+    </view>
+
+    <scroll-view class="content" scroll-y>
+      <!-- 硬性要求 -->
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">硬性要求(必填)</text>
+          
+          <view class="form-group">
+            <text class="form-label">过敏原 *</text>
+            <view class="tag-group">
+              <view class="tag-btn" v-for="(tag, index) in allergies" :key="index" @tap="removeTag('allergies', index)">
+                <text>{{ tag }}</text>
+                <text class="tag-remove">×</text>
+              </view>
+            </view>
+            <view class="tag-input-row">
+              <input class="tag-input" placeholder="输入后回车添加" v-model="tagInput" @confirm="addTag('allergies')" />
+            </view>
+          </view>
+
+          <view class="form-group">
+            <text class="form-label">绝对忌口 *</text>
+            <view class="tag-group">
+              <view class="tag-btn" v-for="(tag, index) in absoluteAvoid" :key="index" @tap="removeTag('absoluteAvoid', index)">
+                <text>{{ tag }}</text>
+                <text class="tag-remove">×</text>
+              </view>
+            </view>
+            <view class="tag-input-row">
+              <input class="tag-input" placeholder="输入后回车添加" v-model="tagInput" @confirm="addTag('absoluteAvoid')" />
+            </view>
+          </view>
+
+          <view class="form-group">
+            <text class="form-label">宗教饮食</text>
+            <view class="radio-group">
+              <view class="radio-item" :class="{'radio-active': religiousDiet === 'none'}" @tap="religiousDiet = 'none'">
+                <text>无限制</text>
+              </view>
+              <view class="radio-item" :class="{'radio-active': religiousDiet === 'halal'}" @tap="religiousDiet = 'halal'">
+                <text>清真</text>
+              </view>
+              <view class="radio-item" :class="{'radio-active': religiousDiet === 'vegetarian'}" @tap="religiousDiet = 'vegetarian'">
+                <text>素食</text>
+              </view>
+              <view class="radio-item" :class="{'radio-active': religiousDiet === 'vegan'}" @tap="religiousDiet = 'vegan'">
+                <text>纯素</text>
+              </view>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 软性偏好 -->
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">软性偏好(可选)</text>
+          
+          <view class="form-group">
+            <text class="form-label">辣度偏好</text>
+            <view class="slider-row">
+              <text class="slider-label">不辣</text>
+              <slider class="slider" min="0" max="5" :value="spiceLevel" @change="spiceLevel = $event.detail.value" show-value />
+              <text class="slider-label">特辣</text>
+            </view>
+          </view>
+
+          <view class="form-group">
+            <text class="form-label">口味偏好</text>
+            <view class="tag-group">
+              <view class="tag-btn" v-for="(tag, index) in flavorPref" :key="index" @tap="removeTag('flavorPref', index)">
+                <text>{{ tag }}</text>
+                <text class="tag-remove">×</text>
+              </view>
+            </view>
+            <view class="tag-input-row">
+              <input class="tag-input" placeholder="酸/甜/咸/鲜/苦" v-model="tagInput" @confirm="addTag('flavorPref')" />
+            </view>
+          </view>
+
+          <view class="form-group">
+            <text class="form-label">菜系偏好</text>
+            <view class="tag-group">
+              <view class="tag-btn" v-for="(tag, index) in cuisinePref" :key="index" @tap="removeTag('cuisinePref', index)">
+                <text>{{ tag }}</text>
+                <text class="tag-remove">×</text>
+              </view>
+            </view>
+            <view class="tag-input-row">
+              <input class="tag-input" placeholder="川/粤/淮扬/日料/西餐" v-model="tagInput" @confirm="addTag('cuisinePref')" />
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 健康目标 -->
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">健康目标</text>
+          <text class="hint-text" v-if="systemGoals.length > 0">系统推荐目标(基于健康报告):</text>
+          <view class="tag-group" v-if="systemGoals.length > 0">
+            <view class="tag-btn system-tag" v-for="(goal, index) in systemGoals" :key="index">
+              <text>{{ goal }}</text>
+            </view>
+          </view>
+          <view class="form-group" style="margin-top: 20rpx;">
+            <text class="form-label">自定义目标</text>
+            <view class="tag-group">
+              <view class="tag-btn" v-for="(tag, index) in healthGoals" :key="index" @tap="removeTag('healthGoals', index)">
+                <text>{{ tag }}</text>
+                <text class="tag-remove">×</text>
+              </view>
+            </view>
+            <view class="tag-input-row">
+              <input class="tag-input" placeholder="减脂/增肌/控糖/肠道调理" v-model="tagInput" @confirm="addTag('healthGoals')" />
+            </view>
+          </view>
+          <view class="confirm-btn" @tap="confirmGoals">
+            <text>确认目标</text>
+          </view>
+        </view>
+      </view>
+
+      <view class="submit-area">
+        <view class="submit-btn" @tap="savePreferences">
+          <text>{{ submitting ? '提交中...' : '保存偏好' }}</text>
+        </view>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+import { getDietPreferences, saveDietPreferences } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      familyMemberId: null,
+      allergies: [],
+      absoluteAvoid: [],
+      religiousDiet: 'none',
+      spiceLevel: 0,
+      flavorPref: [],
+      cuisinePref: [],
+      healthGoals: [],
+      systemGoals: [],
+      tagInput: '',
+      submitting: false
+    }
+  },
+  onLoad() {
+    this.familyMemberId = uni.getStorageSync('currentMemberId')
+    this.loadPreferences()
+  },
+  methods: {
+    goBack() {
+      uni.navigateBack()
+    },
+    loadPreferences() {
+      var self = this
+      getDietPreferences().then(function(res) {
+        if (res) {
+          self.allergies = res.allergies || []
+          self.absoluteAvoid = res.absoluteAvoid || []
+          self.religiousDiet = res.religiousDiet || 'none'
+          self.spiceLevel = res.spiceLevel || 0
+          self.flavorPref = res.flavorPref || []
+          self.cuisinePref = res.cuisinePref || []
+          self.healthGoals = res.healthGoals || []
+          self.systemGoals = res.systemGoals || []
+        }
+      })
+    },
+    addTag(field) {
+      var value = this.tagInput.trim()
+      if (value) {
+        this[field].push(value)
+        this.tagInput = ''
+      }
+    },
+    removeTag(field, index) {
+      this[field].splice(index, 1)
+    },
+    confirmGoals() {
+      this.savePreferences()
+    },
+    savePreferences() {
+      var self = this
+      if (this.allergies.length === 0 && this.absoluteAvoid.length === 0) {
+        uni.showToast({ title: '请填写过敏原或忌口', icon: 'none' })
+        return
+      }
+      this.submitting = true
+      saveDietPreferences({
+        allergies: this.allergies,
+        absoluteAvoid: this.absoluteAvoid,
+        religiousDiet: this.religiousDiet,
+        spiceLevel: this.spiceLevel,
+        flavorPref: this.flavorPref,
+        cuisinePref: this.cuisinePref,
+        healthGoals: this.healthGoals.concat(this.systemGoals),
+        goalConfirmed: 1
+      }).then(function(res) {
+        self.submitting = false
+        uni.showToast({ title: '保存成功', icon: 'success' })
+        setTimeout(function() {
+          uni.navigateBack()
+        }, 1000)
+      }).catch(function() {
+        self.submitting = false
+        uni.showToast({ title: '保存失败', icon: 'none' })
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.preferences-page {
+  min-height: 100vh;
+  background-color: #F5F7FA;
+  display: flex;
+  flex-direction: column;
+}
+.nav-bar {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 88rpx;
+  padding-top: env(safe-area-inset-top);
+  background-color: #FFFFFF;
+  position: relative;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+.nav-back {
+  position: absolute;
+  left: 30rpx;
+  top: 50%;
+  transform: translateY(-50%);
+  padding: 10rpx;
+}
+.back-text {
+  font-size: 28rpx;
+  color: #333;
+}
+.nav-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+}
+.nav-placeholder {
+  width: 80rpx;
+}
+.content {
+  flex: 1;
+  padding: 30rpx;
+}
+.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;
+}
+.card-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 24rpx;
+  display: block;
+}
+.form-group {
+  margin-bottom: 24rpx;
+}
+.form-label {
+  font-size: 26rpx;
+  color: #333;
+  font-weight: 500;
+  margin-bottom: 12rpx;
+  display: block;
+}
+.hint-text {
+  font-size: 24rpx;
+  color: #999;
+  margin-bottom: 12rpx;
+  display: block;
+}
+.tag-group {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+  margin-bottom: 12rpx;
+}
+.tag-btn {
+  padding: 12rpx 20rpx;
+  border: 1rpx solid #ddd;
+  border-radius: 30rpx;
+  font-size: 24rpx;
+  color: #666;
+  background-color: #FAFAFA;
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+}
+.tag-btn.system-tag {
+  background-color: #FFF5EB;
+  border-color: #FF8C42;
+  color: #FF8C42;
+}
+.tag-remove {
+  font-size: 28rpx;
+  color: #999;
+  line-height: 1;
+}
+.tag-input-row {
+  margin-top: 12rpx;
+}
+.tag-input {
+  border: 1rpx solid #ddd;
+  border-radius: 30rpx;
+  padding: 12rpx 20rpx;
+  font-size: 26rpx;
+  background-color: #FAFAFA;
+}
+.radio-group {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+.radio-item {
+  padding: 12rpx 24rpx;
+  border: 1rpx solid #ddd;
+  border-radius: 30rpx;
+  font-size: 24rpx;
+  color: #666;
+  background-color: #FAFAFA;
+}
+.radio-active {
+  border-color: #FF8C42;
+  color: #FF8C42;
+  background-color: #FFF5EB;
+}
+.slider-row {
+  display: flex;
+  align-items: center;
+  gap: 20rpx;
+}
+.slider-label {
+  font-size: 24rpx;
+  color: #666;
+  width: 60rpx;
+}
+.slider {
+  flex: 1;
+}
+.confirm-btn {
+  background-color: #E8F5E9;
+  color: #4CAF50;
+  text-align: center;
+  padding: 20rpx 0;
+  border-radius: 12rpx;
+  font-size: 26rpx;
+  margin-top: 20rpx;
+}
+.submit-area {
+  padding: 40rpx 30rpx;
+}
+.submit-btn {
+  background-color: #FF8C42;
+  color: #FFFFFF;
+  text-align: center;
+  padding: 28rpx 0;
+  border-radius: 16rpx;
+  font-size: 30rpx;
+  font-weight: 600;
+}
+</style>

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

@@ -2477,3 +2477,22 @@ export const uploadServiceRoleImage = (filePath) => {
 // 推荐系统
 export const getRecommendList = (data) => request('/api/recommend/list', 'POST', data)
 export const trackRecommendAction = (data) => request('/api/recommend/track', 'POST', data)
+
+// ===== 饮食模块 =====
+export const getDietPreferences = () => request('/api/diet/preferences/current-member', 'POST', {})
+export const saveDietPreferences = (data) => request('/api/diet/preferences/save', 'POST', data)
+export const getDietIngredients = () => request('/api/diet/ingredients/suggest', 'POST', {})
+export const refreshDietIngredients = () => request('/api/diet/ingredients/recommend', 'POST', {})
+export const addDietIngredient = (data) => request('/api/diet/ingredients/add', 'POST', data)
+export const removeDietIngredient = (data) => request('/api/diet/ingredients/remove', 'POST', data)
+export const confirmDietIngredients = (data) => request('/api/diet/ingredients/confirm', 'POST', data)
+export const getDietRecommendation = (data) => request('/api/diet/recommendation/today', 'POST', data)
+export const generateDietRecommendation = (data) => request('/api/diet/recommendation/generate', 'POST', data)
+export const updateDietRecommendation = (data) => request('/api/diet/recommendation/update', 'POST', data)
+export const completeDietRecommendation = (data) => request('/api/diet/recommendation/complete', 'POST', data)
+export const recognizeDietFood = (data) => request('/api/diet/record/recognize', 'POST', data)
+export const saveDietRecord = (data) => request('/api/diet/record/save', 'POST', data)
+export const getDietDailyRecords = (data) => request('/api/diet/record/daily', 'POST', data)
+export const getDietWeeklyTrend = (data) => request('/api/diet/record/weekly', 'POST', data)
+export const getMealConfig = (data) => request('/api/diet/meals/config', 'POST', data)
+export const saveMealConfig = (data) => request('/api/diet/meals/config/save', 'POST', data)