十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Vue的幼儿园营养配餐系统设计与实现

基于Vue的幼儿园营养配餐系统设计与实现 简介基于Vue框架打造的幼儿园营养配餐系统Web端设计源码面向前端开发者、Vue/TypeScript学习者和幼儿园信息化项目设计人员。项目以Vue组件化方式实现了配餐管理场景下的核心界面与业务逻辑涵盖登录、首页、配餐管理、营养展示等模块并包含路由配置、Axios封装、hooks函数与TS类型定义便于对照学习前端工程结构和类型约束实践。压缩包共61个文件体积约1.21MB其中以25个Vue组件为主另有5个JavaScript脚本、4个TypeScript类型文件、4个JSON数据文件以及图片、样式、配置等辅助文件整体目录按src/router、src/pages、src/hooks等模块组织直观易读。目前已有300人学习下载。借助这份源码可快速搭建幼儿园营养配餐系统Web端原型深入理解菜单管理、订单流转与营养数据组织方式并复用其页面布局和组件拆分思路。1. 幼儿园配套餐本质是在算一套每天的营养账幼儿园的“带量食谱”和家里做饭完全是两码事每一道菜要写明食材克重每餐要折算能量和蛋白质、脂肪、钙、铁、锌还得避开过敏源最后还要按班级出勤人数汇总采购量。手工查《中国食物成分表》算这些一个营养员一下午只能排两天食谱还容易在乘法上出错。所以“基于Vue框架的幼儿园营养配餐系统web端设计源码”这个标题里的重点不在“Vue”而在“配餐”——Vue只是把营养计算、食谱排布、报表展示这套业务搬到了浏览器里让保健医在网页上直接完成每周配餐和营养核查。web前端开发的同学可以把它当作一个典型的中后台业务项目来拆解营养信息化团队的成员则能从中看到数据模型和计算边界。下文按“领域模型 → 工程搭建 → 计算核心 → 联调验证”的顺序展开所有代码都可以直接抄进一个Vue 3项目里跑通。2. 营养标准、食材库与过敏原配餐领域的数据模型2.1 儿童营养供给标准先建“可变标准”不要写死常量做配餐系统第一步不是写页面而是决定“标准”放在哪里。很多人会顺手在代码里写const dailyEnergy 1300这在小范围Demo里没问题但一上真实环境就崩小班和大班标准不同同一个班里还有体重偏轻需要加餐的儿童更麻烦的是园所通常参照当地卫生保健部门的指导意见标准会随学期调整。常见做法是把“营养素供给标准”做成可编辑的业务配置按班级类型或年龄段存储。标准表至少包含这些字段班级类型小班/中班/大班、能量千卡、蛋白质克、脂肪克、碳水化合物克、钙毫克、铁毫克、锌毫克、维生素A微克视黄醇当量。例如3-6岁儿童每日能量参考值一般在1200到1400千卡这个量级蛋白质约40到55克具体数值以园所采用的膳食营养素参考摄入量标准为准。系统在做覆盖率报表时取的是这张配置表里的数值而不是代码里的魔法数字。标准范围带来的联动设计有了标准表后面所有报表都变成“实际摄入量 ÷ 标准量 × 100%”。覆盖率低于80%提示偏高或偏低需要在菜品搭配上调整。这个设计让“营养分析”页面完全由数据驱动修改一次标准所有历史数据重新按新标准计算不需要改任何页面代码。2.2 食材库与可食部计算的第一道乘数食材库是所有营养计算的源头。一条食材记录至少要包含名称、分类、可食部比例、以及每100克可食部对应的各营养素含量。可食部是新手最容易忽略的字段——带骨鸡肉、带壳鸡蛋、带皮水果这些食材的毛重和实际吃进嘴里的重量差很多。一条猪肉瘦的典型记录像这样可食部0.95意思是买100克生肉去骨去皮去筋膜后能吃约95克。之前的项目里有人偷懒不乘可食部结果所有菜品能量算出来都虚高午餐能量直接超过标准家长投诉说孩子下午点心吃太多了其实问题出在计算口径上。可食部必须由录入食材的人填真实食材可以查阅《中国食物成分表》标准版不要自己拍脑袋填。代码里计算会用到这个字段后面第4章会给出具体函数。2.2.1 食材分类影响菜谱结构食材分类建议分得细一点谷薯类、蔬菜类、水果类、畜禽肉类、水产类、蛋类、奶类、大豆及坚果类、油盐类。分类字段有两个作用一是配餐时按类选菜保证一周食谱覆盖全部类别二是做营养素结构分析时可以统计“谷薯类提供的能量占比是否过高”。分类用数字编码存页面展示时用字典表映射中文名不要直接存中文否则后面改分类名要写迁移脚本。2.3 过敏原与餐次标签配餐里的安全红线过敏原在幼儿园场景里不是可选项而是必选项。常见的八大类过敏原包括牛奶、鸡蛋、花生、大豆、小麦、鱼、虾、坚果实际项目中建议把过敏原做成可配置的清单因为不同地区、不同家长关注的过敏原不一样。食材和成品菜都需要打过敏原标签食材的过敏原由录入员维护菜品的过敏原则由“菜品-食材”关联关系自动聚合推算而不是人工重复勾选——否则菜改了配方过敏原标签漏改出了事就是事故。餐次标签按幼儿园作息分为早餐、早点、午餐、午点、晚餐五个时段。带量食谱一般只覆盖在园的两餐两点或三餐两点晚餐如果不在园内吃就不配。餐次字段建议用字符串枚举存不要用布尔字段分开存五个否则后面加一个“夜宵”餐次要改表结构。菜品-食材的复合结构这个系统的核心业务对象是“配餐单”某年某月某日、某个班级、某个餐次包含若干菜品每个菜品下面挂若干食材每样食材带克重和可食部数据。用JavaScript描述这个结构非常直观const mealPlan { date: 2025-05-12, classId: K3A, // 大班A组 mealType: lunch, // 午餐 dishes: [ { dishId: d001, name: 番茄炒鸡蛋, allergyTags: [egg], // 由食材聚合生成 ingredients: [ { ingredientId: i01, name: 番茄, weightGrams: 150 }, { ingredientId: i02, name: 鸡蛋, weightGrams: 50 } ] } ] }这套结构就是从“菜品-食材”多对多关系映射出来的配餐单本身是“日期班餐次”维度的快照保存后不允许食材被删改只能整体替换版本方便追溯上周的食谱为什么算出来是那个数。2.4 设计成关系型表配餐数据模型的落地方案如果后端用MySQL存这套数据核心建表语句可以这样写。注意外键关系依次为配餐单 → 餐次明细 → 菜品 → 食材明细。CREATE TABLE meal_plan ( id BIGINT PRIMARY KEY AUTO_INCREMENT, plan_date DATE NOT NULL, class_id VARCHAR(16) NOT NULL, meal_type VARCHAR(8) NOT NULL COMMENT breakfast/morning_snack/lunch/afternoon_snack/dinner, status TINYINT DEFAULT 0 COMMENT 0草稿 1已发布, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_date_class_meal (plan_date, class_id, meal_type) ); CREATE TABLE meal_plan_dish ( id BIGINT PRIMARY KEY AUTO_INCREMENT, plan_id BIGINT NOT NULL, dish_id BIGINT NOT NULL, seq INT DEFAULT 0 COMMENT 菜品顺序, CONSTRAINT fk_plan_dish_plan FOREIGN KEY (plan_id) REFERENCES meal_plan(id) ); CREATE TABLE dish_ingredient ( id BIGINT PRIMARY KEY AUTO_INCREMENT, dish_id BIGINT NOT NULL, ingredient_id BIGINT NOT NULL, weight_grams DECIMAL(8,2) NOT NULL COMMENT 人均克重, CONSTRAINT fk_dish_ingredient FOREIGN KEY (dish_id) REFERENCES dish(id) );字段说明weight_grams存的是“人均克重”不是全班总量。后面做采购汇总时用人均克重乘以出勤人数得到总量这样保健医调整食谱时只看“每个孩子吃多少”不用心算全班30人的量。status字段保留草稿和发布两种状态发布后的配餐单要锁定编辑必须走“创建新版本”流程这是幼儿园留档检查的刚需。实体关键字段说明配餐单plan_date / class_id / meal_type同一日期同班同餐次唯一餐次明细plan_id / dish_id / seq一个配餐单挂多个菜品菜品name / allergy_tags过敏原由食材聚合不入库冗余字段食材明细dish_id / ingredient_id / weight_grams人均克重精确到0.01克食材库name / category / edible_ratio / per100g数据可食部比例0-1之间3. 用 Vue 把配餐主流程搭起来路由、布局与组件3.1 项目初始化与依赖安装Vue安装及环境配置现在比Vue 2时代简单很多。创建工程推荐用Vite模板选vuenpm create vitelatest kindergarten-diet-web -- --template vue cd kindergarten-diet-web npm install npm install vue-router4 pinia axios element-plus npm run dev这里说明几个选择状态管理用Pinia而不是Vuex因为Pinia的store写法更接近组合式API配餐单这种“按日期维度散落草稿”的数据用Pinia的setup store写起来比Vuex的mutation直观得多UI库用Element Plus因为它的表格、日期选择器、树形控件在后台管理系统里非常成熟如果你团队里已经用Ant Design Vue替换成本也不高组件设计思路是一样的。Node版本建议18以上低于16会直接报错这是Vite 5的最低门槛。3.2 布局、菜单与路由参数中后台布局基本是固定套路左侧菜单、顶部栏、右侧内容区。配餐系统的菜单可以这样组织配餐单、食材库、营养分析、班级管理。路由配置里配餐单页面需要接收日期和班级两个参数import { createRouter, createWebHistory } from vue-router const router createRouter({ history: createWebHistory(), routes: [ { path: /meal-plan, name: MealPlan, component: () import(/views/MealPlan.vue) }, { path: /meal-plan/detail, name: MealPlanDetail, component: () import(/views/MealPlanDetail.vue), props: route ({ planDate: route.query.date, classId: route.query.classId }) }, { path: /ingredient, component: () import(/views/Ingredient.vue) }, { path: /nutrition-report, component: () import(/views/NutritionReport.vue) } ] })这里用到vue路由参数的常见写法列表页跳转详情页时用query携带date和classId通过props函数映射到组件的props里。不建议把日期放到路径参数里因为日期格式带斜杠会被路由解析器当成多级路径增加转义成本用query传参刷新页面参数也不会丢。路由懒加载用动态import首页首屏只加载布局和菜单配餐单页面等到用户点击时才下载对应JS块。3.3 配餐单页面的组件设计配餐单页面是核心操作页布局上分成三块顶部工具栏日期picker、班级下拉框、中间餐次Tabs早餐/早点/午餐/午点/晚餐、右侧当前餐次的菜品明细。这个页面最忌讳的是把所有逻辑堆在一个巨型组件里。我一般会把菜品明细拆成两个小组件。表格展示菜品下挂的食材明细先定义一个展示用组件DishIngredientTable.vue它只负责渲染一个菜品的食材列表和合计营养template div classdish-ingredient-table h4{{ dish.name }}/h4 el-table :datadish.ingredients sizesmall el-table-column propname label食材 width120 / el-table-column propweightGrams label人均克重 width90 / el-table-column label能量(kcal) template #default{ row } {{ calcRowEnergy(row) }} /template /el-table-column el-table-column label操作 width70 template #default{ row } el-button link typedanger clickremoveIngredient(row)删除/el-button /template /el-table-column /el-table /div /template script setup defineProps({ dish: Object }) const calcRowEnergy (row) { const edible row.edibleRatio ?? 1 const energyPer100g row.energyPer100g ?? 0 return (row.weightGrams * edible * energyPer100g / 100).toFixed(1) } /scriptcalcRowEnergy的计算逻辑是第4章的迷你版可食部比例与每百克能量相乘再除以100。组件只接收dish对象食材增删操作通过事件抛给父组件由父组件调用Pinia store统一管理。这样做的好处是后续如果要在菜品行内加“替代食材”功能可以单独扩展这个子组件不影响配餐单主体。3.4 用 Pinia 管理配餐状态草稿不丢、联动即时配餐单数据的最大特点是“草稿多、保存少”。营养员可能一边配菜一边接电话回头还要继续调所以必须有一个地方暂存“正在编辑的配餐单”。Pinia store里按date classId mealType为维度存草稿对象// stores/mealPlan.js import { defineStore } from pinia export const useMealPlanStore defineStore(mealPlan, { state: () ({ drafts: new Map(), // key: 2025-05-12-K3A-lunch activePlanId: null }), actions: { getDraft(date, classId, mealType) { const key ${date}-${classId}-${mealType} return this.drafts.get(key) || { dishes: [] } }, addIngredient(date, classId, mealType, dishId, ingredient) { const plan this.getDraft(date, classId, mealType) const dish plan.dishes.find(d d.dishId dishId) if (dish) { dish.ingredients.push({ ...ingredient, id: Date.now() }) } this.drafts.set(${date}-${classId}-${mealType}, plan) }, removeIngredient(date, classId, mealType, dishId, ingredientId) { const plan this.getDraft(date, classId, mealType) const dish plan.dishes.find(d d.dishId dishId) dish.ingredients dish.ingredients.filter(item item.id ! ingredientId) } } })说明用Map而不是普通对象存草稿因为key里带斜杠和冒号时普通对象的属性访问要写成drafts[2025-05-12-K3A-lunch]Map直接用字符串key更干净。id: Date.now()生成临时id保存到后端时会被数据库自增id替换这个临时id只用于Vue的key绑定和删除定位。这个store是跨组件共享的左侧菜单点“营养分析”切走再切回来草稿还在内存里配合浏览器刷新前的beforeunload提示能避免营养员半小时的工作白费。到这一步web前端开发的骨架已经完整路由、布局、核心页面、状态管理。真正让这套系统区别于普通CRUD的是第4章的营养素计算和覆盖率报表。4. 营养素计算与覆盖率报表配餐系统的核心代码4.1 计算函数克重、可食部与每百克含量营养计算的全部秘密就一条公式实际摄入量 人均克重 × 可食部比例 × 每100克含量 ÷ 100。一个餐次里有多个菜品每个菜品有多个食材所以整体计算是三层循环餐次 → 菜品 → 食材。// utils/nutrition.js const NUTRIENTS [energy, protein, fat, carbohydrate, calcium, iron, zinc, vitA] export function calcMealNutrition(mealDishes, ingredientMap) { // mealDishes: [{ dishId, ingredients: [{ ingredientId, weightGrams }] }] // ingredientMap: { ingredientId: { edibleRatio, energyPer100g, proteinPer100g, ... } } const result {} NUTRIENTS.forEach(key { result[key] 0 }) for (const dish of mealDishes) { for (const item of dish.ingredients) { const ing ingredientMap[item.ingredientId] if (!ing) continue // 食材被删除时跳过避免计算中断 const edibleWeight item.weightGrams * (ing.edibleRatio ?? 1) for (const key of NUTRIENTS) { const per100gKey ${key}Per100g result[key] edibleWeight * (ing[per100gKey] ?? 0) / 100 } } } return result // { energy: 523.6, protein: 21.3, ... } }参数说明mealDishes是当前餐次的所有菜品ingredientMap是食材库的快照映射用对象key代替数组查找。可食部用了?? 1兜底——历史数据里如果没填可食部按1处理但报表里要把这类食材标记为“缺可食部数据”提示录入员补全。三个循环的嵌套看起来简单性能上完全没问题一个餐次最多十个菜品每个菜品最多六种食材总计算量在几十次乘法以内报表加载都是毫秒级。4.2 覆盖率报表与标准量对比输出调整建议计算出实际摄入量之后要和标准量对比生成覆盖率。这个比率一般控制在80%到120%之间低于80%说明孩子没吃饱或营养密度不够高于120%说明能量超标容易造成肥胖。报表页面按“周”维度展示把五天午餐的数据拉出来横比。每日覆盖率表格的一种呈现日期能量蛋白质脂肪碳水化合物钙铁锌综合判定周一96%108%92%88%74%101%95%钙偏低周二103%97%105%95%112%88%90%正常周三89%85%90%82%70%93%80%钙、碳水偏低周四112%119%126%92%89%97%108%脂肪偏高周五98%102%95%100%92%96%91%正常生成这个表格的代码思路并不复杂逐个餐次调用calcMealNutrition用返回值除以标准量再乘100export function calcCoverage(nutrition, standard) { const coverage {} for (const key of Object.keys(nutrition)) { coverage[key] standard[key] ? Math.round(nutrition[key] / standard[key] * 100) : null } return coverage }标准量小于等于0时返回null而不是报错因为钙这个字段如果园所没有维护标准报表里显示“—”比显示0或Infinity更诚实。综合判定列可以简单做规则判断任何一个指标低于80%或高于120%就取偏差最大的那个指标名拼成提示文案。不建议在Vue组件里写复杂的判定逻辑抽成纯函数generateAdvice(coverage)更容易单测。4.3 接口封装与错误码拦截配餐保存的后端协同前端计算只是预览最终要保存到后端。配餐保存是一个典型的“写多表”事务配餐单主表、餐次明细表、菜品与食材关系任何一环失败都不能落库。axios封装时拦截器处理三类典型错误// api/http.js import axios from axios const http axios.create({ baseURL: /api, timeout: 10000 }) http.interceptors.response.use( response response.data, error { const status error.response?.status const msg error.response?.data?.message if (status 401) { // token失效跳登录页 window.location.href /login } else if (status 422) { // 参数校验失败例如食材克重不能为负 ElMessage.warning(msg || 参数校验失败) } else if (status 409) { // 该日期的配餐单已发布不能覆盖编辑 ElMessageBox.confirm(当天配餐单已发布是否创建新版本) } else { ElMessage.error(msg || 网络异常请稍后重试) } return Promise.reject(error) } ) export default http错误码设计表状态码场景前端行为401登录过期或未登录清除本地token跳登录页422食材克重超出合理范围等校验失败显示后端message不跳转409配餐单已发布尝试覆盖弹窗询问是否创建新版本500数据库写入失败等显示“网络异常”保留草稿不跳转注意422和409的区别处理422是用户填错提示后停留在原页面让用户改409是业务流程冲突必须用户明确选择“创建新版本”才会走复制接口。拦截器里不要顺手把错误全部吞掉Promise.reject(error)必须保留让调用方还能根据错误码做更细的补救动作。5. 上线前用三个技巧验证计算和调试5.1 手工验算一道菜别直接信报表数字第一个技巧最笨但最有效拿一道家常菜手工算一遍再和页面对比。以番茄炒鸡蛋为例假设番茄人均150克、可食部1鸡蛋人均50克、可食部0.88食材库中番茄每100克能量20千卡、鸡蛋每100克能量144千卡。手工计算番茄能量 150 × 1 × 20 ÷ 100 30千卡鸡蛋能量 50 × 0.88 × 144 ÷ 100 63.36千卡合计93.36千卡。页面显示93.4千卡就是对的。如果页面显示的是93.36而你的报表列没有做四舍五入视觉上会有一长串小数建议统一展示层用toFixed(1)存储层保留原始浮点数不要为了好看提前截断否则周报表累计时会累积误差。Vue调试工具里的Pinia面板在此时非常有用打开Vue Devtools的Pinia标签页修改配餐单中鸡蛋克重从50改成55能看到drafts里的对象和界面上方合计数值同时变化。如果改了克重但页面数字没动基本可以确定是组件没有从store取数而是本地复制了一份props——这是Vue项目实战里最高频的“数据不同步”问题。5.2 人均克重和出勤人数分开存采购汇总才不出错第二个技巧涉及业务字段边界。有人图省事在配餐单上直接存“全班总用量”比如番茄4500克。这样营养员调食谱时很痛苦改成5000克到底每个孩子多吃了几克正确做法是只存人均克重班级人数在“出勤记录”表里按日维护。采购汇总页在生成“明日食材采购单”时才用当日出勤人数乘以人均克重再乘以1.2的富余系数——考虑到食材损耗和个别孩子加餐。人均克重是营养学口径出勤人数是行政口径两者混在一个字段里报表和采购迟早打架。5.3 过敏原校验放在保存前用一次请求拦截完成最后一个技巧是过敏原校验的时机。菜品保存时校验自己带的食材有无过敏原这只做了一半完整的校验是“该菜品过敏原”与“该班级过敏儿童清单”做碰撞。前端应该在点击“保存配餐单”按钮时先调用一个校验函数而不是等后端报错再返工const allergyCheck (planDishes, classAllergyList) { const conflicts [] for (const dish of planDishes) { const hit dish.allergyTags.filter(tag classAllergyList.includes(tag)) if (hit.length) { conflicts.push({ dishName: dish.name, allergyTags: hit }) } } if (conflicts.length) { ElMessageBox.alert( 以下菜品含有班级过敏原${conflicts.map(c c.dishName).join(、)}, 过敏原冲突, { type: warning } ) return false } return true }这个函数放在保存接口调用之前把dish.allergyTags和班级配置的过敏原列表做差集。注意过敏原标签必须用统一编码如egg代表鸡蛋不能用“鸡蛋”“蛋类”这种中文字符串做比对否则今天录“鸡蛋”明天录“蛋类”碰撞检测就形同虚设。校验通过后还会二次调用后端接口再验一遍前后端双校验避免有人绕过前端直接调API把过敏菜品上传上去。本文还有配套的精品资源点击获取
返回列表