用前端三兄弟做个「干饭雷达」:手把手教你实现美食推荐 + 订单管理系统

发布时间:2025-11-24 15:50

手把手教你做手工皂,环保又实用 #生活乐趣# #生活分享# #美食生活分享# #手作生活体验#

前言

打工人的每日灵魂拷问:「中午吃啥?晚上吃啥?」为了解决这个世纪难题,我用 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美食推荐管理系统
勒南兄弟——法国现实主义的先声

随便看看