用前端三兄弟做个「干饭雷达」:手把手教你实现美食推荐 + 订单管理系统
手把手教你做手工皂,环保又实用 #生活乐趣# #生活分享# #美食生活分享# #手作生活体验#
前言打工人的每日灵魂拷问:「中午吃啥?晚上吃啥?」为了解决这个世纪难题,我用 HTML+CSS+JavaScript 写了个「干饭雷达」美食推荐小工具 —— 支持分类随机选餐、订单管理、饮食偏好统计,还能本地存储记录你的干饭历史!今天就带大家拆解这个项目的实现思路~

先看效果:这是一个响应式网页,主要包含四大模块:
分类选择:支持筛选中餐 / 西餐 / 快餐等 5 类美食随机推荐:点击「启动雷达扫描」获取随机美食详情(含产地、历史、做法等)订单管理:可添加 / 删除待吃清单,提交订单后清空列表饮食统计:用柱状图展示累计已提交订单的类别偏好 二、核心功能实现拆解 1. 美食数据库设计(数据层)要实现推荐功能,首先得有「美食数据池」。这里用对象字面量模拟数据库,按类别分组存储美食信息,每个美食包含名称、产地、历史、文化、做法等字段,关键是显式标注类别(用于后续统计)。
示例代码(部分):
javascript
// 美食数据库(按类别分组)
const foodDatabase = {
chinese: [
{
name: "麻婆豆腐",
origin: "四川成都",
history: "清同治年间由陈麻婆首创,川菜麻辣味型代表",
culture: "体现川人“尚滋味、好辛香”的饮食文化",
method: "嫩豆腐焯水,炒香肉末加豆瓣酱,勾芡撒花椒粉",
category: "chinese" // 关键:标注类别
},
// 其他中餐数据...
],
western: [ /* 西餐数据 */ ],
fastfood: [ /* 快餐数据 */ ],
// 其他类别...
};
2. 随机推荐功能(交互层)用户选择分类后,点击「启动雷达扫描」按钮,从对应类别(或全类别)的美食中随机抽取一个,并展示详情。这里需要处理两种情况:
选「全部类别」:合并所有类别数据选具体类别:直接取对应数组示例代码(核心逻辑):
javascript
// 随机推荐按钮点击事件
document.getElementById('randomBtn').addEventListener('click', () => {
const category = document.getElementById('categorySelect').value;
// 根据分类获取目标数据(全部分类则合并数组)
const targetList = category === 'all'
? Object.values(foodDatabase).flat() // 合并所有类别数组
: foodDatabase[category] || []; // 具体类别数组
if (targetList.length === 0) {
alert('该类别暂无数据,请选择其他类别~');
return;
}
// 随机选取一个美食
const randomFood = targetList[Math.floor(Math.random() * targetList.length)];
// 渲染推荐结果(含添加订单按钮)
renderFoodDetail(randomFood);
});
// 渲染美食详情的函数(部分)
function renderFoodDetail(food) {
const resultArea = document.getElementById('resultArea');
resultArea.innerHTML = `
<div class="bg-white rounded-lg shadow-lg p-6 text-center">
<h2 class="text-2xl font-bold text-blue-600 mb-2">今日探测结果:${food.name}</h2>
<button class="add-btn">添加到订单</button>
<!-- 其他详情展示... -->
</div>
`;
// 添加订单按钮点击事件(防重复添加)
document.querySelector('.add-btn').addEventListener('click', () => {
if (!userData.order.some(f => f.name === food.name)) {
userData.order.push(food);
localStorage.setItem('foodOrder', JSON.stringify(userData.order));
updateOrderList(); // 更新订单列表
}
});
}
3. 订单管理(状态与存储)订单功能需要实现:
展示当前待吃清单(支持删除)提交订单时清空列表,并更新饮食统计数据持久化(用 localStorage 存储订单和统计数据)示例代码(订单删除逻辑):
javascript
// 订单列表删除按钮事件(事件委托)
document.getElementById('orderList').addEventListener('click', (e) => {
if (e.target.classList.contains('remove-btn')) {
const index = parseInt(e.target.dataset.index, 10);
// 从数组中删除对应项
userData.order.splice(index, 1);
// 本地存储更新
localStorage.setItem('foodOrder', JSON.stringify(userData.order));
// 重新渲染订单列表
updateOrderList();
}
});
4. 饮食偏好统计(可视化)统计功能用柱状图展示各分类的累计订单量,核心步骤:
从 localStorage 读取统计数据计算最大值(用于柱状图高度比例)动态生成 Y 轴刻度和柱状图 DOM示例代码(统计渲染函数):
javascript
function renderStatistics() {
const categories = Object.keys(foodDatabase); // 获取所有类别
const stats = categories.map(cat => ({
category: cat,
count: userData.statistics[cat] || 0 // 读取统计数据(无则为0)
}));
const maxCount = Math.max(...stats.map(s => s.count), 1); // 防止高度为0
// 生成Y轴刻度(最多5个刻度)
const yTicks = [];
const tickInterval = Math.ceil(maxCount / 4);
for (let i = 0; i <= maxCount; i += tickInterval) {
yTicks.push(i);
}
// 动态构建柱状图HTML
const barContainer = document.getElementById('categoryBar');
barContainer.innerHTML = `
<div class="y-axis">${yTicks.map(tick => `<span>${tick}</span>`).join('')}</div>
<div class="flex flex-wrap gap-4 justify-center ml-8">
${stats.map(({ category, count }) => `
<div class="chart-bar">
<div class="bar" style="height: ${(count / maxCount) * 150}px;">
<span class="bar-count">${count}</span>
</div>
<p class="bar-label">${category}</p>
</div>
`).join('')}
</div>
`;
}
三、技术亮点总结 响应式设计:用 Tailwind CSS 实现移动端 / PC 端适配,无需手写媒体查询用户体验细节:订单重复添加提示(按钮置灰 + title 提示)提交成功 Toast 动画(通过 opacity 过渡实现淡入淡出)滚动溢出处理(订单列表和模态框设置 max-h+overflow-y-auto)数据持久化:利用 localStorage 存储订单和统计数据,刷新页面不丢失动态图表:纯 JS 计算柱状图高度,配合 CSS 过渡实现平滑动画 四、项目扩展方向这个小工具还可以继续优化,比如:
添加「历史推荐记录」功能(存储已推荐过的美食)支持「口味偏好设置」(辣度 / 甜度过滤)对接真实 API(获取实时餐厅数据)增加饼图 / 折线图等更多统计维度 结语这个项目完整覆盖了前端基础知识点:HTML 结构搭建、CSS 样式布局、JavaScript 交互逻辑,以及本地存储的使用。通过动手实现一个「有用」的小工具,能更深刻理解前端三兄弟的协作方式~源码已整理好,需要的小伙伴可以评论区留言~ QQ群324074264
(注:文中示例代码为核心逻辑节选,完整代码可查看原文中的 HTML 文件)
HTML文件:可直接复制以下使用
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>干饭雷达:今日美食探测仪</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
<style>
.order-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border-radius: 0.75rem;
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05);
width: 90%;
max-width: 500px;
z-index: 100;
}
.success-toast {
position: fixed;
top: 20%;
left: 50%;
transform: translateX(-50%);
background: #10b981;
color: white;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
opacity: 0;
transition: opacity 0.3s ease;
z-index: 101;
}
.chart-container {
position: relative;
padding: 1rem;
min-height: 200px;
}
.chart-bar {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
min-width: 80px;
}
.bar {
background: #3b82f6;
width: 50px;
border-radius: 0.25rem;
transition: height 0.3s ease;
position: relative;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2);
}
.bar-count {
position: absolute;
top: -1.25rem;
left: 50%;
transform: translateX(-50%);
font-size: 0.75rem;
color: #374151;
font-weight: 500;
}
.bar-label {
font-size: 0.875rem;
color: #4b5563;
text-align: center;
word-wrap: break-word;
}
.backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 99;
}
.no-data {
text-align: center;
color: #6b7280;
font-size: 0.875rem;
padding: 1rem;
}
.y-axis {
position: absolute;
left: 0;
top: 1rem;
bottom: 3rem;
width: 2rem;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
color: #6b7280;
font-size: 0.75rem;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<div class="container mx-auto px-4 py-8 max-w-6xl">
<!-- 头部 -->
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900 md:text-4xl"> 干饭雷达:今日美食探测仪</h1>
<p class="mt-2 text-sm text-gray-500">扫描全球美食,定制你的今日干饭计划</p>
</div>
<!-- 操作区 -->
<div class="bg-white rounded-lg shadow-lg p-6 mb-8">
<div class="flex flex-col md:flex-row gap-4">
<div class="flex-1">
<label class="block text-sm font-medium text-gray-700 mb-2">选择美食类别</label>
<select id="categorySelect" class="w-full border rounded-lg p-2 focus:ring-blue-500 focus:border-blue-500">
<option value="all">全部类别</option>
<option value="chinese">中餐</option>
<option value="western">西餐</option>
<option value="fastfood">快餐</option>
<option value="japanese">日料</option>
<option value="snack">小吃</option>
</select>
</div>
<div class="flex items-end">
<button id="randomBtn" class="w-full md:w-auto bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg">
启动雷达扫描
</button>
</div>
</div>
</div>
<!-- 推荐结果区 -->
<div class="mb-8" id="resultArea">
<div class="bg-white rounded-lg shadow-lg p-6">
<div class="text-center text-gray-500">点击「启动雷达扫描」获取今日推荐美食</div>
</div>
</div>
<!-- 订单与统计区 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 订单区 -->
<div class="bg-white rounded-lg shadow-lg p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">❤️ 我的订单(今日待吃清单)</h3>
<div id="orderList" class="space-y-2 max-h-[300px] overflow-y-auto">
<p class="text-gray-500">订单为空,扫描美食后点击「添加到订单」吧!</p>
</div>
<div class="mt-4 text-center">
<button id="submitOrder" class="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-lg disabled:opacity-50">
<i class="fas fa-shopping-bag"></i>
<span>提交今日订单</span>
</button>
</div>
</div>
<!-- 统计区 -->
<div class="bg-white rounded-lg shadow-lg p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4"> 饮食偏好统计(累计已提交订单)</h3>
<div class="chart-container" id="categoryBar"></div>
</div>
</div>
</div>
<!-- 订单详情模态框(隐藏) -->
<div id="orderModal" class="hidden">
<div class="backdrop"></div>
<div class="order-modal">
<div class="p-6">
<h3 class="text-xl font-bold text-gray-900 mb-4"> 订单详情</h3>
<div id="modalOrderList" class="space-y-2 mb-4 max-h-[300px] overflow-y-auto"></div>
<p class="text-sm text-gray-600 mb-4">总共有 <span id="modalTotal">0</span> 道美食待享用</p>
<div class="text-right">
<button id="confirmOrder" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg">
确认提交
</button>
<button id="cancelOrder" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg ml-2">
取消
</button>
</div>
</div>
</div>
</div>
<!-- 提交成功提示(隐藏) -->
<div id="successToast" class="success-toast">订单提交成功!️</div>
<script>
// 美食数据库(补充category字段,确保统计精准)
const foodDatabase = {
chinese: [
{
name: "麻婆豆腐",
origin: "四川成都",
history: "清同治年间由陈麻婆首创,川菜麻辣味型代表",
culture: "体现川人“尚滋味、好辛香”的饮食文化",
method: "嫩豆腐焯水,炒香肉末加豆瓣酱,勾芡撒花椒粉",
category: "chinese" // 显式声明类别
},
{
name: "扬州炒饭",
origin: "江苏扬州",
history: "源于隋朝“碎金饭”,明清成为淮扬经典",
culture: "淮扬菜“选料严谨、刀工精细”的体现",
method: "隔夜饭打散,加火腿虾仁玉米粒翻炒",
category: "chinese"
}
],
western: [
{
name: "惠灵顿牛排",
origin: "英国伦敦",
history: "因惠灵顿公爵喜爱得名,19世纪英国经典",
culture: "欧洲精致餐饮文化的代表",
method: "牛排煎香裹鹅肝酱,起酥皮包紧烤制",
category: "western"
},
{
name: "勃艮第牛肉",
origin: "法国勃艮第",
history: "用当地红酒慢炖,19世纪巴黎推广",
culture: "法国葡萄酒与烹饪结合的典范",
method: "牛肉煎上色,加红酒和牛骨汤慢炖2小时",
category: "western"
}
],
fastfood: [
{
name: "巨无霸汉堡",
origin: "美国宾夕法尼亚",
history: "1967年麦当劳发明,全球年销5亿个",
culture: "美式快餐标准化生产的代表",
method: "牛肉饼煎五分熟,夹酸黄瓜生菜芝士",
category: "fastfood"
}
],
japanese: [
{
name: "江户前寿司",
origin: "日本东京",
history: "19世纪“握寿司”成为现代雏形",
culture: "体现“旬之味”的季节饮食文化",
method: "醋饭压实,放上新鲜金枪鱼/三文鱼",
category: "japanese"
}
],
snack: [
{
name: "长沙臭豆腐",
origin: "湖南长沙",
history: "清代王致和发明,街头经典小吃",
culture: "“闻臭吃香”的湖湘饮食特色",
method: "豆腐发酵后油炸,淋辣卤香菜",
category: "snack"
}
]
};
// 用户数据(统计基于已提交订单)
const userData = {
order: JSON.parse(localStorage.getItem('foodOrder')) || [],
statistics: JSON.parse(localStorage.getItem('foodStatistics')) || {} // 格式:{ chinese: 3, western: 2 }
};
// 初始化订单列表
function updateOrderList() {
const orderList = document.getElementById('orderList');
if (userData.order.length === 0) {
orderList.innerHTML = '<p class="text-gray-500">订单为空,扫描美食后点击「添加到订单」吧!</p>';
document.getElementById('submitOrder').disabled = true;
return;
}
orderList.innerHTML = userData.order.map((food, index) => `
<div class="bg-gray-50 p-3 rounded-lg flex justify-between items-center">
<div class="flex items-center gap-2">
<span class="text-sm">${index + 1}. ${food.name}</span>
</div>
<button class="text-red-500 hover:text-red-600 remove-btn" data-index="${index}">
<i class="fas fa-trash"></i>
</button>
</div>
`).join('');
document.getElementById('submitOrder').disabled = false;
}
// 移除订单
document.getElementById('orderList').addEventListener('click', (e) => {
if (e.target.classList.contains('remove-btn')) {
const index = parseInt(e.target.dataset.index, 10);
userData.order.splice(index, 1);
localStorage.setItem('foodOrder', JSON.stringify(userData.order));
updateOrderList();
}
});
// 显示订单详情模态框
function showOrderModal() {
const modalOrderList = document.getElementById('modalOrderList');
modalOrderList.innerHTML = userData.order.map(food => `
<div class="bg-gray-50 p-3 rounded-lg flex justify-between items-center">
<span class="text-sm">${food.name}</span>
<span class="text-sm text-gray-600">${food.origin}</span>
</div>
`).join('');
document.getElementById('modalTotal').textContent = userData.order.length;
document.getElementById('orderModal').classList.remove('hidden');
}
// 提交订单逻辑
document.getElementById('submitOrder').addEventListener('click', () => {
if (userData.order.length === 0) return;
showOrderModal();
});
// 确认提交订单(核心统计更新逻辑)
document.getElementById('confirmOrder').addEventListener('click', () => {
// 更新统计数据:遍历当前订单中的每个美食,累加对应类别计数
userData.order.forEach(food => {
const category = food.category;
userData.statistics[category] = (userData.statistics[category] || 0) + 1;
});
// 清空当前订单
userData.order = [];
// 持久化存储
localStorage.setItem('foodStatistics', JSON.stringify(userData.statistics));
localStorage.setItem('foodOrder', JSON.stringify(userData.order));
// 显示成功提示并更新界面
document.getElementById('orderModal').classList.add('hidden');
const toast = document.getElementById('successToast');
toast.style.opacity = '1';
setTimeout(() => toast.style.opacity = '0', 2000);
updateOrderList();
renderStatistics(); // 关键:提交后立即刷新统计图表
});
// 取消订单提交
document.getElementById('cancelOrder').addEventListener('click', () => {
document.getElementById('orderModal').classList.add('hidden');
});
// 渲染统计图表(增强版)
function renderStatistics() {
const categories = Object.keys(foodDatabase);
const barContainer = document.getElementById('categoryBar');
const stats = categories.map(cat => ({
category: cat,
count: userData.statistics[cat] || 0
}));
const maxCount = Math.max(...stats.map(s => s.count), 1); // 防止除零错误
// 处理无数据情况
if (maxCount === 0) {
barContainer.innerHTML = '<div class="no-data">暂无已提交订单统计数据</div>';
return;
}
// 生成Y轴刻度(最多5个刻度)
const yTicks = [];
const tickInterval = Math.ceil(maxCount / 4);
for (let i = 0; i <= maxCount; i += tickInterval) {
yTicks.push(i);
}
// 构建图表HTML
barContainer.innerHTML = `
<div class="y-axis">
${yTicks.map(tick => `
<span>${tick}</span>
`).join('')}
</div>
<div class="flex flex-wrap gap-4 justify-center ml-8">
${stats.map(({ category, count }) => `
<div class="chart-bar">
<div class="bar" style="height: ${(count / maxCount) * 150}px;">
<span class="bar-count">${count}</span>
</div>
<p class="bar-label">${category}</p>
</div>
`).join('')}
</div>
`;
}
// 随机推荐逻辑
document.getElementById('randomBtn').addEventListener('click', () => {
const category = document.getElementById('categorySelect').value;
const targetList = category === 'all'
? Object.values(foodDatabase).flat()
: foodDatabase[category] || [];
if (targetList.length === 0) {
alert('该类别暂无数据,请选择其他类别~');
return;
}
const randomFood = targetList[Math.floor(Math.random() * targetList.length)];
const isInOrder = userData.order.some(f => f.name === randomFood.name);
document.getElementById('resultArea').innerHTML = `
<div class="bg-white rounded-lg shadow-lg p-6 text-center">
<h2 class="text-2xl font-bold text-blue-600 mb-2">今日探测结果:${randomFood.name}</h2>
<button class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg ${isInOrder ? 'disabled' : ''}"
${isInOrder ? 'title="已在订单中"' : ''}>
<i class="fas fa-cart-plus"></i>
<span>${isInOrder ? '已添加' : '添加到订单'}</span>
</button>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div class="bg-gray-50 p-4 rounded-lg">
<h4 class="text-sm font-medium text-gray-700 mb-2"> 基本信息</h4>
<p class="text-xs text-gray-600">产地:${randomFood.origin}</p>
<p class="text-xs text-gray-600">历史:${randomFood.history}</p>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<h4 class="text-sm font-medium text-gray-700 mb-2">️ 文化与做法</h4>
<p class="text-xs text-gray-600">饮食文化:${randomFood.culture}</p>
<p class="text-xs text-gray-600">简单做法:${randomFood.method}</p>
</div>
</div>
</div>
`;
// 添加订单事件
document.getElementById('resultArea').querySelector('button').addEventListener('click', () => {
if (!isInOrder) {
userData.order.push(randomFood);
localStorage.setItem('foodOrder', JSON.stringify(userData.order));
updateOrderList();
}
});
});
// 初始化界面
updateOrderList();
renderStatistics(); // 页面加载时渲染初始统计
</script>
</body>
</html>
网址:用前端三兄弟做个「干饭雷达」:手把手教你实现美食推荐 + 订单管理系统 https://www.yuejiaxmz.com/news/view/1409758
相关内容
“技以载道 匠心筑梦” 兄弟装饰标准化工程管理体系成就十万客户选择手把手教你用Python实现智能推荐算法
15款兄弟双拼共用堂屋的设计农村自建房别墅,兄弟常来常往更亲近
师大美食推荐移动应用系统的设计与实现(29页)
现实?兄弟姐妹之间,往往会“装”,关系才更长远!
把美味装在手机里:美食类APP推荐指南
把美味装在手机里美食类APP推荐指南
8套一层农村兄弟双拼别墅,省钱又实用的一层兄弟双拼自建房别墅
ssm美食推荐管理系统
勒南兄弟——法国现实主义的先声

