1284 lines
46 KiB
JavaScript
1284 lines
46 KiB
JavaScript
|
|
/**
|
|||
|
|
* ============================================================
|
|||
|
|
* 今天吃啥 · 渭南家常菜推荐助手 — 核心交互逻辑
|
|||
|
|
* 模块化 IIFE · ES6 · localStorage 持久化 · 全边界处理
|
|||
|
|
* Phase 4 — app.js(集成联调版:内联数据优先 + grocery checkbox 修复)
|
|||
|
|
* ============================================================
|
|||
|
|
*/
|
|||
|
|
(function () {
|
|||
|
|
'use strict';
|
|||
|
|
|
|||
|
|
// ==================== DOM 缓存 ====================
|
|||
|
|
const $ = (sel) => document.querySelector(sel);
|
|||
|
|
const $$ = (sel) => document.querySelectorAll(sel);
|
|||
|
|
|
|||
|
|
const DOM = {
|
|||
|
|
// 区域
|
|||
|
|
sections: {
|
|||
|
|
home: $('#section-home'),
|
|||
|
|
filter: $('#section-filter'),
|
|||
|
|
results: $('#section-results'),
|
|||
|
|
weekly: $('#section-weekly'),
|
|||
|
|
grocery: $('#section-grocery'),
|
|||
|
|
favorites: $('#section-favorites')
|
|||
|
|
},
|
|||
|
|
// 推荐区
|
|||
|
|
recommendCard: $('#recommend-card'),
|
|||
|
|
btnRandom: $('#btn-random'),
|
|||
|
|
// 筛选区
|
|||
|
|
filterChips: $$('#section-filter .chip'),
|
|||
|
|
toggleSpicy: $('#toggle-spicy'),
|
|||
|
|
togglePork: $('#toggle-pork'),
|
|||
|
|
toggleSeafood: $('#toggle-seafood'),
|
|||
|
|
// 结果区
|
|||
|
|
dishesGrid: $('#dishes-grid'),
|
|||
|
|
resultCount: $('#result-count'),
|
|||
|
|
emptyState: $('#empty-state'),
|
|||
|
|
sortSelect: $('#sort-select'),
|
|||
|
|
btnClearFilters: $('#btn-clear-filters'),
|
|||
|
|
// 周菜单
|
|||
|
|
weeklyGrid: $('#weekly-grid'),
|
|||
|
|
btnRegenerateWeekly: $('#btn-regenerate-weekly'),
|
|||
|
|
btnWeeklyToGrocery: $('#btn-weekly-to-grocery'),
|
|||
|
|
// 买菜清单
|
|||
|
|
groceryList: $('#grocery-list'),
|
|||
|
|
groceryEmpty: $('#grocery-empty'),
|
|||
|
|
btnClearGrocery: $('#btn-clear-grocery'),
|
|||
|
|
// 收藏
|
|||
|
|
favGrid: $('#fav-grid'),
|
|||
|
|
favCount: $('#fav-count'),
|
|||
|
|
favEmpty: $('#fav-empty'),
|
|||
|
|
// 忌口
|
|||
|
|
dietaryInput: $('#dietary-input'),
|
|||
|
|
dietaryTags: $('#dietary-tags'),
|
|||
|
|
btnAddDietary: $('#btn-add-dietary'),
|
|||
|
|
// 弹窗
|
|||
|
|
modalOverlay: $('#modal-overlay'),
|
|||
|
|
modalBody: $('#modal-body'),
|
|||
|
|
modalClose: $('#modal-close'),
|
|||
|
|
modalTitle: $('#modal-title'),
|
|||
|
|
modalTags: $$('.modal-tags')[0],
|
|||
|
|
modalIngredientsList: $('#modal-ingredients-list'),
|
|||
|
|
modalStepsList: $('#modal-steps-list'),
|
|||
|
|
btnModalFav: $('#btn-modal-fav'),
|
|||
|
|
btnModalAddWeekly: $('#btn-modal-add-weekly'),
|
|||
|
|
// Toast
|
|||
|
|
toastContainer: $('#toast-container'),
|
|||
|
|
// 导航
|
|||
|
|
navBtns: $$('.nav-btn')
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ==================== 全局状态 ====================
|
|||
|
|
let allDishes = []; // 全部69道菜
|
|||
|
|
let currentFilters = { // 当前筛选条件
|
|||
|
|
category: 'all',
|
|||
|
|
meat: 'all',
|
|||
|
|
time: 'all',
|
|||
|
|
tag: 'all'
|
|||
|
|
};
|
|||
|
|
let currentRecommendDish = null; // 当前推荐的菜品
|
|||
|
|
let currentModalDish = null; // 当前弹窗显示的菜品
|
|||
|
|
let weeklyMenuData = []; // 周菜单数据 [7天 × {lunch, dinner}]
|
|||
|
|
let groceryData = {}; // 买菜清单聚合数据
|
|||
|
|
let dataLoaded = false;
|
|||
|
|
|
|||
|
|
// ==================== 存储管理 ====================
|
|||
|
|
const STORAGE_KEYS = {
|
|||
|
|
FAVORITES: 'wndc_favorites',
|
|||
|
|
DIETARY: 'wndc_dietary',
|
|||
|
|
HISTORY: 'wndc_history',
|
|||
|
|
WEEKLY: 'wndc_weekly',
|
|||
|
|
GROCERY: 'wndc_grocery'
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function safeGetJSON(key, fallback) {
|
|||
|
|
try {
|
|||
|
|
const raw = localStorage.getItem(key);
|
|||
|
|
return raw ? JSON.parse(raw) : fallback;
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn(`[Storage] 读取 "${key}" 失败,使用默认值`, e);
|
|||
|
|
return fallback;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function safeSetJSON(key, value) {
|
|||
|
|
try {
|
|||
|
|
localStorage.setItem(key, JSON.stringify(value));
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn(`[Storage] 写入 "${key}" 失败`, e);
|
|||
|
|
showToast('存储空间不足,请清理浏览器缓存');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getFavorites() { return safeGetJSON(STORAGE_KEYS.FAVORITES, []); }
|
|||
|
|
function setFavorites(ids) { safeSetJSON(STORAGE_KEYS.FAVORITES, ids); }
|
|||
|
|
function getDietary() { return safeGetJSON(STORAGE_KEYS.DIETARY, []); }
|
|||
|
|
function setDietary(items) { safeSetJSON(STORAGE_KEYS.DIETARY, items); }
|
|||
|
|
function getHistory() { return safeGetJSON(STORAGE_KEYS.HISTORY, []); }
|
|||
|
|
function setHistory(ids) { safeSetJSON(STORAGE_KEYS.HISTORY, ids); }
|
|||
|
|
function getWeekly() { return safeGetJSON(STORAGE_KEYS.WEEKLY, null); }
|
|||
|
|
function setWeekly(data) { safeSetJSON(STORAGE_KEYS.WEEKLY, data); }
|
|||
|
|
function getGrocery() { return safeGetJSON(STORAGE_KEYS.GROCERY, null); }
|
|||
|
|
function setGrocery(data) { safeSetJSON(STORAGE_KEYS.GROCERY, data); }
|
|||
|
|
|
|||
|
|
// ==================== Toast 提示 ====================
|
|||
|
|
function showToast(message, duration = 2200) {
|
|||
|
|
const toast = document.createElement('div');
|
|||
|
|
toast.className = 'toast';
|
|||
|
|
toast.textContent = message;
|
|||
|
|
toast.setAttribute('role', 'status');
|
|||
|
|
DOM.toastContainer.appendChild(toast);
|
|||
|
|
|
|||
|
|
setTimeout(() => {
|
|||
|
|
if (toast.parentNode) toast.parentNode.removeChild(toast);
|
|||
|
|
}, duration + 300);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 数据加载 ====================
|
|||
|
|
// 离线优先策略:优先读取内联数据 window.__DISHES__(零外部请求),
|
|||
|
|
// 回退到 fetch('dishes.json')(本地开发/HTTP 服务场景)。
|
|||
|
|
async function loadDishes() {
|
|||
|
|
try {
|
|||
|
|
// 策略1:内联数据(已在 index.html 中通过 <script> 注入,100% 离线可用)
|
|||
|
|
if (window.__DISHES__ && Array.isArray(window.__DISHES__) && window.__DISHES__.length > 0) {
|
|||
|
|
allDishes = window.__DISHES__;
|
|||
|
|
dataLoaded = true;
|
|||
|
|
console.log(`[Data] 从内联数据加载 ${allDishes.length} 道菜谱(离线模式)`);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 策略2:fetch 远程/本地 JSON 文件
|
|||
|
|
const resp = await fetch('dishes.json');
|
|||
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|||
|
|
allDishes = await resp.json();
|
|||
|
|
if (!Array.isArray(allDishes) || allDishes.length === 0) {
|
|||
|
|
throw new Error('数据格式无效');
|
|||
|
|
}
|
|||
|
|
dataLoaded = true;
|
|||
|
|
console.log(`[Data] 从 dishes.json 加载 ${allDishes.length} 道菜谱`);
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('[Data] 菜谱数据加载失败', e);
|
|||
|
|
showToast('菜谱数据加载失败,请刷新页面重试');
|
|||
|
|
DOM.recommendCard.innerHTML = '<div class="card-body"><p style="color:#E53935;text-align:center;">数据加载失败,请检查后刷新页面</p></div>';
|
|||
|
|
DOM.recommendCard.classList.remove('skeleton-loading');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 工具函数 ====================
|
|||
|
|
|
|||
|
|
/** 获取菜品所有关联食材名称的集合(用于忌口过滤) */
|
|||
|
|
function getDishIngredientNames(dish) {
|
|||
|
|
return new Set(dish.食材.map(ing => ing.名称));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 检查菜品是否被忌口过滤掉 */
|
|||
|
|
function isDishBlockedByDietary(dish, dietaryList) {
|
|||
|
|
if (!dietaryList || dietaryList.length === 0) return false;
|
|||
|
|
const ingredients = getDishIngredientNames(dish);
|
|||
|
|
// 检查 toggle 开关
|
|||
|
|
if (DOM.toggleSpicy.checked) {
|
|||
|
|
const spicyKeywords = ['辣椒', '辣椒面', '辣椒油', '干辣椒', '辣椒酱', '泡椒', '郫县豆瓣酱', '豆瓣酱', '小米辣'];
|
|||
|
|
if (spicyKeywords.some(kw => ingredients.has(kw))) return true;
|
|||
|
|
}
|
|||
|
|
if (DOM.togglePork.checked) {
|
|||
|
|
const porkKeywords = ['猪肉', '猪', '五花肉', '排骨', '猪油', '腊肉', '猪皮', '肥肠'];
|
|||
|
|
if (porkKeywords.some(kw => ingredients.has(kw) || dish.食材.some(i => i.名称.includes('猪')))) return true;
|
|||
|
|
}
|
|||
|
|
if (DOM.toggleSeafood.checked) {
|
|||
|
|
const seafoodKeywords = ['虾', '鱼', '蟹', '鱿', '蛤', '贝', '海带', '紫菜', '虾皮'];
|
|||
|
|
if (seafoodKeywords.some(kw => ingredients.has(kw))) return true;
|
|||
|
|
}
|
|||
|
|
// 检查自定义忌口列表
|
|||
|
|
for (const dietaryItem of dietaryList) {
|
|||
|
|
if (ingredients.has(dietaryItem)) return true;
|
|||
|
|
// 模糊匹配:检查食材名称中包含忌口词的
|
|||
|
|
for (const ingName of ingredients) {
|
|||
|
|
if (ingName.includes(dietaryItem)) return true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 多条件筛选
|
|||
|
|
* @returns {Array} 过滤后的菜品数组
|
|||
|
|
*/
|
|||
|
|
function filterDishes(dishes, filters) {
|
|||
|
|
if (!dishes || dishes.length === 0) return [];
|
|||
|
|
const dietaryList = getDietary();
|
|||
|
|
|
|||
|
|
return dishes.filter(dish => {
|
|||
|
|
// 分类筛选
|
|||
|
|
if (filters.category !== 'all' && dish.分类 !== filters.category) return false;
|
|||
|
|
// 荤素筛选
|
|||
|
|
if (filters.meat !== 'all' && dish.荤素 !== filters.meat) return false;
|
|||
|
|
// 耗时筛选
|
|||
|
|
if (filters.time === 'quick' && dish.耗时分钟 > 20) return false;
|
|||
|
|
if (filters.time === 'medium' && (dish.耗时分钟 <= 20 || dish.耗时分钟 > 60)) return false;
|
|||
|
|
if (filters.time === 'slow' && dish.耗时分钟 <= 60) return false;
|
|||
|
|
// 标签筛选
|
|||
|
|
if (filters.tag !== 'all' && !dish.标签.includes(filters.tag)) return false;
|
|||
|
|
// 忌口过滤
|
|||
|
|
if (isDishBlockedByDietary(dish, dietaryList)) return false;
|
|||
|
|
return true;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Fisher-Yates 洗牌 */
|
|||
|
|
function shuffle(arr) {
|
|||
|
|
const a = [...arr];
|
|||
|
|
for (let i = a.length - 1; i > 0; i--) {
|
|||
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|||
|
|
[a[i], a[j]] = [a[j], a[i]];
|
|||
|
|
}
|
|||
|
|
return a;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 将过滤后菜品按分类分组 */
|
|||
|
|
function groupByCategory(dishes) {
|
|||
|
|
const groups = { '主食': [], '热菜': [], '凉菜': [], '汤羹': [], '小吃': [] };
|
|||
|
|
dishes.forEach(d => {
|
|||
|
|
if (groups[d.分类]) groups[d.分类].push(d);
|
|||
|
|
});
|
|||
|
|
return groups;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 模块1:随机推荐引擎 ====================
|
|||
|
|
/**
|
|||
|
|
* 推荐策略:
|
|||
|
|
* 1. 排除最近7道推荐过的(冷却队列)
|
|||
|
|
* 2. 优先从「主食/热菜/凉菜/汤羹」中各选1道,形成搭配
|
|||
|
|
* 3. 不够时从其他分类补充
|
|||
|
|
* 4. 应用忌口过滤
|
|||
|
|
*/
|
|||
|
|
function recommend() {
|
|||
|
|
if (!dataLoaded || allDishes.length === 0) return null;
|
|||
|
|
|
|||
|
|
const dietaryList = getDietary();
|
|||
|
|
const history = getHistory();
|
|||
|
|
|
|||
|
|
// 可用菜品:排除冷却队列中的 + 忌口过滤
|
|||
|
|
const available = allDishes.filter(d => {
|
|||
|
|
if (history.includes(d.id)) return false;
|
|||
|
|
if (isDishBlockedByDietary(d, dietaryList)) return false;
|
|||
|
|
return true;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (available.length === 0) {
|
|||
|
|
// 全部被排除,清空冷却队列重试
|
|||
|
|
setHistory([]);
|
|||
|
|
const retryAvailable = allDishes.filter(d => !isDishBlockedByDietary(d, dietaryList));
|
|||
|
|
if (retryAvailable.length === 0) {
|
|||
|
|
showToast('所有菜品都被忌口排除了,请调整忌口设置');
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
return retryAvailable[Math.floor(Math.random() * retryAvailable.length)];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 按分类分组
|
|||
|
|
const groups = groupByCategory(available);
|
|||
|
|
|
|||
|
|
// 策略:从主要分类中随机选1道(排除小吃),形成"主食+热菜+凉菜+汤羹"搭配
|
|||
|
|
const mainCategories = ['主食', '热菜', '凉菜', '汤羹'];
|
|||
|
|
const categoryPool = mainCategories.filter(cat => groups[cat].length > 0);
|
|||
|
|
|
|||
|
|
if (categoryPool.length === 0) {
|
|||
|
|
// 只有小吃或其他,随机选一个
|
|||
|
|
return available[Math.floor(Math.random() * available.length)];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 从可用分类中随机选一组搭配
|
|||
|
|
const shuffledCategories = shuffle(categoryPool);
|
|||
|
|
const picked = [];
|
|||
|
|
|
|||
|
|
// 尽量选4道(各分类1道),至少选1道
|
|||
|
|
for (let i = 0; i < Math.min(4, shuffledCategories.length); i++) {
|
|||
|
|
const cat = shuffledCategories[i];
|
|||
|
|
const catDishes = groups[cat];
|
|||
|
|
if (catDishes.length > 0) {
|
|||
|
|
picked.push(catDishes[Math.floor(Math.random() * catDishes.length)]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 如果少于2道,从全部available中补
|
|||
|
|
if (picked.length < 2 && available.length > picked.length) {
|
|||
|
|
const remaining = available.filter(d => !picked.find(p => p.id === d.id));
|
|||
|
|
while (picked.length < 2 && remaining.length > 0) {
|
|||
|
|
const idx = Math.floor(Math.random() * remaining.length);
|
|||
|
|
picked.push(remaining.splice(idx, 1)[0]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 返回搭配组合(挑第一道作为主打推荐显示在推荐卡片)
|
|||
|
|
const primary = picked[0];
|
|||
|
|
primary._companions = picked.slice(1); // 附带搭配
|
|||
|
|
|
|||
|
|
// 更新冷却队列
|
|||
|
|
const newHistory = [...history, primary.id];
|
|||
|
|
if (newHistory.length > 7) newHistory.shift();
|
|||
|
|
setHistory(newHistory);
|
|||
|
|
|
|||
|
|
// 如果有搭配菜,也将它们的id加入冷却
|
|||
|
|
for (const comp of picked.slice(1)) {
|
|||
|
|
if (!newHistory.includes(comp.id)) {
|
|||
|
|
newHistory.push(comp.id);
|
|||
|
|
if (newHistory.length > 10) newHistory.shift();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
setHistory(newHistory);
|
|||
|
|
|
|||
|
|
return primary;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 模块2:筛选过滤器 ====================
|
|||
|
|
function applyFilters() {
|
|||
|
|
if (!dataLoaded) return;
|
|||
|
|
const filtered = filterDishes(allDishes, currentFilters);
|
|||
|
|
renderResults(filtered);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 模块3:周菜单生成器 ====================
|
|||
|
|
/**
|
|||
|
|
* 生成一周(7天)菜单,每天午餐+晚餐 = 14道菜
|
|||
|
|
* 约束:
|
|||
|
|
* - 每顿饭从不同分类中选(午餐侧重热菜+主食,晚餐侧重热菜+汤羹+凉菜)
|
|||
|
|
* - 连续2天不重复菜名
|
|||
|
|
* - 7天内尽量不重复(尽可能做到)
|
|||
|
|
* - 应用忌口过滤
|
|||
|
|
*/
|
|||
|
|
function generateWeeklyMenu() {
|
|||
|
|
if (!dataLoaded || allDishes.length === 0) return [];
|
|||
|
|
|
|||
|
|
const dietaryList = getDietary();
|
|||
|
|
const available = allDishes.filter(d => !isDishBlockedByDietary(d, dietaryList));
|
|||
|
|
|
|||
|
|
if (available.length < 14) {
|
|||
|
|
showToast('可用菜品不足14道,无法生成完整周菜单,请调整忌口');
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const groups = groupByCategory(available);
|
|||
|
|
const usedIds = new Set();
|
|||
|
|
const usedNames = new Set();
|
|||
|
|
const menu = [];
|
|||
|
|
const dayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
|||
|
|
|
|||
|
|
// 每日用餐策略
|
|||
|
|
const mealStrategy = [
|
|||
|
|
{ meal: 'lunch', preferCategories: ['主食', '热菜'], fallback: ['凉菜', '汤羹', '小吃'] },
|
|||
|
|
{ meal: 'dinner', preferCategories: ['热菜', '汤羹'], fallback: ['主食', '凉菜', '小吃'] }
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
function pickDish(categories, usedSet) {
|
|||
|
|
// 先从偏好分类中选
|
|||
|
|
for (const cat of categories) {
|
|||
|
|
const candidates = (groups[cat] || []).filter(d => !usedSet.has(d.id));
|
|||
|
|
if (candidates.length > 0) {
|
|||
|
|
return candidates[Math.floor(Math.random() * candidates.length)];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// fallback: 从所有可用中选
|
|||
|
|
const allCandidates = available.filter(d => !usedSet.has(d.id));
|
|||
|
|
if (allCandidates.length > 0) {
|
|||
|
|
return allCandidates[Math.floor(Math.random() * allCandidates.length)];
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (let day = 0; day < 7; day++) {
|
|||
|
|
const dayDishes = [];
|
|||
|
|
const dayUsedIds = new Set();
|
|||
|
|
|
|||
|
|
for (const strategy of mealStrategy) {
|
|||
|
|
const dish = pickDish([...strategy.preferCategories, ...strategy.fallback], new Set([...usedIds, ...dayUsedIds]));
|
|||
|
|
if (dish) {
|
|||
|
|
dayDishes.push(dish);
|
|||
|
|
dayUsedIds.add(dish.id);
|
|||
|
|
usedIds.add(dish.id);
|
|||
|
|
usedNames.add(dish.菜名);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 去重检查:连续2天不重复菜名
|
|||
|
|
if (day >= 1) {
|
|||
|
|
const prevDayNames = new Set(menu[day - 1].dishes.map(d => d.菜名));
|
|||
|
|
for (let i = 0; i < dayDishes.length; i++) {
|
|||
|
|
if (prevDayNames.has(dayDishes[i].菜名)) {
|
|||
|
|
// 回溯:替换掉重复的菜
|
|||
|
|
const replacement = pickDish(
|
|||
|
|
mealStrategy[i].preferCategories.concat(mealStrategy[i].fallback),
|
|||
|
|
new Set([...usedIds, ...dayUsedIds])
|
|||
|
|
);
|
|||
|
|
if (replacement && !prevDayNames.has(replacement.菜名)) {
|
|||
|
|
usedIds.delete(dayDishes[i].id);
|
|||
|
|
dayUsedIds.delete(dayDishes[i].id);
|
|||
|
|
dayDishes[i] = replacement;
|
|||
|
|
dayUsedIds.add(replacement.id);
|
|||
|
|
usedIds.add(replacement.id);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
menu.push({
|
|||
|
|
day: day,
|
|||
|
|
dayName: dayNames[day],
|
|||
|
|
dishes: dayDishes // [lunch, dinner]
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return menu;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 模块4:买菜清单聚合器 ====================
|
|||
|
|
/**
|
|||
|
|
* 从周菜单菜品中聚合食材,合并相同食材,按品类分组
|
|||
|
|
*/
|
|||
|
|
function aggregateGrocery(menuData) {
|
|||
|
|
if (!menuData || menuData.length === 0) return {};
|
|||
|
|
|
|||
|
|
const ingredientMap = {}; // { "食材名": { 总量描述汇总, 菜品来源[] } }
|
|||
|
|
|
|||
|
|
const allMenuDishes = [];
|
|||
|
|
menuData.forEach(day => {
|
|||
|
|
if (day.dishes) day.dishes.forEach(d => { if (d) allMenuDishes.push(d); });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
allMenuDishes.forEach(dish => {
|
|||
|
|
dish.食材.forEach(ing => {
|
|||
|
|
const name = ing.名称;
|
|||
|
|
const qty = ing.用量;
|
|||
|
|
if (!ingredientMap[name]) {
|
|||
|
|
ingredientMap[name] = { quantities: [], dishes: [] };
|
|||
|
|
}
|
|||
|
|
ingredientMap[name].quantities.push(qty);
|
|||
|
|
if (!ingredientMap[name].dishes.find(d => d.id === dish.id)) {
|
|||
|
|
ingredientMap[name].dishes.push({ id: dish.id, name: dish.菜名 });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 按品类分组(基于食材名称关键词推断品类)
|
|||
|
|
const categories = {
|
|||
|
|
'🥩 肉类': [],
|
|||
|
|
'🥬 蔬菜': [],
|
|||
|
|
'🍚 主食/豆制品': [],
|
|||
|
|
'🧂 调料': [],
|
|||
|
|
'🥚 蛋奶': [],
|
|||
|
|
'🫘 干货': [],
|
|||
|
|
'其他': []
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const meatKeywords = ['肉', '排骨', '肘', '牛', '羊', '鸡', '鱼', '虾', '蟹', '肚', '肥肠', '血', '皮', '腊肉'];
|
|||
|
|
const vegKeywords = ['菜', '椒', '豆', '瓜', '茄', '葱', '蒜', '姜', '韭', '芹', '花', '萝卜', '藕', '笋', '菇', '木耳', '黄花', '紫菜', '海带', '香椿', '菠菜', '白菜', '土豆', '山药'];
|
|||
|
|
const stapleKeywords = ['面', '米', '粉', '豆', '豆腐', '腐', '粉丝', '粉条', '馍', '饼'];
|
|||
|
|
const seasoningKeywords = ['油', '盐', '酱', '醋', '糖', '酒', '粉', '面', '酱', '辣椒', '花椒', '八角', '桂皮', '香叶', '茴香', '味精', '鸡精', '蚝油', '生抽', '老抽', '香油', '料酒', '淀粉', '蜂蜜', '冰糖', '酵母', '碱'];
|
|||
|
|
const eggKeywords = ['蛋', '奶'];
|
|||
|
|
const dryKeywords = ['木耳', '黄花', '枸杞', '红枣', '莲子', '桂圆', '葡萄干', '花生', '芝麻', '核桃', '绿豆', '百合', '陈皮', '紫菜', '虾皮'];
|
|||
|
|
|
|||
|
|
function classifyIngredient(name) {
|
|||
|
|
if (meatKeywords.some(k => name.includes(k))) return '🥩 肉类';
|
|||
|
|
if (dryKeywords.some(k => name.includes(k))) return '🫘 干货';
|
|||
|
|
if (eggKeywords.some(k => name.includes(k))) return '🥚 蛋奶';
|
|||
|
|
if (seasoningKeywords.some(k => name.includes(k))) return '🧂 调料';
|
|||
|
|
if (stapleKeywords.some(k => name.includes(k))) return '🍚 主食/豆制品';
|
|||
|
|
if (vegKeywords.some(k => name.includes(k))) return '🥬 蔬菜';
|
|||
|
|
return '其他';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Object.entries(ingredientMap).forEach(([name, data]) => {
|
|||
|
|
const cat = classifyIngredient(name);
|
|||
|
|
// 智能汇总用量(简单拼接)
|
|||
|
|
const qtySummary = data.quantities.length === 1
|
|||
|
|
? data.quantities[0]
|
|||
|
|
: `${data.quantities.length}份(${data.quantities.join('、')})`;
|
|||
|
|
categories[cat].push({
|
|||
|
|
name,
|
|||
|
|
quantity: qtySummary,
|
|||
|
|
dishCount: data.dishes.length,
|
|||
|
|
dishes: data.dishes
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 移除空分类
|
|||
|
|
Object.keys(categories).forEach(key => {
|
|||
|
|
if (categories[key].length === 0) delete categories[key];
|
|||
|
|
else categories[key].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return categories;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 模块5:收藏/忌口管理 ====================
|
|||
|
|
function toggleFavorite(dishId) {
|
|||
|
|
const favs = getFavorites();
|
|||
|
|
const idx = favs.indexOf(dishId);
|
|||
|
|
if (idx > -1) {
|
|||
|
|
favs.splice(idx, 1);
|
|||
|
|
showToast('已取消收藏');
|
|||
|
|
return false;
|
|||
|
|
} else {
|
|||
|
|
favs.push(dishId);
|
|||
|
|
showToast('已加入收藏 ❤');
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isFavorite(dishId) {
|
|||
|
|
return getFavorites().includes(dishId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function addDietaryItem(item) {
|
|||
|
|
if (!item || !item.trim()) return false;
|
|||
|
|
const trimmed = item.trim();
|
|||
|
|
const dietary = getDietary();
|
|||
|
|
if (dietary.includes(trimmed)) {
|
|||
|
|
showToast('该忌口食材已存在');
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
dietary.push(trimmed);
|
|||
|
|
setDietary(dietary);
|
|||
|
|
renderDietaryTags();
|
|||
|
|
showToast(`已添加忌口:${trimmed}`);
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function removeDietaryItem(item) {
|
|||
|
|
let dietary = getDietary();
|
|||
|
|
dietary = dietary.filter(d => d !== item);
|
|||
|
|
setDietary(dietary);
|
|||
|
|
renderDietaryTags();
|
|||
|
|
showToast(`已移除忌口:${item}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== UI 渲染层 ====================
|
|||
|
|
|
|||
|
|
/** 渲染推荐卡片 */
|
|||
|
|
function renderRecommendCard(dish) {
|
|||
|
|
if (!dish) {
|
|||
|
|
DOM.recommendCard.innerHTML = `
|
|||
|
|
<div class="card-body" style="text-align:center;padding:2rem;">
|
|||
|
|
<p>暂无推荐菜品,请调整忌口设置</p>
|
|||
|
|
</div>`;
|
|||
|
|
DOM.recommendCard.classList.remove('skeleton-loading');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
currentRecommendDish = dish;
|
|||
|
|
|
|||
|
|
let companionsHTML = '';
|
|||
|
|
if (dish._companions && dish._companions.length > 0) {
|
|||
|
|
companionsHTML = `
|
|||
|
|
<div style="margin-top:0.5rem;font-size:0.8rem;color:var(--color-text-muted);">
|
|||
|
|
<span>🍽️ 搭配推荐:</span>
|
|||
|
|
${dish._companions.map(c => `<span class="tag" style="cursor:pointer" data-dish-id="${c.id}">${c.菜名}</span>`).join(' ')}
|
|||
|
|
</div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
DOM.recommendCard.innerHTML = `
|
|||
|
|
<div class="card-img-wrap">
|
|||
|
|
<div class="card-img-placeholder">
|
|||
|
|
<img src="images/dish_${dish.id}.jpg" alt="${escHtml(dish.菜名)}" loading="lazy">
|
|||
|
|
</div>
|
|||
|
|
<span class="card-badge card-badge-category">${dish.分类}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body">
|
|||
|
|
<h3 class="card-dish-name">${escHtml(dish.菜名)}</h3>
|
|||
|
|
<div class="card-dish-tags">
|
|||
|
|
${dish.标签.map(t => `<span class="tag">${escHtml(t)}</span>`).join('')}
|
|||
|
|
</div>
|
|||
|
|
<div class="card-meta">
|
|||
|
|
<span class="meta-item">
|
|||
|
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/></svg>
|
|||
|
|
约${dish.耗时分钟}分钟
|
|||
|
|
</span>
|
|||
|
|
<span class="meta-item">${dish.荤素}</span>
|
|||
|
|
</div>
|
|||
|
|
${companionsHTML}
|
|||
|
|
<button class="btn btn-primary btn-detail" data-dish-id="${dish.id}" aria-label="查看做法">查看做法</button>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
DOM.recommendCard.classList.remove('skeleton-loading');
|
|||
|
|
|
|||
|
|
// 绑定详情按钮
|
|||
|
|
DOM.recommendCard.querySelector('.btn-detail').addEventListener('click', (e) => {
|
|||
|
|
const id = parseInt(e.target.dataset.dishId);
|
|||
|
|
const d = allDishes.find(dd => dd.id === id);
|
|||
|
|
if (d) showModal(d);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 绑定搭配标签点击
|
|||
|
|
DOM.recommendCard.querySelectorAll('[data-dish-id]').forEach(tag => {
|
|||
|
|
tag.addEventListener('click', (e) => {
|
|||
|
|
const id = parseInt(e.target.dataset.dishId);
|
|||
|
|
const d = allDishes.find(dd => dd.id === id);
|
|||
|
|
if (d) showModal(d);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 渲染菜品结果网格 */
|
|||
|
|
function renderResults(dishes) {
|
|||
|
|
if (!dishes || dishes.length === 0) {
|
|||
|
|
DOM.dishesGrid.innerHTML = '';
|
|||
|
|
DOM.emptyState.hidden = false;
|
|||
|
|
DOM.resultCount.textContent = '(0道)';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
DOM.emptyState.hidden = true;
|
|||
|
|
DOM.resultCount.textContent = `(${dishes.length}道)`;
|
|||
|
|
|
|||
|
|
const favs = getFavorites();
|
|||
|
|
|
|||
|
|
DOM.dishesGrid.innerHTML = dishes.map(dish => `
|
|||
|
|
<div class="dish-card" data-dish-id="${dish.id}" role="listitem" tabindex="0">
|
|||
|
|
<div class="card-img-wrap">
|
|||
|
|
<div class="card-img-placeholder">
|
|||
|
|
<img src="images/dish_${dish.id}.jpg" alt="${escHtml(dish.菜名)}" loading="lazy">
|
|||
|
|
</div>
|
|||
|
|
<span class="card-badge card-badge-category">${escHtml(dish.分类)}</span>
|
|||
|
|
${dish.耗时分钟 <= 20 ? '<span class="card-badge card-badge-time" style="top:2.2rem;">⚡快手</span>' : ''}
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body">
|
|||
|
|
<h3 class="card-dish-name">${escHtml(dish.菜名)}</h3>
|
|||
|
|
<div class="card-dish-tags">
|
|||
|
|
${dish.标签.slice(0, 3).map(t => `<span class="tag">${escHtml(t)}</span>`).join('')}
|
|||
|
|
</div>
|
|||
|
|
<div class="card-meta">
|
|||
|
|
<span class="meta-item">⏱ ${dish.耗时分钟}分钟</span>
|
|||
|
|
<span class="meta-item">${dish.荤素}</span>
|
|||
|
|
</div>
|
|||
|
|
<button class="btn btn-fav btn-sm ${favs.includes(dish.id) ? 'btn-fav-active' : ''}" data-fav-id="${dish.id}" aria-label="${favs.includes(dish.id) ? '取消收藏' : '收藏'} ${escHtml(dish.菜名)}">
|
|||
|
|
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
|||
|
|
<path fill="currentColor" d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`).join('');
|
|||
|
|
|
|||
|
|
// 绑定卡片点击 → 弹窗
|
|||
|
|
DOM.dishesGrid.querySelectorAll('.dish-card').forEach(card => {
|
|||
|
|
card.addEventListener('click', (e) => {
|
|||
|
|
if (e.target.closest('.btn-fav')) return; // 不拦截收藏按钮
|
|||
|
|
const id = parseInt(card.dataset.dishId);
|
|||
|
|
const dish = allDishes.find(d => d.id === id);
|
|||
|
|
if (dish) showModal(dish);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 绑定收藏按钮
|
|||
|
|
DOM.dishesGrid.querySelectorAll('.btn-fav').forEach(btn => {
|
|||
|
|
btn.addEventListener('click', (e) => {
|
|||
|
|
e.stopPropagation();
|
|||
|
|
const id = parseInt(btn.dataset.favId);
|
|||
|
|
const result = toggleFavorite(id);
|
|||
|
|
btn.classList.toggle('btn-fav-active', result);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 渲染周菜单 */
|
|||
|
|
function renderWeeklyMenu(menuData) {
|
|||
|
|
if (!menuData || menuData.length === 0) {
|
|||
|
|
DOM.weeklyGrid.innerHTML = `
|
|||
|
|
<div class="empty-state" style="grid-column:1/-1;">
|
|||
|
|
<svg viewBox="0 0 24 24" width="64" height="64" aria-hidden="true"><path fill="#BDBDBD" d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z"/></svg>
|
|||
|
|
<p class="empty-text">暂无周菜单</p>
|
|||
|
|
<p class="empty-hint">点击「重新生成」按钮生成一周菜单</p>
|
|||
|
|
</div>`;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const today = new Date();
|
|||
|
|
const startOfWeek = new Date(today);
|
|||
|
|
startOfWeek.setDate(today.getDate() - today.getDay() + 1); // 周一
|
|||
|
|
|
|||
|
|
DOM.weeklyGrid.innerHTML = menuData.map((day, idx) => {
|
|||
|
|
const date = new Date(startOfWeek);
|
|||
|
|
date.setDate(startOfWeek.getDate() + idx);
|
|||
|
|
const dateStr = `${date.getMonth() + 1}/${date.getDate()}`;
|
|||
|
|
|
|||
|
|
const lunchDish = day.dishes[0];
|
|||
|
|
const dinnerDish = day.dishes[1];
|
|||
|
|
|
|||
|
|
return `
|
|||
|
|
<div class="weekly-day-card" data-day="${day.day}" role="listitem">
|
|||
|
|
<div class="day-header">
|
|||
|
|
<span class="day-name">${day.dayName}</span>
|
|||
|
|
<span class="day-date">${dateStr}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="day-meals">
|
|||
|
|
<div class="meal-slot">
|
|||
|
|
<span class="meal-label">午餐</span>
|
|||
|
|
<span class="meal-dish" data-dish-id="${lunchDish ? lunchDish.id : ''}" style="${lunchDish ? 'cursor:pointer' : ''}">
|
|||
|
|
${lunchDish ? escHtml(lunchDish.菜名) : '—'}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="meal-slot">
|
|||
|
|
<span class="meal-label">晚餐</span>
|
|||
|
|
<span class="meal-dish" data-dish-id="${dinnerDish ? dinnerDish.id : ''}" style="${dinnerDish ? 'cursor:pointer' : ''}">
|
|||
|
|
${dinnerDish ? escHtml(dinnerDish.菜名) : '—'}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
}).join('');
|
|||
|
|
|
|||
|
|
// 绑定菜品名点击 → 弹窗
|
|||
|
|
DOM.weeklyGrid.querySelectorAll('.meal-dish[data-dish-id]').forEach(el => {
|
|||
|
|
el.addEventListener('click', () => {
|
|||
|
|
const id = parseInt(el.dataset.dishId);
|
|||
|
|
const dish = allDishes.find(d => d.id === id);
|
|||
|
|
if (dish) showModal(dish);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 渲染买菜清单 */
|
|||
|
|
function renderGrocery(groceryData) {
|
|||
|
|
if (!groceryData || Object.keys(groceryData).length === 0) {
|
|||
|
|
DOM.groceryEmpty.hidden = false;
|
|||
|
|
DOM.groceryList.innerHTML = '';
|
|||
|
|
DOM.groceryList.appendChild(DOM.groceryEmpty);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
DOM.groceryEmpty.hidden = true;
|
|||
|
|
|
|||
|
|
const categoryIcons = {
|
|||
|
|
'🥩 肉类': '🥩',
|
|||
|
|
'🥬 蔬菜': '🥬',
|
|||
|
|
'🍚 主食/豆制品': '🍚',
|
|||
|
|
'🧂 调料': '🧂',
|
|||
|
|
'🥚 蛋奶': '🥚',
|
|||
|
|
'🫘 干货': '🫘',
|
|||
|
|
'其他': '📦'
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
DOM.groceryList.innerHTML = Object.entries(groceryData).map(([cat, items]) => `
|
|||
|
|
<div class="grocery-category">
|
|||
|
|
<div class="grocery-category-header">
|
|||
|
|
${categoryIcons[cat] || '📦'} ${cat}(${items.length}种)
|
|||
|
|
</div>
|
|||
|
|
<div class="grocery-items">
|
|||
|
|
${items.map(item => `
|
|||
|
|
<div class="grocery-item">
|
|||
|
|
<span class="grocery-item-name">
|
|||
|
|
<input type="checkbox" class="grocery-checkbox" aria-label="标记已买:${escHtml(item.name)}">
|
|||
|
|
<span>${escHtml(item.name)}</span>
|
|||
|
|
</span>
|
|||
|
|
<span class="grocery-item-qty">${escHtml(item.quantity)}</span>
|
|||
|
|
</div>
|
|||
|
|
`).join('')}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`).join('');
|
|||
|
|
|
|||
|
|
// 绑定 checkbox 的划线效果
|
|||
|
|
// 修复:精确定位 .grocery-item-name 内的 <span>(食材名称),而非外层包裹 span
|
|||
|
|
DOM.groceryList.querySelectorAll('.grocery-checkbox').forEach(cb => {
|
|||
|
|
cb.addEventListener('change', () => {
|
|||
|
|
const itemEl = cb.closest('.grocery-item').querySelector('.grocery-item-name span');
|
|||
|
|
if (itemEl) {
|
|||
|
|
if (cb.checked) {
|
|||
|
|
itemEl.classList.add('grocery-item-checked');
|
|||
|
|
} else {
|
|||
|
|
itemEl.classList.remove('grocery-item-checked');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 渲染收藏列表 */
|
|||
|
|
function renderFavorites() {
|
|||
|
|
const favs = getFavorites();
|
|||
|
|
const favDishes = favs.map(id => allDishes.find(d => d.id === id)).filter(Boolean);
|
|||
|
|
DOM.favCount.textContent = `(${favDishes.length}道)`;
|
|||
|
|
|
|||
|
|
if (favDishes.length === 0) {
|
|||
|
|
DOM.favEmpty.hidden = false;
|
|||
|
|
// 清空之前的卡片(保留空状态)
|
|||
|
|
Array.from(DOM.favGrid.children).forEach(child => {
|
|||
|
|
if (child !== DOM.favEmpty) child.remove();
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
DOM.favEmpty.hidden = true;
|
|||
|
|
|
|||
|
|
// 移除旧卡片
|
|||
|
|
Array.from(DOM.favGrid.children).forEach(child => {
|
|||
|
|
if (child !== DOM.favEmpty) child.remove();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
favDishes.forEach(dish => {
|
|||
|
|
const card = document.createElement('div');
|
|||
|
|
card.className = 'dish-card';
|
|||
|
|
card.setAttribute('data-dish-id', dish.id);
|
|||
|
|
card.setAttribute('role', 'listitem');
|
|||
|
|
card.setAttribute('tabindex', '0');
|
|||
|
|
card.innerHTML = `
|
|||
|
|
<div class="card-img-wrap">
|
|||
|
|
<div class="card-img-placeholder">
|
|||
|
|
<img src="images/dish_${dish.id}.jpg" alt="${escHtml(dish.菜名)}" loading="lazy">
|
|||
|
|
</div>
|
|||
|
|
<span class="card-badge card-badge-category">${escHtml(dish.分类)}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body">
|
|||
|
|
<h3 class="card-dish-name">${escHtml(dish.菜名)}</h3>
|
|||
|
|
<div class="card-dish-tags">
|
|||
|
|
${dish.标签.slice(0, 3).map(t => `<span class="tag">${escHtml(t)}</span>`).join('')}
|
|||
|
|
</div>
|
|||
|
|
<div class="card-meta">
|
|||
|
|
<span class="meta-item">⏱ ${dish.耗时分钟}分钟</span>
|
|||
|
|
<span class="meta-item">${dish.荤素}</span>
|
|||
|
|
</div>
|
|||
|
|
<button class="btn btn-fav btn-sm btn-fav-active" data-fav-id="${dish.id}" aria-label="取消收藏 ${escHtml(dish.菜名)}">
|
|||
|
|
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
|||
|
|
<path fill="currentColor" d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
card.addEventListener('click', (e) => {
|
|||
|
|
if (e.target.closest('.btn-fav')) return;
|
|||
|
|
showModal(dish);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
card.querySelector('.btn-fav').addEventListener('click', (e) => {
|
|||
|
|
e.stopPropagation();
|
|||
|
|
toggleFavorite(dish.id);
|
|||
|
|
renderFavorites();
|
|||
|
|
applyFilters(); // 刷新结果列表中的收藏状态
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
DOM.favGrid.appendChild(card);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 渲染忌口标签 */
|
|||
|
|
function renderDietaryTags() {
|
|||
|
|
const dietary = getDietary();
|
|||
|
|
DOM.dietaryTags.innerHTML = dietary.map(item => `
|
|||
|
|
<span class="dietary-tag" role="listitem">
|
|||
|
|
${escHtml(item)}
|
|||
|
|
<button class="dietary-tag-remove" data-dietary="${escHtml(item)}" aria-label="移除忌口:${escHtml(item)}">×</button>
|
|||
|
|
</span>
|
|||
|
|
`).join('');
|
|||
|
|
|
|||
|
|
DOM.dietaryTags.querySelectorAll('.dietary-tag-remove').forEach(btn => {
|
|||
|
|
btn.addEventListener('click', () => {
|
|||
|
|
removeDietaryItem(btn.dataset.dietary);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 渲染弹窗 */
|
|||
|
|
function showModal(dish) {
|
|||
|
|
if (!dish) return;
|
|||
|
|
currentModalDish = dish;
|
|||
|
|
|
|||
|
|
DOM.modalTitle.textContent = dish.菜名;
|
|||
|
|
|
|||
|
|
// 标签 — 使用缓存的 DOM 引用
|
|||
|
|
if (DOM.modalTags) {
|
|||
|
|
DOM.modalTags.innerHTML = dish.标签.map(t => `<span class="tag">${escHtml(t)}</span>`).join('');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 图片
|
|||
|
|
const imgPlaceholder = DOM.modalBody.querySelector('.modal-img-placeholder');
|
|||
|
|
if (imgPlaceholder) {
|
|||
|
|
imgPlaceholder.innerHTML = `<img src="images/dish_${dish.id}.jpg" alt="${escHtml(dish.菜名)}" style="width:100%;height:auto;border-radius:12px;display:block">`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 食材清单
|
|||
|
|
DOM.modalIngredientsList.innerHTML = dish.食材.map(ing => `
|
|||
|
|
<li><strong>${escHtml(ing.名称)}</strong>:${escHtml(ing.用量)}</li>
|
|||
|
|
`).join('');
|
|||
|
|
|
|||
|
|
// 步骤
|
|||
|
|
DOM.modalStepsList.innerHTML = dish.步骤.map(step => `
|
|||
|
|
<li>
|
|||
|
|
<span>${escHtml(step.描述)}<span class="step-time">⏱ ${step.时长分钟}分钟</span></span>
|
|||
|
|
</li>
|
|||
|
|
`).join('');
|
|||
|
|
|
|||
|
|
// 已耗时
|
|||
|
|
const totalTime = dish.步骤.reduce((sum, s) => sum + s.时长分钟, 0);
|
|||
|
|
if (DOM.modalStepsList.parentElement) {
|
|||
|
|
const existingTotal = DOM.modalStepsList.parentElement.querySelector('.step-total-time');
|
|||
|
|
if (existingTotal) existingTotal.remove();
|
|||
|
|
const totalEl = document.createElement('p');
|
|||
|
|
totalEl.className = 'step-total-time';
|
|||
|
|
totalEl.style.cssText = 'margin-top:0.5rem;font-size:0.85rem;color:var(--color-text-muted);';
|
|||
|
|
totalEl.textContent = `总耗时约 ${totalTime} 分钟`;
|
|||
|
|
DOM.modalStepsList.parentElement.appendChild(totalEl);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 收藏按钮状态
|
|||
|
|
const isFav = isFavorite(dish.id);
|
|||
|
|
DOM.btnModalFav.classList.toggle('btn-fav-active', isFav);
|
|||
|
|
DOM.btnModalFav.innerHTML = isFav
|
|||
|
|
? `<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg> 已收藏`
|
|||
|
|
: `<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M16.5 3c-1.74 0-3.41.81-4.5 2.09C10.91 3.81 9.24 3 7.5 3 4.42 3 2 5.42 2 8.5c0 3.78 3.4 6.86 8.55 11.54L12 21.35l1.45-1.32C18.6 15.36 22 12.28 22 8.5 22 5.42 19.58 3 16.5 3zm-4.4 15.55l-.1.1-.1-.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05z"/></svg> 收藏`;
|
|||
|
|
|
|||
|
|
// 显示弹窗
|
|||
|
|
DOM.modalOverlay.hidden = false;
|
|||
|
|
document.body.style.overflow = 'hidden';
|
|||
|
|
|
|||
|
|
// 焦点管理:聚焦关闭按钮
|
|||
|
|
setTimeout(() => DOM.modalClose.focus(), 100);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function hideModal() {
|
|||
|
|
DOM.modalOverlay.hidden = true;
|
|||
|
|
document.body.style.overflow = '';
|
|||
|
|
currentModalDish = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 导航切换 ====================
|
|||
|
|
function switchSection(sectionName) {
|
|||
|
|
// 隐藏所有区域
|
|||
|
|
Object.values(DOM.sections).forEach(s => { if (s) s.hidden = true; });
|
|||
|
|
|
|||
|
|
// 显示目标区域
|
|||
|
|
const targetMap = {
|
|||
|
|
'home': DOM.sections.home,
|
|||
|
|
'weekly': DOM.sections.weekly,
|
|||
|
|
'grocery': DOM.sections.grocery,
|
|||
|
|
'favorites': DOM.sections.favorites
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (targetMap[sectionName]) {
|
|||
|
|
targetMap[sectionName].hidden = false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 首页和筛选/结果区始终可见(除了在非home区时隐藏)
|
|||
|
|
if (sectionName === 'home') {
|
|||
|
|
DOM.sections.filter.hidden = false;
|
|||
|
|
DOM.sections.results.hidden = false;
|
|||
|
|
} else {
|
|||
|
|
DOM.sections.filter.hidden = true;
|
|||
|
|
DOM.sections.results.hidden = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 更新导航状态
|
|||
|
|
DOM.navBtns.forEach(btn => {
|
|||
|
|
btn.setAttribute('aria-current', btn.dataset.nav === sectionName ? 'true' : 'false');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 切换到对应区域时加载数据
|
|||
|
|
if (sectionName === 'weekly') {
|
|||
|
|
if (weeklyMenuData.length === 0) {
|
|||
|
|
const saved = getWeekly();
|
|||
|
|
if (saved && saved.length > 0) {
|
|||
|
|
weeklyMenuData = saved;
|
|||
|
|
} else {
|
|||
|
|
weeklyMenuData = generateWeeklyMenu();
|
|||
|
|
if (weeklyMenuData.length > 0) setWeekly(weeklyMenuData);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
renderWeeklyMenu(weeklyMenuData);
|
|||
|
|
} else if (sectionName === 'grocery') {
|
|||
|
|
if (Object.keys(groceryData).length === 0) {
|
|||
|
|
const saved = getGrocery();
|
|||
|
|
if (saved && Object.keys(saved).length > 0) {
|
|||
|
|
groceryData = saved;
|
|||
|
|
} else if (weeklyMenuData.length > 0) {
|
|||
|
|
groceryData = aggregateGrocery(weeklyMenuData);
|
|||
|
|
setGrocery(groceryData);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
renderGrocery(groceryData);
|
|||
|
|
} else if (sectionName === 'favorites') {
|
|||
|
|
renderFavorites();
|
|||
|
|
renderDietaryTags();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== HTML 转义 ====================
|
|||
|
|
function escHtml(str) {
|
|||
|
|
if (!str) return '';
|
|||
|
|
const div = document.createElement('div');
|
|||
|
|
div.textContent = str;
|
|||
|
|
return div.innerHTML;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ==================== 事件绑定 ====================
|
|||
|
|
|
|||
|
|
/** 导航按钮点击 */
|
|||
|
|
DOM.navBtns.forEach(btn => {
|
|||
|
|
btn.addEventListener('click', () => {
|
|||
|
|
switchSection(btn.dataset.nav);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/** 「换一个」按钮 */
|
|||
|
|
DOM.btnRandom.addEventListener('click', () => {
|
|||
|
|
const dish = recommend();
|
|||
|
|
if (dish) {
|
|||
|
|
renderRecommendCard(dish);
|
|||
|
|
showToast('已为你推荐新菜品 🎲');
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/** 筛选 Chip 点击 */
|
|||
|
|
DOM.filterChips.forEach(chip => {
|
|||
|
|
chip.addEventListener('click', () => {
|
|||
|
|
const group = chip.closest('.filter-group');
|
|||
|
|
if (!group) return;
|
|||
|
|
|
|||
|
|
const filterType = chip.dataset.filter || chip.dataset.meat || chip.dataset.time || chip.dataset.tag;
|
|||
|
|
let filterKey = '';
|
|||
|
|
if (chip.dataset.filter !== undefined) filterKey = 'category';
|
|||
|
|
else if (chip.dataset.meat !== undefined) filterKey = 'meat';
|
|||
|
|
else if (chip.dataset.time !== undefined) filterKey = 'time';
|
|||
|
|
else if (chip.dataset.tag !== undefined) filterKey = 'tag';
|
|||
|
|
|
|||
|
|
if (!filterKey) return;
|
|||
|
|
|
|||
|
|
// 更新 active 状态
|
|||
|
|
group.querySelectorAll('.chip').forEach(c => {
|
|||
|
|
c.setAttribute('aria-pressed', 'false');
|
|||
|
|
c.classList.remove('chip-active');
|
|||
|
|
});
|
|||
|
|
chip.setAttribute('aria-pressed', 'true');
|
|||
|
|
chip.classList.add('chip-active');
|
|||
|
|
|
|||
|
|
// 更新筛选状态
|
|||
|
|
currentFilters[filterKey] = filterType;
|
|||
|
|
applyFilters();
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/** 忌口 Toggle 开关 */
|
|||
|
|
function bindDietaryToggles() {
|
|||
|
|
[DOM.toggleSpicy, DOM.togglePork, DOM.toggleSeafood].forEach(toggle => {
|
|||
|
|
toggle.addEventListener('change', () => {
|
|||
|
|
applyFilters();
|
|||
|
|
// 也刷新推荐
|
|||
|
|
if (currentRecommendDish && isDishBlockedByDietary(currentRecommendDish, getDietary())) {
|
|||
|
|
const newDish = recommend();
|
|||
|
|
if (newDish) renderRecommendCard(newDish);
|
|||
|
|
}
|
|||
|
|
// 刷新周菜单
|
|||
|
|
if (weeklyMenuData.length > 0) {
|
|||
|
|
weeklyMenuData = generateWeeklyMenu();
|
|||
|
|
if (weeklyMenuData.length > 0) setWeekly(weeklyMenuData);
|
|||
|
|
renderWeeklyMenu(weeklyMenuData);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 排序选择 */
|
|||
|
|
DOM.sortSelect.addEventListener('change', () => {
|
|||
|
|
const filtered = filterDishes(allDishes, currentFilters);
|
|||
|
|
const sortType = DOM.sortSelect.value;
|
|||
|
|
switch (sortType) {
|
|||
|
|
case 'time-asc':
|
|||
|
|
filtered.sort((a, b) => a.耗时分钟 - b.耗时分钟);
|
|||
|
|
break;
|
|||
|
|
case 'time-desc':
|
|||
|
|
filtered.sort((a, b) => b.耗时分钟 - a.耗时分钟);
|
|||
|
|
break;
|
|||
|
|
case 'name':
|
|||
|
|
filtered.sort((a, b) => a.菜名.localeCompare(b.菜名, 'zh-CN'));
|
|||
|
|
break;
|
|||
|
|
default: // 默认排序:按id
|
|||
|
|
filtered.sort((a, b) => a.id - b.id);
|
|||
|
|
}
|
|||
|
|
renderResults(filtered);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/** 清除筛选 */
|
|||
|
|
DOM.btnClearFilters.addEventListener('click', () => {
|
|||
|
|
// 重置筛选状态
|
|||
|
|
currentFilters = { category: 'all', meat: 'all', time: 'all', tag: 'all' };
|
|||
|
|
// 重置分组内的第一个(all)
|
|||
|
|
document.querySelectorAll('#section-filter .filter-group').forEach(group => {
|
|||
|
|
const firstChip = group.querySelector('.chip');
|
|||
|
|
if (firstChip) {
|
|||
|
|
group.querySelectorAll('.chip').forEach(c => {
|
|||
|
|
c.setAttribute('aria-pressed', 'false');
|
|||
|
|
c.classList.remove('chip-active');
|
|||
|
|
});
|
|||
|
|
firstChip.setAttribute('aria-pressed', 'true');
|
|||
|
|
firstChip.classList.add('chip-active');
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
applyFilters();
|
|||
|
|
showToast('已清除所有筛选条件');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/** 周菜单 - 重新生成 */
|
|||
|
|
DOM.btnRegenerateWeekly.addEventListener('click', () => {
|
|||
|
|
weeklyMenuData = generateWeeklyMenu();
|
|||
|
|
if (weeklyMenuData.length > 0) {
|
|||
|
|
setWeekly(weeklyMenuData);
|
|||
|
|
renderWeeklyMenu(weeklyMenuData);
|
|||
|
|
// 清除旧的买菜清单
|
|||
|
|
groceryData = {};
|
|||
|
|
setGrocery(null);
|
|||
|
|
renderGrocery({});
|
|||
|
|
showToast('已重新生成本周菜单 ✨');
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/** 周菜单 → 买菜清单 */
|
|||
|
|
DOM.btnWeeklyToGrocery.addEventListener('click', () => {
|
|||
|
|
if (weeklyMenuData.length === 0) {
|
|||
|
|
weeklyMenuData = generateWeeklyMenu();
|
|||
|
|
if (weeklyMenuData.length > 0) setWeekly(weeklyMenuData);
|
|||
|
|
}
|
|||
|
|
if (weeklyMenuData.length === 0) {
|
|||
|
|
showToast('请先生成周菜单');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
groceryData = aggregateGrocery(weeklyMenuData);
|
|||
|
|
setGrocery(groceryData);
|
|||
|
|
renderGrocery(groceryData);
|
|||
|
|
switchSection('grocery');
|
|||
|
|
showToast('买菜清单已生成 🛒');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/** 清空买菜清单 */
|
|||
|
|
DOM.btnClearGrocery.addEventListener('click', () => {
|
|||
|
|
groceryData = {};
|
|||
|
|
setGrocery(null);
|
|||
|
|
renderGrocery({});
|
|||
|
|
showToast('买菜清单已清空');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/** 弹窗 - 关闭 */
|
|||
|
|
DOM.modalClose.addEventListener('click', hideModal);
|
|||
|
|
DOM.modalOverlay.addEventListener('click', (e) => {
|
|||
|
|
if (e.target === DOM.modalOverlay) hideModal();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/** 弹窗 - ESC 关闭 */
|
|||
|
|
document.addEventListener('keydown', (e) => {
|
|||
|
|
if (e.key === 'Escape' && !DOM.modalOverlay.hidden) {
|
|||
|
|
hideModal();
|
|||
|
|
// 焦点回到之前触发弹窗的元素
|
|||
|
|
const activeSection = Object.entries(DOM.sections).find(([_, s]) => !s.hidden);
|
|||
|
|
if (activeSection) {
|
|||
|
|
const firstBtn = activeSection[1].querySelector('button');
|
|||
|
|
if (firstBtn) firstBtn.focus();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/** 弹窗 - 收藏按钮 */
|
|||
|
|
DOM.btnModalFav.addEventListener('click', () => {
|
|||
|
|
if (!currentModalDish) return;
|
|||
|
|
const result = toggleFavorite(currentModalDish.id);
|
|||
|
|
const isFav = isFavorite(currentModalDish.id);
|
|||
|
|
DOM.btnModalFav.classList.toggle('btn-fav-active', isFav);
|
|||
|
|
DOM.btnModalFav.innerHTML = isFav
|
|||
|
|
? `<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg> 已收藏`
|
|||
|
|
: `<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M16.5 3c-1.74 0-3.41.81-4.5 2.09C10.91 3.81 9.24 3 7.5 3 4.42 3 2 5.42 2 8.5c0 3.78 3.4 6.86 8.55 11.54L12 21.35l1.45-1.32C18.6 15.36 22 12.28 22 8.5 22 5.42 19.58 3 16.5 3zm-4.4 15.55l-.1.1-.1-.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05z"/></svg> 收藏`;
|
|||
|
|
applyFilters();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/** 弹窗 - 加入周菜单 */
|
|||
|
|
DOM.btnModalAddWeekly.addEventListener('click', () => {
|
|||
|
|
if (!currentModalDish) return;
|
|||
|
|
if (weeklyMenuData.length === 0) {
|
|||
|
|
weeklyMenuData = generateWeeklyMenu();
|
|||
|
|
if (weeklyMenuData.length > 0) setWeekly(weeklyMenuData);
|
|||
|
|
}
|
|||
|
|
if (weeklyMenuData.length === 0) {
|
|||
|
|
showToast('请先生成周菜单后重试');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
// 尝试将菜品加入有空位的一天
|
|||
|
|
let added = false;
|
|||
|
|
for (const day of weeklyMenuData) {
|
|||
|
|
if (day.dishes.length < 2) {
|
|||
|
|
day.dishes.push(currentModalDish);
|
|||
|
|
added = true;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (added) {
|
|||
|
|
setWeekly(weeklyMenuData);
|
|||
|
|
renderWeeklyMenu(weeklyMenuData);
|
|||
|
|
hideModal();
|
|||
|
|
switchSection('weekly');
|
|||
|
|
showToast(`已将「${currentModalDish.菜名}」加入周菜单`);
|
|||
|
|
} else {
|
|||
|
|
showToast('周菜单已满7天,请重新生成后添加');
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/** 添加忌口 */
|
|||
|
|
DOM.btnAddDietary.addEventListener('click', () => {
|
|||
|
|
const value = DOM.dietaryInput.value;
|
|||
|
|
if (addDietaryItem(value)) {
|
|||
|
|
DOM.dietaryInput.value = '';
|
|||
|
|
applyFilters();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
DOM.dietaryInput.addEventListener('keydown', (e) => {
|
|||
|
|
if (e.key === 'Enter') {
|
|||
|
|
const value = DOM.dietaryInput.value;
|
|||
|
|
if (addDietaryItem(value)) {
|
|||
|
|
DOM.dietaryInput.value = '';
|
|||
|
|
applyFilters();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ==================== 初始化 ====================
|
|||
|
|
async function init() {
|
|||
|
|
try {
|
|||
|
|
// 1. 加载数据(内联优先 → fetch 回退)
|
|||
|
|
await loadDishes();
|
|||
|
|
|
|||
|
|
if (!dataLoaded) return;
|
|||
|
|
|
|||
|
|
// 2. 恢复忌口状态
|
|||
|
|
renderDietaryTags();
|
|||
|
|
|
|||
|
|
// 3. 渲染全部菜品
|
|||
|
|
applyFilters();
|
|||
|
|
|
|||
|
|
// 4. 初始推荐
|
|||
|
|
const dish = recommend();
|
|||
|
|
if (dish) {
|
|||
|
|
renderRecommendCard(dish);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 5. 绑定忌口 toggle
|
|||
|
|
bindDietaryToggles();
|
|||
|
|
|
|||
|
|
// 6. 恢复周菜单
|
|||
|
|
const savedWeekly = getWeekly();
|
|||
|
|
if (savedWeekly && savedWeekly.length > 0) {
|
|||
|
|
weeklyMenuData = savedWeekly;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 7. 恢复买菜清单
|
|||
|
|
const savedGrocery = getGrocery();
|
|||
|
|
if (savedGrocery && Object.keys(savedGrocery).length > 0) {
|
|||
|
|
groceryData = savedGrocery;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('[App] 初始化完成 ✅');
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('[App] 初始化失败', e);
|
|||
|
|
showToast('应用初始化失败,请刷新页面重试');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 启动应用
|
|||
|
|
init();
|
|||
|
|
|
|||
|
|
})();
|