Files
renjianbo f2e65a8fbb feat: virtual company module, team projects, PWA dishes app, and startup scripts overhaul
- Add company module (3-tier org, CEO planning, parallel departments)
- Add company orchestrator, knowledge extractor, presets, scheduler
- Add company API endpoints, models, and frontend views
- Add 今天吃啥 PWA app (69 dishes, real images, offline support)
- Add team_projects output directory structure
- Add unified manage.ps1 for service lifecycle
- Add Windows startup guide v1.0
- Add TTS troubleshooting doc
- Update frontend (AgentChat UX overhaul, new views)
- Update backend (voice engine fix, multi-tenant, RBAC)
- Remove deprecated startup scripts and old docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-10 22:37:56 +08:00

1284 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ============================================================
* 今天吃啥 · 渭南家常菜推荐助手 — 核心交互逻辑
* 模块化 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();
})();