فهرست منبع

feat: implement diet recommendation system (frontend)

- Add 5 API methods to mini-program utils/api.js
- Create pages/meal/recommend.vue (AI recipe recommendation)
- Create pages/meal/log.vue (meal logging + nutrition summary)
- Register meal subpackage in pages.json
- Create web admin src/api/diet.js (13 API functions)
- Create admin Foods.vue, Recipes.vue, SeasonalFoods.vue pages
- Add admin diet routes to router/index.js
- Add '饮食管理' menu section to Layout.vue sidebar
Sisyphus 4 ماه پیش
والد
کامیت
ea5aee73e9

+ 14 - 0
cfc-frontend/pages.json

@@ -611,6 +611,20 @@
           "style": { "navigationBarTitleText": "新增保单" }
         }
       ]
+    },
+    // ===== 食谱推荐 =====
+    {
+      "root": "pages/meal",
+      "pages": [
+        {
+          "path": "recommend",
+          "style": { "navigationBarTitleText": "智能食谱推荐" }
+        },
+        {
+          "path": "log",
+          "style": { "navigationBarTitleText": "饮食记录" }
+        }
+      ]
     }
   ],
   "globalStyle": {

+ 337 - 0
cfc-frontend/pages/meal/log.vue

@@ -0,0 +1,337 @@
+<template>
+  <view class="meal-log">
+    <!-- 日期导航 -->
+    <view class="date-nav">
+      <text class="nav-arrow" @click="changeDate(-1)">‹</text>
+      <text class="date-text">{{ currentDate }}</text>
+      <text class="nav-arrow" @click="changeDate(1)">›</text>
+    </view>
+
+    <!-- 记录新饮食 -->
+    <view class="log-form">
+      <view class="form-title">记录饮食</view>
+      <view class="form-item">
+        <text class="label">餐次</text>
+        <picker :value="mealTypeIndex" :range="mealTypes" @change="onMealTypeChange">
+          <view class="picker-value">{{ mealTypes[mealTypeIndex] || '请选择' }}</view>
+        </picker>
+      </view>
+      <view class="form-item">
+        <text class="label">食物</text>
+        <input class="input" v-model="foodsInput" placeholder="输入食物名称,逗号分隔" />
+      </view>
+      <view class="form-item">
+        <text class="label">备注</text>
+        <input class="input" v-model="note" placeholder="选填" />
+      </view>
+      <button class="submit-btn" type="primary" :loading="saving" @click="saveLog" :disabled="saving">
+        {{ saving ? '保存中...' : '记录' }}
+      </button>
+    </view>
+
+    <!-- 最近饮食 -->
+    <view class="logs-section">
+      <view class="section-title">近期饮食记录</view>
+      <view v-if="logs.length === 0 && !loadingLogs" class="empty-row">
+        <text class="empty-text">暂无记录</text>
+      </view>
+      <view v-for="log in logs" :key="log.id" class="log-card">
+        <view class="log-header">
+          <text class="log-meal-type">{{ log.mealType }}</text>
+          <text class="log-time">{{ formatTime(log.createdAt) }}</text>
+        </view>
+        <text class="log-foods">{{ log.foods }}</text>
+        <text v-if="log.note" class="log-note">{{ log.note }}</text>
+      </view>
+      <view v-if="loadingLogs" class="loading-row">
+        <text>加载中...</text>
+      </view>
+    </view>
+
+    <!-- 营养摘要 -->
+    <view class="nutrition-summary">
+      <view class="section-title">营养摄入统计 (近7天)</view>
+      <view v-if="Object.keys(nutritionData).length === 0 && !loadingNutrition" class="empty-row">
+        <text class="empty-text">暂无数据</text>
+      </view>
+      <view v-if="loadingNutrition" class="loading-row">
+        <text>加载中...</text>
+      </view>
+      <view v-if="Object.keys(nutritionData).length > 0" class="nutrition-grid">
+        <view v-for="(val, key) in nutritionData" :key="key" class="nutrition-item">
+          <text class="nutrition-key">{{ key }}</text>
+          <text class="nutrition-val">{{ val }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { logMeal, getMealLogs, getNutritionSummary } from '@/utils/api'
+
+export default {
+  name: 'MealLog',
+  data() {
+    return {
+      mealTypes: ['早餐', '午餐', '晚餐', '加餐'],
+      mealTypeIndex: -1,
+      foodsInput: '',
+      note: '',
+      saving: false,
+      currentDate: this.getToday(),
+      logs: [],
+      loadingLogs: false,
+      nutritionData: {},
+      loadingNutrition: false
+    }
+  },
+  onLoad() {
+    this.loadLogs()
+    this.loadNutrition()
+  },
+  methods: {
+    getToday() {
+      const d = new Date()
+      return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
+    },
+    formatDate(date) {
+      const d = new Date(date)
+      return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
+    },
+    formatTime(dateStr) {
+      if (!dateStr) return ''
+      const d = new Date(dateStr)
+      return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
+    },
+    changeDate(offset) {
+      const d = new Date(this.currentDate)
+      d.setDate(d.getDate() + offset)
+      this.currentDate = this.formatDate(d)
+    },
+    onMealTypeChange(e) {
+      this.mealTypeIndex = e.detail.value
+    },
+    async saveLog() {
+      if (this.mealTypeIndex < 0) {
+        uni.showToast({ title: '请选择餐次', icon: 'none' })
+        return
+      }
+      if (!this.foodsInput.trim()) {
+        uni.showToast({ title: '请输入食物名称', icon: 'none' })
+        return
+      }
+      this.saving = true
+      try {
+        const data = {
+          mealType: this.mealTypes[this.mealTypeIndex],
+          foods: this.foodsInput.trim(),
+          note: this.note,
+          mealDate: this.currentDate
+        }
+        const res = await logMeal(data)
+        if (res.code === 0) {
+          uni.showToast({ title: '记录成功', icon: 'success' })
+          this.foodsInput = ''
+          this.note = ''
+          this.mealTypeIndex = -1
+          this.loadLogs()
+          this.loadNutrition()
+        } else {
+          uni.showToast({ title: res.message || '记录失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '网络错误', icon: 'none' })
+      } finally {
+        this.saving = false
+      }
+    },
+    async loadLogs() {
+      this.loadingLogs = true
+      try {
+        const res = await getMealLogs({ days: 7 })
+        if (res.code === 0) {
+          this.logs = res.data || []
+        }
+      } catch (e) {
+        console.log('load logs error', e)
+      } finally {
+        this.loadingLogs = false
+      }
+    },
+    async loadNutrition() {
+      this.loadingNutrition = true
+      try {
+        const res = await getNutritionSummary({ days: 7 })
+        if (res.code === 0) {
+          this.nutritionData = res.data || {}
+        }
+      } catch (e) {
+        console.log('load nutrition error', e)
+      } finally {
+        this.loadingNutrition = false
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.meal-log {
+  min-height: 100vh;
+  background: var(--bg, #FFF7ED);
+  padding: 20rpx 30rpx;
+  padding-bottom: 60rpx;
+}
+.date-nav {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 20rpx 0;
+}
+.nav-arrow {
+  font-size: 48rpx;
+  color: var(--color-primary, #F97316);
+  padding: 0 30rpx;
+  font-weight: 600;
+}
+.date-text {
+  font-size: 32rpx;
+  color: #333;
+  font-weight: 500;
+  min-width: 200rpx;
+  text-align: center;
+}
+.log-form {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 30rpx;
+  margin-top: 10rpx;
+  box-shadow: var(--shadow-md, 0 2rpx 12rpx rgba(0,0,0,0.06));
+}
+.form-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 24rpx;
+}
+.form-item {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+.form-item .label {
+  width: 100rpx;
+  font-size: 28rpx;
+  color: #666;
+  flex-shrink: 0;
+}
+.form-item .input {
+  flex: 1;
+  font-size: 28rpx;
+  padding: 10rpx 20rpx;
+  border: 1rpx solid #eee;
+  border-radius: 8rpx;
+}
+.form-item .picker-value {
+  font-size: 28rpx;
+  color: var(--color-primary, #F97316);
+  padding: 10rpx 20rpx;
+  background: #FFF0E0;
+  border-radius: 8rpx;
+}
+.submit-btn {
+  margin-top: 20rpx;
+  background: var(--color-primary, #F97316);
+  border: none;
+  border-radius: 10rpx;
+  font-size: 30rpx;
+  height: 80rpx;
+  line-height: 80rpx;
+}
+.logs-section {
+  margin-top: 30rpx;
+}
+.section-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 16rpx;
+  padding-left: 10rpx;
+  border-left: 6rpx solid var(--color-primary, #F97316);
+}
+.log-card {
+  background: #fff;
+  border-radius: 12rpx;
+  padding: 20rpx 24rpx;
+  margin-bottom: 16rpx;
+  box-shadow: var(--shadow-md, 0 2rpx 12rpx rgba(0,0,0,0.06));
+}
+.log-header {
+  display: flex;
+  justify-content: space-between;
+  margin-bottom: 10rpx;
+}
+.log-meal-type {
+  font-size: 28rpx;
+  font-weight: 500;
+  color: var(--color-primary, #F97316);
+}
+.log-time {
+  font-size: 24rpx;
+  color: #999;
+}
+.log-foods {
+  font-size: 26rpx;
+  color: #333;
+  line-height: 1.4;
+}
+.log-note {
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 8rpx;
+  display: block;
+}
+.empty-row {
+  text-align: center;
+  padding: 40rpx 0;
+}
+.empty-text {
+  font-size: 26rpx;
+  color: #ccc;
+}
+.loading-row {
+  text-align: center;
+  padding: 20rpx 0;
+  font-size: 26rpx;
+  color: #999;
+}
+.nutrition-summary {
+  margin-top: 30rpx;
+}
+.nutrition-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 20rpx;
+}
+.nutrition-item {
+  background: #fff;
+  border-radius: 12rpx;
+  padding: 20rpx 24rpx;
+  min-width: 140rpx;
+  box-shadow: var(--shadow-md, 0 2rpx 12rpx rgba(0,0,0,0.06));
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.nutrition-key {
+  font-size: 24rpx;
+  color: #999;
+}
+.nutrition-val {
+  font-size: 30rpx;
+  color: var(--color-primary, #F97316);
+  font-weight: 600;
+  margin-top: 6rpx;
+}
+</style>

+ 295 - 0
cfc-frontend/pages/meal/recommend.vue

@@ -0,0 +1,295 @@
+<template>
+  <view class="meal-recommend">
+    <!-- 顶部配置 -->
+    <view class="config-section">
+      <view class="config-item">
+        <text class="label">餐次</text>
+        <picker :value="mealTypeIndex" :range="mealTypes" @change="onMealTypeChange">
+          <view class="picker-value">{{ mealTypes[mealTypeIndex] || '请选择' }}</view>
+        </picker>
+      </view>
+      <view class="config-item">
+        <text class="label">口味偏好</text>
+        <input class="input" v-model="tasteNote" placeholder="如: 清淡、少辣" />
+      </view>
+    </view>
+    <button class="generate-btn" type="primary" :loading="generating" @click="generateRecommend" :disabled="generating">
+      {{ generating ? '生成中...' : '生成推荐' }}
+    </button>
+
+    <!-- 推荐结果 -->
+    <view v-if="recommendResult" class="result-section">
+      <view class="section-title">推荐方案</view>
+      <view class="answer-card">
+        <text class="answer-text">{{ recommendResult.answer }}</text>
+      </view>
+
+      <!-- 替换选项 -->
+      <view v-if="replaceOptions.length > 0" class="replace-section">
+        <view class="section-title">可替换食材</view>
+        <scroll-view class="replace-scroll" scroll-x>
+          <view class="replace-items">
+            <view
+              v-for="food in replaceOptions"
+              :key="food.id"
+              class="replace-item"
+              hover-class="replace-item-hover"
+              @click="replaceFoodItem(food.id)"
+            >
+              <text class="food-name">{{ food.name }}</text>
+              <text class="food-kcal">{{ food.kcalPer100g }}kcal/100g</text>
+            </view>
+          </view>
+        </scroll-view>
+      </view>
+
+      <!-- 营养摘要 -->
+      <view v-if="nutritionSummary" class="nutrition-section">
+        <view class="section-title">营养摘要</view>
+        <view class="nutrition-grid">
+          <view v-for="(val, key) in nutritionSummary" :key="key" class="nutrition-item">
+            <text class="nutrition-key">{{ key }}</text>
+            <text class="nutrition-val">{{ val }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 空状态 -->
+    <view v-if="!recommendResult && !generating" class="empty-state">
+      <text class="empty-icon">🍽️</text>
+      <text class="empty-text">点击上方按钮获取智能食谱推荐</text>
+    </view>
+
+    <!-- 替换结果提示 -->
+    <view v-if="replaceResult" class="toast" @click="replaceResult = ''">
+      <text>已替换为: {{ replaceResult.name }}</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getMealRecommend, replaceFood } from '@/utils/api'
+
+export default {
+  name: 'MealRecommend',
+  data() {
+    return {
+      mealTypes: ['早餐', '午餐', '晚餐', '加餐'],
+      mealTypeIndex: -1,
+      tasteNote: '',
+      generating: false,
+      recommendResult: null,
+      replaceOptions: [],
+      nutritionSummary: null,
+      replaceResult: null
+    }
+  },
+  methods: {
+    onMealTypeChange(e) {
+      this.mealTypeIndex = e.detail.value
+    },
+    async generateRecommend() {
+      this.generating = true
+      this.recommendResult = null
+      this.replaceResult = null
+      try {
+        const params = {}
+        if (this.mealTypeIndex >= 0) {
+          params.mealType = this.mealTypes[this.mealTypeIndex]
+        }
+        const res = await getMealRecommend(params)
+        if (res.code === 0) {
+          this.recommendResult = res.data
+          this.replaceOptions = res.data.replaceOptions || []
+          this.nutritionSummary = res.data.nutritionSummary || null
+        } else {
+          uni.showToast({ title: res.message || '获取推荐失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '网络错误', icon: 'none' })
+      } finally {
+        this.generating = false
+      }
+    },
+    async replaceFoodItem(foodId) {
+      try {
+        const res = await replaceFood({ foodId })
+        if (res.code === 0 && res.data) {
+          this.replaceResult = res.data
+          uni.showToast({ title: '已替换: ' + res.data.name, icon: 'success' })
+        } else {
+          uni.showToast({ title: res.message || '替换失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '替换失败', icon: 'none' })
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.meal-recommend {
+  min-height: 100vh;
+  background: var(--bg, #FFF7ED);
+  padding: 20rpx 30rpx;
+}
+.config-section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 30rpx;
+  box-shadow: var(--shadow-md, 0 2rpx 12rpx rgba(0,0,0,0.06));
+}
+.config-item {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+.config-item:last-child {
+  margin-bottom: 0;
+}
+.label {
+  width: 140rpx;
+  font-size: 28rpx;
+  color: #333;
+  flex-shrink: 0;
+}
+.picker-value {
+  font-size: 28rpx;
+  color: var(--color-primary, #F97316);
+  padding: 10rpx 20rpx;
+  background: #FFF0E0;
+  border-radius: 8rpx;
+}
+.input {
+  flex: 1;
+  font-size: 28rpx;
+  padding: 10rpx 20rpx;
+  border: 1rpx solid #eee;
+  border-radius: 8rpx;
+  color: #333;
+}
+.generate-btn {
+  margin: 30rpx 0;
+  background: var(--color-primary, #F97316);
+  border: none;
+  border-radius: 12rpx;
+  font-size: 32rpx;
+  height: 88rpx;
+  line-height: 88rpx;
+}
+.result-section {
+  margin-top: 20rpx;
+}
+.section-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 16rpx;
+  padding-left: 10rpx;
+  border-left: 6rpx solid var(--color-primary, #F97316);
+}
+.answer-card {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 30rpx;
+  box-shadow: var(--shadow-md, 0 2rpx 12rpx rgba(0,0,0,0.06));
+}
+.answer-text {
+  font-size: 28rpx;
+  color: #333;
+  line-height: 1.6;
+  white-space: pre-wrap;
+}
+.replace-section {
+  margin-top: 30rpx;
+}
+.replace-scroll {
+  width: 100%;
+  white-space: nowrap;
+}
+.replace-items {
+  display: flex;
+  flex-direction: row;
+  padding: 10rpx 0;
+}
+.replace-item {
+  display: inline-flex;
+  flex-direction: column;
+  align-items: center;
+  background: #fff;
+  border-radius: 12rpx;
+  padding: 20rpx 30rpx;
+  margin-right: 20rpx;
+  box-shadow: var(--shadow-md, 0 2rpx 12rpx rgba(0,0,0,0.06));
+  min-width: 160rpx;
+}
+.replace-item-hover {
+  opacity: 0.8;
+  background: #FFF0E0;
+}
+.food-name {
+  font-size: 28rpx;
+  color: var(--color-primary, #F97316);
+  font-weight: 500;
+}
+.food-kcal {
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 8rpx;
+}
+.nutrition-section {
+  margin-top: 30rpx;
+}
+.nutrition-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 20rpx;
+}
+.nutrition-item {
+  background: #fff;
+  border-radius: 12rpx;
+  padding: 20rpx 24rpx;
+  min-width: 140rpx;
+  box-shadow: var(--shadow-md, 0 2rpx 12rpx rgba(0,0,0,0.06));
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.nutrition-key {
+  font-size: 24rpx;
+  color: #999;
+}
+.nutrition-val {
+  font-size: 30rpx;
+  color: var(--color-primary, #F97316);
+  font-weight: 600;
+  margin-top: 6rpx;
+}
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 120rpx 0;
+}
+.empty-icon {
+  font-size: 80rpx;
+}
+.empty-text {
+  font-size: 28rpx;
+  color: #999;
+  margin-top: 20rpx;
+}
+.toast {
+  position: fixed;
+  bottom: 100rpx;
+  left: 50%;
+  transform: translateX(-50%);
+  background: rgba(0,0,0,0.8);
+  color: #fff;
+  padding: 16rpx 40rpx;
+  border-radius: 40rpx;
+  font-size: 28rpx;
+}
+</style>

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

@@ -1292,3 +1292,10 @@ export const updateContact = (data) => request('/api/contact/update', 'POST', da
 export const deleteContact = (id) => request('/api/contact/delete', 'POST', { id: id })
 export const importPhoneContact = (data) => request('/api/contact/import-phone', 'POST', data)
 export const recordContactInteraction = (data) => request('/api/contact/interaction', 'POST', data)
+
+// ===== 食谱推荐 =====
+export const getMealRecommend = (params) => request('/api/meal/recommend', 'POST', params)
+export const replaceFood = (data) => request('/api/meal/replace', 'POST', data)
+export const logMeal = (data) => request('/api/meal/log', 'POST', data)
+export const getMealLogs = (data) => request('/api/meal/logs', 'POST', data)
+export const getNutritionSummary = (data) => request('/api/meal/nutrition-summary', 'POST', data)

+ 51 - 0
cfc-web/src/api/diet.js

@@ -0,0 +1,51 @@
+import request from '@/utils/request'
+
+// ===== 食材管理 =====
+
+export function adminFoodList(data) {
+  return request({ url: '/api/admin/foods/list', method: 'post', data })
+}
+
+export function adminFoodCreate(data) {
+  return request({ url: '/api/admin/foods/create', method: 'post', data })
+}
+
+export function adminFoodUpdate(data) {
+  return request({ url: '/api/admin/foods/update', method: 'post', data })
+}
+
+export function adminFoodDelete(data) {
+  return request({ url: '/api/admin/foods/delete', method: 'post', data })
+}
+
+// ===== 食谱管理 =====
+
+export function adminRecipeList(data) {
+  return request({ url: '/api/admin/recipes/list', method: 'post', data })
+}
+
+export function adminRecipeCreate(data) {
+  return request({ url: '/api/admin/recipes/create', method: 'post', data })
+}
+
+export function adminRecipeUpdate(data) {
+  return request({ url: '/api/admin/recipes/update', method: 'post', data })
+}
+
+export function adminRecipeDelete(data) {
+  return request({ url: '/api/admin/recipes/delete', method: 'post', data })
+}
+
+// ===== 应季食材管理 =====
+
+export function adminSeasonalList(data) {
+  return request({ url: '/api/admin/seasonal/list', method: 'post', data })
+}
+
+export function adminSeasonalCreate(data) {
+  return request({ url: '/api/admin/seasonal/create', method: 'post', data })
+}
+
+export function adminSeasonalDelete(data) {
+  return request({ url: '/api/admin/seasonal/delete', method: 'post', data })
+}

+ 19 - 0
cfc-web/src/router/index.js

@@ -297,6 +297,25 @@ const routes = [
         component: () => import('@/views/admin/OrderManage.vue'),
         meta: { title: 'DanShop订单', perm: 'system:orders' }
       },
+      // ========== 饮食管理 (admin) ==========
+      {
+        path: 'foods',
+        name: 'Foods',
+        component: () => import('@/views/admin/Foods.vue'),
+        meta: { title: '食材管理', perm: 'diet:foods' }
+      },
+      {
+        path: 'recipes',
+        name: 'Recipes',
+        component: () => import('@/views/admin/Recipes.vue'),
+        meta: { title: '食谱管理', perm: 'diet:recipes' }
+      },
+      {
+        path: 'seasonal-foods',
+        name: 'SeasonalFoods',
+        component: () => import('@/views/admin/SeasonalFoods.vue'),
+        meta: { title: '应季食材', perm: 'diet:seasonal' }
+      },
       {
         path: 'my-families',
         name: 'MyFamilies',

+ 8 - 0
cfc-web/src/views/Layout.vue

@@ -203,6 +203,14 @@ export default {
             { path: '/activity-review', label: '活动审核', icon: 'el-icon-s-check', perm: 'activity:review' },
           ]},
 
+        // diet 管理 (admin)
+        { title: '饮食管理', icon: 'el-icon-s-management', perm: 'diet:*',
+          children: [
+            { path: '/foods', label: '食材管理', icon: 'el-icon-s-management', perm: 'diet:foods' },
+            { path: '/recipes', label: '食谱管理', icon: 'el-icon-s-management', perm: 'diet:recipes' },
+            { path: '/seasonal-foods', label: '应季食材', icon: 'el-icon-s-management', perm: 'diet:seasonal' },
+          ]},
+
         // system 管理 (仅 admin)
         { title: '系统管理', icon: 'el-icon-s-tools', perm: 'system:*',
           children: [

+ 201 - 0
cfc-web/src/views/admin/Foods.vue

@@ -0,0 +1,201 @@
+<template>
+  <div class="foods">
+    <el-card>
+      <div slot="header">
+        <span>食材管理</span>
+        <el-button type="primary" size="mini" style="float:right" @click="openCreate">新建食材</el-button>
+        <el-select
+          v-model="categoryFilter"
+          placeholder="分类筛选"
+          size="mini"
+          style="float:right;margin-right:12px"
+          clearable
+          @change="loadList"
+        >
+          <el-option label="全部" value="" />
+          <el-option label="蔬菜" value="蔬菜" />
+          <el-option label="水果" value="水果" />
+          <el-option label="肉类" value="肉类" />
+          <el-option label="水产" value="水产" />
+          <el-option label="蛋奶" value="蛋奶" />
+          <el-option label="豆类" value="豆类" />
+          <el-option label="谷物" value="谷物" />
+          <el-option label="坚果" value="坚果" />
+          <el-option label="调味品" value="调味品" />
+          <el-option label="其他" value="其他" />
+        </el-select>
+      </div>
+      <el-table :data="list" v-loading="loading" border stripe>
+        <el-table-column prop="id" label="ID" width="80" />
+        <el-table-column prop="name" label="名称" />
+        <el-table-column prop="category" label="分类" width="100" />
+        <el-table-column prop="kcalPer100g" label="热量(kcal)" width="100" />
+        <el-table-column prop="protein" label="蛋白质(g)" width="100" />
+        <el-table-column prop="status" label="状态" width="80" />
+        <el-table-column label="操作" width="160">
+          <template slot-scope="{ row }">
+            <el-button size="mini" @click="editItem(row)">编辑</el-button>
+            <el-button size="mini" type="danger" @click="deleteItem(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <el-dialog :title="editId ? '编辑食材' : '新建食材'" :visible.sync="dialogVisible" width="600px">
+      <el-form label-width="120px">
+        <el-form-item label="名称">
+          <el-input v-model="form.name" />
+        </el-form-item>
+        <el-form-item label="分类">
+          <el-select v-model="form.category" style="width:100%">
+            <el-option label="蔬菜" value="蔬菜" />
+            <el-option label="水果" value="水果" />
+            <el-option label="肉类" value="肉类" />
+            <el-option label="水产" value="水产" />
+            <el-option label="蛋奶" value="蛋奶" />
+            <el-option label="豆类" value="豆类" />
+            <el-option label="谷物" value="谷物" />
+            <el-option label="坚果" value="坚果" />
+            <el-option label="调味品" value="调味品" />
+            <el-option label="其他" value="其他" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="热量(kcal/100g)">
+          <el-input-number v-model="form.kcalPer100g" :min="0" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="蛋白质(g)">
+          <el-input-number v-model="form.protein" :min="0" :precision="1" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="碳水(g)">
+          <el-input-number v-model="form.carbs" :min="0" :precision="1" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="脂肪(g)">
+          <el-input-number v-model="form.fat" :min="0" :precision="1" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="膳食纤维(g)">
+          <el-input-number v-model="form.fiber" :min="0" :precision="1" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="单位">
+          <el-input v-model="form.unit" placeholder="如: 克、个、份" />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="form.status" style="width:100%">
+            <el-option label="启用" value="active" />
+            <el-option label="停用" value="inactive" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="saveItem">保存</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'Foods',
+  data() {
+    return {
+      loading: false,
+      list: [],
+      dialogVisible: false,
+      categoryFilter: '',
+      editId: null,
+      form: {
+        name: '',
+        category: '',
+        kcalPer100g: 0,
+        protein: 0,
+        carbs: 0,
+        fat: 0,
+        fiber: 0,
+        unit: '克',
+        status: 'active'
+      }
+    }
+  },
+  created() {
+    this.loadList()
+  },
+  methods: {
+    async loadList() {
+      this.loading = true
+      try {
+        const baseURL = process.env.VUE_APP_BASE_API || ''
+        const token = localStorage.getItem('token')
+        const axios = this.$axios || (await import('axios')).default
+        const res = await axios.post(baseURL + '/api/admin/foods/list', { category: this.categoryFilter || undefined }, {
+          headers: { Authorization: 'Bearer ' + token }
+        })
+        this.list = res.data && res.data.data || []
+      } catch (e) {
+        this.$message && this.$message.error && this.$message.error('加载失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    openCreate() {
+      this.editId = null
+      this.form = { name: '', category: '', kcalPer100g: 0, protein: 0, carbs: 0, fat: 0, fiber: 0, unit: '克', status: 'active' }
+      this.dialogVisible = true
+    },
+    editItem(row) {
+      this.editId = row.id
+      this.form = {
+        name: row.name || '',
+        category: row.category || '',
+        kcalPer100g: row.kcalPer100g || 0,
+        protein: row.protein || 0,
+        carbs: row.carbs || 0,
+        fat: row.fat || 0,
+        fiber: row.fiber || 0,
+        unit: row.unit || '克',
+        status: row.status || 'active'
+      }
+      this.dialogVisible = true
+    },
+    async saveItem() {
+      try {
+        const baseURL = process.env.VUE_APP_BASE_API || ''
+        const token = localStorage.getItem('token')
+        const axios = this.$axios || (await import('axios')).default
+        const data = { ...this.form }
+        if (this.editId) {
+          data.id = this.editId
+          await axios.post(baseURL + '/api/admin/foods/update', data, {
+            headers: { Authorization: 'Bearer ' + token }
+          })
+          this.$message && this.$message.success && this.$message.success('更新成功')
+        } else {
+          await axios.post(baseURL + '/api/admin/foods/create', data, {
+            headers: { Authorization: 'Bearer ' + token }
+          })
+          this.$message && this.$message.success && this.$message.success('创建成功')
+        }
+        this.dialogVisible = false
+        this.loadList()
+      } catch (e) {
+        this.$message && this.$message.error && this.$message.error('保存失败')
+      }
+    },
+    async deleteItem(row) {
+      this.$confirm('确定删除 "' + row.name + '" 吗?', '提示', { type: 'warning' }).then(async () => {
+        try {
+          const baseURL = process.env.VUE_APP_BASE_API || ''
+          const token = localStorage.getItem('token')
+          const axios = this.$axios || (await import('axios')).default
+          await axios.post(baseURL + '/api/admin/foods/delete', { id: row.id }, {
+            headers: { Authorization: 'Bearer ' + token }
+          })
+          this.$message && this.$message.success && this.$message.success('删除成功')
+          this.loadList()
+        } catch (e) {
+          this.$message && this.$message.error && this.$message.error('删除失败')
+        }
+      }).catch(() => {})
+    }
+  }
+}
+</script>

+ 198 - 0
cfc-web/src/views/admin/Recipes.vue

@@ -0,0 +1,198 @@
+<template>
+  <div class="recipes">
+    <el-card>
+      <div slot="header">
+        <span>食谱管理</span>
+        <el-button type="primary" size="mini" style="float:right" @click="openCreate">新建食谱</el-button>
+        <el-select
+          v-model="mealTypeFilter"
+          placeholder="餐次筛选"
+          size="mini"
+          style="float:right;margin-right:12px"
+          clearable
+          @change="loadList"
+        >
+          <el-option label="全部" value="" />
+          <el-option label="早餐" value="早餐" />
+          <el-option label="午餐" value="午餐" />
+          <el-option label="晚餐" value="晚餐" />
+          <el-option label="加餐" value="加餐" />
+        </el-select>
+      </div>
+      <el-table :data="list" v-loading="loading" border stripe>
+        <el-table-column prop="id" label="ID" width="80" />
+        <el-table-column prop="name" label="名称" />
+        <el-table-column prop="mealType" label="类型" width="80" />
+        <el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
+        <el-table-column prop="status" label="状态" width="80" />
+        <el-table-column label="操作" width="160">
+          <template slot-scope="{ row }">
+            <el-button size="mini" @click="editItem(row)">编辑</el-button>
+            <el-button size="mini" type="danger" @click="deleteItem(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <el-dialog :title="editId ? '编辑食谱' : '新建食谱'" :visible.sync="dialogVisible" width="700px">
+      <el-form label-width="120px">
+        <el-form-item label="名称">
+          <el-input v-model="form.name" />
+        </el-form-item>
+        <el-form-item label="餐次">
+          <el-select v-model="form.mealType" style="width:100%">
+            <el-option label="早餐" value="早餐" />
+            <el-option label="午餐" value="午餐" />
+            <el-option label="晚餐" value="晚餐" />
+            <el-option label="加餐" value="加餐" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="描述">
+          <el-input v-model="form.description" type="textarea" :rows="2" />
+        </el-form-item>
+        <el-form-item label="食材">
+          <el-input v-model="form.ingredients" placeholder="食材名称,逗号分隔" />
+        </el-form-item>
+        <el-form-item label="做法">
+          <el-input v-model="form.instructions" type="textarea" :rows="4" />
+        </el-form-item>
+        <el-form-item label="热量(kcal)">
+          <el-input-number v-model="form.calories" :min="0" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="蛋白质(g)">
+          <el-input-number v-model="form.protein" :min="0" :precision="1" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="碳水(g)">
+          <el-input-number v-model="form.carbs" :min="0" :precision="1" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="脂肪(g)">
+          <el-input-number v-model="form.fat" :min="0" :precision="1" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="图片URL">
+          <el-input v-model="form.imageUrl" placeholder="可选" />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="form.status" style="width:100%">
+            <el-option label="启用" value="active" />
+            <el-option label="停用" value="inactive" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="saveItem">保存</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'Recipes',
+  data() {
+    return {
+      loading: false,
+      list: [],
+      dialogVisible: false,
+      mealTypeFilter: '',
+      editId: null,
+      form: {
+        name: '',
+        mealType: '',
+        description: '',
+        ingredients: '',
+        instructions: '',
+        calories: 0,
+        protein: 0,
+        carbs: 0,
+        fat: 0,
+        imageUrl: '',
+        status: 'active'
+      }
+    }
+  },
+  created() {
+    this.loadList()
+  },
+  methods: {
+    async loadList() {
+      this.loading = true
+      try {
+        const baseURL = process.env.VUE_APP_BASE_API || ''
+        const token = localStorage.getItem('token')
+        const axios = this.$axios || (await import('axios')).default
+        const res = await axios.post(baseURL + '/api/admin/recipes/list', { mealType: this.mealTypeFilter || undefined }, {
+          headers: { Authorization: 'Bearer ' + token }
+        })
+        this.list = res.data && res.data.data || []
+      } catch (e) {
+        this.$message && this.$message.error && this.$message.error('加载失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    openCreate() {
+      this.editId = null
+      this.form = { name: '', mealType: '', description: '', ingredients: '', instructions: '', calories: 0, protein: 0, carbs: 0, fat: 0, imageUrl: '', status: 'active' }
+      this.dialogVisible = true
+    },
+    editItem(row) {
+      this.editId = row.id
+      this.form = {
+        name: row.name || '',
+        mealType: row.mealType || '',
+        description: row.description || '',
+        ingredients: row.ingredients || '',
+        instructions: row.instructions || '',
+        calories: row.calories || 0,
+        protein: row.protein || 0,
+        carbs: row.carbs || 0,
+        fat: row.fat || 0,
+        imageUrl: row.imageUrl || '',
+        status: row.status || 'active'
+      }
+      this.dialogVisible = true
+    },
+    async saveItem() {
+      try {
+        const baseURL = process.env.VUE_APP_BASE_API || ''
+        const token = localStorage.getItem('token')
+        const axios = this.$axios || (await import('axios')).default
+        const data = { ...this.form }
+        if (this.editId) {
+          data.id = this.editId
+          await axios.post(baseURL + '/api/admin/recipes/update', data, {
+            headers: { Authorization: 'Bearer ' + token }
+          })
+          this.$message && this.$message.success && this.$message.success('更新成功')
+        } else {
+          await axios.post(baseURL + '/api/admin/recipes/create', data, {
+            headers: { Authorization: 'Bearer ' + token }
+          })
+          this.$message && this.$message.success && this.$message.success('创建成功')
+        }
+        this.dialogVisible = false
+        this.loadList()
+      } catch (e) {
+        this.$message && this.$message.error && this.$message.error('保存失败')
+      }
+    },
+    async deleteItem(row) {
+      this.$confirm('确定删除 "' + row.name + '" 吗?', '提示', { type: 'warning' }).then(async () => {
+        try {
+          const baseURL = process.env.VUE_APP_BASE_API || ''
+          const token = localStorage.getItem('token')
+          const axios = this.$axios || (await import('axios')).default
+          await axios.post(baseURL + '/api/admin/recipes/delete', { id: row.id }, {
+            headers: { Authorization: 'Bearer ' + token }
+          })
+          this.$message && this.$message.success && this.$message.success('删除成功')
+          this.loadList()
+        } catch (e) {
+          this.$message && this.$message.error && this.$message.error('删除失败')
+        }
+      }).catch(() => {})
+    }
+  }
+}
+</script>

+ 142 - 0
cfc-web/src/views/admin/SeasonalFoods.vue

@@ -0,0 +1,142 @@
+<template>
+  <div class="seasonal-foods">
+    <el-card>
+      <div slot="header">
+        <span>应季食材配置</span>
+        <el-button type="primary" size="mini" style="float:right" @click="openCreate">新增应季映射</el-button>
+        <el-select
+          v-model="monthFilter"
+          placeholder="选择月份"
+          size="mini"
+          style="float:right;margin-right:12px"
+          @change="loadList"
+        >
+          <el-option v-for="m in 12" :key="m" :label="m + '月'" :value="m" />
+        </el-select>
+      </div>
+      <el-table :data="list" v-loading="loading" border stripe>
+        <el-table-column prop="id" label="ID" width="80" />
+        <el-table-column prop="foodId" label="食材ID" width="80" />
+        <el-table-column label="月份" width="80">
+          <template slot-scope="{ row }">{{ row.month }}月</template>
+        </el-table-column>
+        <el-table-column label="推荐等级" width="100">
+          <template slot-scope="{ row }">
+            <el-tag :type="row.peak >= 3 ? 'danger' : row.peak >= 2 ? 'warning' : 'info'" size="mini">
+              {{ row.peak }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="120">
+          <template slot-scope="{ row }">
+            <el-button size="mini" type="danger" @click="deleteItem(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <el-dialog title="新增应季映射" :visible.sync="dialogVisible" width="400px">
+      <el-form label-width="100px">
+        <el-form-item label="食材ID">
+          <el-input-number v-model="form.foodId" :min="1" style="width:100%" />
+        </el-form-item>
+        <el-form-item label="月份">
+          <el-select v-model="form.month" style="width:100%">
+            <el-option v-for="m in 12" :key="m" :label="m + '月'" :value="m" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="推荐等级">
+          <el-select v-model="form.peak" style="width:100%">
+            <el-option label="1-普通" :value="1" />
+            <el-option label="2-推荐" :value="2" />
+            <el-option label="3-强烈推荐" :value="3" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="saveItem">保存</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'SeasonalFoods',
+  data() {
+    const now = new Date()
+    return {
+      loading: false,
+      list: [],
+      dialogVisible: false,
+      monthFilter: now.getMonth() + 1,
+      form: {
+        foodId: null,
+        month: now.getMonth() + 1,
+        peak: 1
+      }
+    }
+  },
+  created() {
+    this.loadList()
+  },
+  methods: {
+    async loadList() {
+      this.loading = true
+      try {
+        const baseURL = process.env.VUE_APP_BASE_API || ''
+        const token = localStorage.getItem('token')
+        const axios = this.$axios || (await import('axios')).default
+        const res = await axios.post(baseURL + '/api/admin/seasonal/list', { month: this.monthFilter }, {
+          headers: { Authorization: 'Bearer ' + token }
+        })
+        this.list = res.data && res.data.data || []
+      } catch (e) {
+        this.$message && this.$message.error && this.$message.error('加载失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    openCreate() {
+      this.form = { foodId: null, month: new Date().getMonth() + 1, peak: 1 }
+      this.dialogVisible = true
+    },
+    async saveItem() {
+      if (!this.form.foodId) {
+        this.$message && this.$message.warning && this.$message.warning('请输入食材ID')
+        return
+      }
+      try {
+        const baseURL = process.env.VUE_APP_BASE_API || ''
+        const token = localStorage.getItem('token')
+        const axios = this.$axios || (await import('axios')).default
+        await axios.post(baseURL + '/api/admin/seasonal/create', this.form, {
+          headers: { Authorization: 'Bearer ' + token }
+        })
+        this.$message && this.$message.success && this.$message.success('创建成功')
+        this.dialogVisible = false
+        this.loadList()
+      } catch (e) {
+        this.$message && this.$message.error && this.$message.error('保存失败')
+      }
+    },
+    async deleteItem(row) {
+      this.$confirm('确定删除该应季映射吗?', '提示', { type: 'warning' }).then(async () => {
+        try {
+          const baseURL = process.env.VUE_APP_BASE_API || ''
+          const token = localStorage.getItem('token')
+          const axios = this.$axios || (await import('axios')).default
+          await axios.post(baseURL + '/api/admin/seasonal/delete', { id: row.id }, {
+            headers: { Authorization: 'Bearer ' + token }
+          })
+          this.$message && this.$message.success && this.$message.success('删除成功')
+          this.loadList()
+        } catch (e) {
+          this.$message && this.$message.error && this.$message.error('删除失败')
+        }
+      }).catch(() => {})
+    }
+  }
+}
+</script>