const API_BASE = '/api'; let currentPage = 'menu'; let categories = []; let dishes = []; let orders = []; let currentCategory = ''; let currentOrderFilter = ''; let dishCompletedCounts = {}; // Store how many times each dish appears in completed orders function getErrorMessage(error, fallback = '操作没有成功,请稍后再试。') { if (!error) return fallback; if (typeof error === 'string') return error; if (Array.isArray(error)) return error.join(';'); if (typeof error === 'object') { const values = Object.values(error).flatMap(value => Array.isArray(value) ? value : [value]); const text = values.filter(Boolean).join(';'); return text || fallback; } return fallback; } function showAppToast(message, type = 'success') { const stack = document.getElementById('toast-stack'); if (!stack || !message) return; const toast = document.createElement('div'); toast.className = `app-toast ${type === 'error' ? 'is-error' : 'is-success'}`; toast.innerHTML = `
`; toast.querySelector('.app-toast-copy').textContent = message; stack.appendChild(toast); window.setTimeout(() => { toast.remove(); }, 2600); } function confirmAction(message, title = '确认一下这步操作') { return new Promise(resolve => { const modalEl = document.getElementById('confirm-modal'); const titleEl = document.getElementById('confirm-modal-title'); const messageEl = document.getElementById('confirm-modal-message'); const cancelBtn = document.getElementById('confirm-modal-cancel'); const confirmBtn = document.getElementById('confirm-modal-confirm'); const modal = bootstrap.Modal.getOrCreateInstance(modalEl); let settled = false; titleEl.textContent = title; messageEl.textContent = message; const cleanup = result => { if (settled) return; settled = true; cancelBtn.removeEventListener('click', onCancel); confirmBtn.removeEventListener('click', onConfirm); modalEl.removeEventListener('hidden.bs.modal', onHidden); resolve(result); }; const onCancel = () => { modal.hide(); cleanup(false); }; const onConfirm = () => { modal.hide(); cleanup(true); }; const onHidden = () => cleanup(false); cancelBtn.addEventListener('click', onCancel); confirmBtn.addEventListener('click', onConfirm); modalEl.addEventListener('hidden.bs.modal', onHidden); modal.show(); }); } document.addEventListener('DOMContentLoaded', () => { loadCategories(); loadDishes(); loadOrders(); initEventListeners(); switchPage('menu'); }); function initEventListeners() { // Navigation document.querySelectorAll('.nav-item').forEach(item => { item.addEventListener('click', () => switchPage(item.dataset.page)); }); // Search - click on search box to open search panel const openSearchPanel = () => { document.getElementById('search-panel').classList.add('active'); document.getElementById('search-input').focus(); }; const menuSearchBox = document.getElementById('menu-search-box'); if (menuSearchBox) { menuSearchBox.addEventListener('click', openSearchPanel); } const heroSearchTrigger = document.getElementById('hero-search-trigger'); if (heroSearchTrigger) { heroSearchTrigger.addEventListener('click', openSearchPanel); } const heroOrderTrigger = document.getElementById('hero-order-trigger'); if (heroOrderTrigger) { heroOrderTrigger.addEventListener('click', () => switchPage('orders')); } document.getElementById('btn-close-search').addEventListener('click', () => { document.getElementById('search-panel').classList.remove('active'); document.getElementById('search-input').value = ''; document.getElementById('search-results').innerHTML = ''; const searchFeedback = document.getElementById('search-feedback'); if (searchFeedback) { searchFeedback.textContent = '输入关键词后,这里会显示匹配到的菜谱。'; } }); const searchInput = document.getElementById('search-input'); if (searchInput) { searchInput.addEventListener('input', debounce(loadSearchResults, 300)); } // FAB document.getElementById('fab-add').addEventListener('click', () => { if (currentPage === 'menu') showDishForm(); else if (currentPage === 'orders') showOrderForm(); }); // Order filter document.querySelectorAll('.filter-pill').forEach(pill => { pill.addEventListener('click', () => filterOrders(pill.dataset.status)); }); // Profile document.getElementById('btn-export').addEventListener('click', exportData); // Dish form document.getElementById('btn-add-ingredient').addEventListener('click', addIngredientRow); document.getElementById('btn-save-dish').addEventListener('click', saveDish); document.getElementById('dish-image').addEventListener('change', handleImageUpload); // Order form document.getElementById('btn-save-order').addEventListener('click', saveOrder); document.getElementById('btn-complete-order').addEventListener('click', () => { const orderId = document.getElementById('order-detail-modal').dataset.orderId; completeOrder(orderId); }); document.getElementById('btn-delete-order').addEventListener('click', () => { const orderId = document.getElementById('order-detail-modal').dataset.orderId; deleteOrder(orderId); }); // Parse document.getElementById('btn-apply-parse').addEventListener('click', applyParse); document.getElementById('parse-url').addEventListener('change', debounce(parseUrl, 800)); } // 存储每个页面的滚动位置 const pageScrollPositions = {}; function switchPage(page) { // 保存当前页面的滚动位置 if (currentPage) { pageScrollPositions[currentPage] = window.scrollY; } currentPage = page; document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); document.getElementById(`page-${page}`).classList.add('active'); document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); document.querySelector(`.nav-item[data-page="${page}"]`).classList.add('active'); // 恢复目标页面的滚动位置(无动画) if (pageScrollPositions[page] !== undefined) { window.scroll(0, pageScrollPositions[page]); } else { window.scroll(0, 0); } // Show/hide FAB based on page const fab = document.getElementById('fab-add'); const fabTop = document.getElementById('fab-top'); const fabLabel = document.getElementById('fab-label'); if (page === 'stats' || page === 'profile') { fab.style.display = 'none'; fabTop.style.display = 'none'; } else if (page === 'menu') { fab.style.display = 'flex'; fabTop.style.display = 'flex'; if (fabLabel) fabLabel.textContent = '记菜'; fab.setAttribute('aria-label', '新增菜品'); } else { fab.style.display = 'flex'; fabTop.style.display = 'none'; if (fabLabel) fabLabel.textContent = '建单'; fab.setAttribute('aria-label', '新建聚会单'); } if (page === 'orders') loadOrders(); if (page === 'stats') loadStats(); } async function loadStats() { try { // Load dishes const dishesRes = await fetch(`${API_BASE}/dishes/`); const allDishes = await dishesRes.json(); // Load orders const ordersRes = await fetch(`${API_BASE}/orders/`); const allOrders = await ordersRes.json(); // Calculate dish completed order counts (only completed orders) const dishCompletedCounts = {}; allOrders.filter(o => o.status === 'completed').forEach(order => { (order.dishes || []).forEach(dishId => { dishCompletedCounts[dishId] = (dishCompletedCounts[dishId] || 0) + 1; }); }); // Add order counts to dishes const dishesWithCounts = allDishes.map(dish => ({ ...dish, orderCount: dishCompletedCounts[dish.id] || 0 })); // Sort by order count dishesWithCounts.sort((a, b) => b.orderCount - a.orderCount); // Filter out dishes with 0 order count const dishesWithOrders = dishesWithCounts.filter(dish => dish.orderCount > 0); // Calculate unique participants and their participation counts const participantCounts = {}; allOrders.forEach(order => { if (order.participants && Array.isArray(order.participants)) { order.participants.forEach(name => { participantCounts[name] = (participantCounts[name] || 0) + 1; }); } }); const uniqueParticipants = Object.keys(participantCounts).length; // Store for ranking modal window.participantRanking = Object.entries(participantCounts) .sort((a, b) => b[1] - a[1]) .map(([name, count]) => ({ name, count })); // Update stats document.getElementById('total-dishes').textContent = allDishes.length; document.getElementById('total-gatherings').textContent = allOrders.length; document.getElementById('total-participants').textContent = uniqueParticipants; // Render ranking const rankList = document.getElementById('dish-rank-list'); if (dishesWithOrders.length === 0) { rankList.innerHTML = `
还没有被加入聚会的菜品
`; } else { rankList.innerHTML = dishesWithOrders.slice(0, 10).map((dish, index) => { let rankClass = 'default'; if (index === 0) rankClass = 'gold'; else if (index === 1) rankClass = 'silver'; else if (index === 2) rankClass = 'bronze'; return `
${index + 1}
${dish.name}
${dish.name}
${dish.orderCount} 次
`; }).join(''); } } catch (e) { console.error('加载统计失败', e); } } async function loadCategories() { try { const res = await fetch(`${API_BASE}/categories/`); categories = await res.json(); renderCategories(); } catch (e) { console.error('加载分类失败', e); } } function renderCategories() { const activeCategory = categories.find(cat => String(cat.id) === String(currentCategory)); const currentFilterLabel = document.getElementById('menu-current-filter'); if (currentFilterLabel) { currentFilterLabel.textContent = activeCategory ? `${activeCategory.name}菜谱` : '全部菜谱'; } // Render to orders page category list const container = document.getElementById('category-list'); if (container) { container.innerHTML = `
全部
`; categories.forEach(cat => { container.innerHTML += `
${cat.name}
`; }); container.querySelectorAll('.category-chip').forEach(chip => { chip.addEventListener('click', () => { currentCategory = chip.dataset.id; renderCategories(); loadDishes(); }); }); } // Render to menu page category filter const filterContainer = document.getElementById('category-filter'); if (filterContainer) { filterContainer.innerHTML = `
全部
`; categories.forEach(cat => { filterContainer.innerHTML += `
${cat.name}
`; }); filterContainer.querySelectorAll('.category-chip').forEach(chip => { chip.addEventListener('click', () => { currentCategory = chip.dataset.id; renderCategories(); loadDishes(); }); }); } } async function loadDishes() { let url = `${API_BASE}/dishes/`; const params = new URLSearchParams(); if (currentCategory) params.append('category', currentCategory); if (params.toString()) url += '?' + params.toString(); try { const res = await fetch(url); dishes = await res.json(); if (!currentCategory) { window.allDishCount = dishes.length; } renderDishes(); } catch (e) { console.error('加载菜品失败', e); } } function renderDishes() { const container = document.getElementById('dish-list'); const feedbackEl = document.getElementById('dish-feedback'); const inProgressOrders = orders.filter(order => order.status === 'in_progress'); const activeCategory = categories.find(cat => String(cat.id) === String(currentCategory)); const visibleCount = dishes.length; const totalDishCount = currentCategory ? (window.allDishCount || visibleCount) : visibleCount; if (feedbackEl) { feedbackEl.dataset.totalDishes = String(totalDishCount); feedbackEl.dataset.activeOrders = String(inProgressOrders.length); feedbackEl.dataset.visibleDishes = String(visibleCount); } if (dishes.length === 0) { if (feedbackEl) { feedbackEl.textContent = activeCategory ? `这一页还没有“${activeCategory.name}”菜谱。` : '还没有菜谱,先记下第一道拿手菜吧。'; } container.innerHTML = `
${activeCategory ? `还没有“${activeCategory.name}”菜谱` : '还没有菜品'}
${activeCategory ? '换个分类看看,或者先把这类家常菜记下来。' : '先记下第一道拿手菜,菜谱本就能慢慢丰富起来。'}
`; return; } if (feedbackEl) { if (activeCategory) { feedbackEl.textContent = `现在看到 ${visibleCount} 道「${activeCategory.name}」菜谱,点开就能查看做法和备菜情况。`; } else { feedbackEl.textContent = `现在收着 ${visibleCount} 道家常菜,看看哪些已经加入最近的聚会单。`; } } container.innerHTML = dishes.map(dish => { const inOrder = isDishInOrder(dish.id); const cookedCount = dishCompletedCounts[dish.id] || 0; const statusText = inOrder ? '已加入当前聚会单' : '还没加入聚会单'; const noteText = cookedCount > 0 ? `做过 ${cookedCount} 次` : '还没在聚会中做过'; return `
${dish.name} ${dish.category_name || '未分类'}
${dish.name}
${noteText}
${statusText}
`; }).join(''); container.querySelectorAll('.dish-card').forEach(card => { card.addEventListener('click', () => showDishDetail(card.dataset.id)); }); } async function loadSearchResults() { const search = document.getElementById('search-input').value; const container = document.getElementById('search-results'); const feedbackEl = document.getElementById('search-feedback'); if (!search.trim()) { container.innerHTML = ''; if (feedbackEl) { feedbackEl.textContent = '输入关键词后,这里会显示匹配到的菜谱。'; } return; } try { const res = await fetch(`${API_BASE}/dishes/?search=${encodeURIComponent(search)}`); const results = await res.json(); if (results.length === 0) { if (feedbackEl) { feedbackEl.textContent = `没有找到和“${search.trim()}”相关的菜谱。`; } container.innerHTML = `
没找到相关菜品
换个菜名、食材或者口味词再试试。
`; return; } if (feedbackEl) { feedbackEl.textContent = `找到 ${results.length} 道和“${search.trim()}”相关的菜谱。`; } container.innerHTML = results.map(dish => { const inOrder = isDishInOrder(dish.id); const cookedCount = dishCompletedCounts[dish.id] || 0; return `
${dish.name} ${dish.category_name || '未分类'}
${dish.name}
${cookedCount > 0 ? `做过 ${cookedCount} 次` : '还没在聚会中做过'}
${inOrder ? '已加入当前聚会单' : '点开后可加入聚会单'}
`; }).join(''); container.querySelectorAll('.dish-card').forEach(card => { card.addEventListener('click', () => { document.getElementById('search-panel').classList.remove('active'); showDishDetail(card.dataset.id); }); }); } catch (e) { console.error('搜索失败', e); if (feedbackEl) { feedbackEl.textContent = '搜索出了点问题,请稍后再试。'; } } } function getInProgressOrder() { return orders.find(o => o.status === 'in_progress'); } function isDishInOrder(dishId) { const inProgressOrder = getInProgressOrder(); if (!inProgressOrder || !inProgressOrder.dishes) return false; return inProgressOrder.dishes.some(d => d.id === dishId || d === dishId); } async function loadOrders() { let url = `${API_BASE}/orders/`; if (currentOrderFilter) url += `?status=${currentOrderFilter}`; try { const res = await fetch(url); orders = await res.json(); // Calculate how many times each dish appears in completed orders dishCompletedCounts = {}; orders.filter(o => o.status === 'completed').forEach(order => { (order.dishes || []).forEach(dishId => { dishCompletedCounts[dishId] = (dishCompletedCounts[dishId] || 0) + 1; }); }); renderOrders(); renderDishes(); } catch (e) { console.error('加载聚会失败', e); } } function renderOrders() { const container = document.getElementById('order-list'); if (orders.length === 0) { container.innerHTML = `
还没有聚会单
点右下角新建一张备菜清单,把要做的菜先安排起来。
`; return; } container.innerHTML = orders.map(order => { const dishCount = order.dishes_detail ? order.dishes_detail.length : 0; const participantsCount = order.participants ? order.participants.length : 0; const summaryText = [ order.party_date ? new Date(order.party_date).toLocaleDateString() : '未安排时间', `${dishCount} 道菜`, participantsCount > 0 ? `${participantsCount} 位参与人` : '待补充参与人' ].join(' · '); return `
${order.name}
${summaryText}
${order.status === 'in_progress' ? '备菜中' : '已完成'}
${(order.dishes_detail || []).map(d => { const isCold = d.category_name && d.category_name.includes('凉菜'); return `${d.name}`; }).join('') || '还没选菜'}
`; }).join(''); container.querySelectorAll('.order-card').forEach(card => { card.addEventListener('click', () => showOrderDetail(card.dataset.id)); }); } function filterOrders(status) { currentOrderFilter = status; document.querySelectorAll('.filter-pill').forEach(p => p.classList.remove('active')); document.querySelector(`.filter-pill[data-status="${status}"]`).classList.add('active'); loadOrders(); } function showDishDetail(id) { const dish = dishes.find(d => d.id == id); if (!dish) return; document.getElementById('detail-cover').src = dish.cover_image || ''; document.getElementById('detail-name').textContent = dish.name; document.getElementById('detail-category').textContent = dish.category_name || '未分类'; const detailSummary = document.getElementById('detail-summary-text'); if (detailSummary) { const cookedCount = dishCompletedCounts[dish.id] || 0; if (isDishInOrder(dish.id)) { detailSummary.textContent = `这道菜已经在当前聚会单里了,材料和做法都可以现在确认。`; } else if (cookedCount > 0) { detailSummary.textContent = `这道菜已经做过 ${cookedCount} 次,适合直接拿来安排这次聚会。`; } else { detailSummary.textContent = '翻翻这道菜需要什么材料,再决定要不要加入聚会单。'; } } const ingList = document.getElementById('detail-ingredients-list'); if (dish.ingredients && dish.ingredients.length > 0) { document.getElementById('detail-ingredients').style.display = 'block'; ingList.innerHTML = dish.ingredients.map(ing => `
${ing.name} ${ing.amount}
`).join(''); } else { document.getElementById('detail-ingredients').style.display = 'none'; } document.getElementById('detail-description').innerHTML = marked.parse(dish.description || '暂无做法'); document.getElementById('btn-delete-dish').onclick = () => deleteDish(dish.id); document.getElementById('btn-edit-dish').onclick = () => { bootstrap.Modal.getInstance(document.getElementById('dish-detail-modal')).hide(); setTimeout(() => showDishForm(dish), 300); }; // Check if dish is in any in-progress gathering const inProgressOrder = getInProgressOrder(); const isInOrder = inProgressOrder ? isDishInOrder(dish.id) : false; const addBtn = document.getElementById('btn-add-to-order'); if (isInOrder) { addBtn.textContent = '移出聚会'; addBtn.className = 'btn btn-danger flex-fill rounded-pill py-3'; addBtn.onclick = async () => { const inProgressOrders = orders.filter(o => o.status === 'in_progress'); if (inProgressOrders.length > 0) { const order = inProgressOrders[0]; const existingDishes = order.dishes || []; const newDishIds = existingDishes.filter(d => d !== dish.id && d !== dish.id.toString()); await fetch(`${API_BASE}/orders/${order.id}/`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: order.name, dish_ids: newDishIds }) }); showAppToast(`已从「${order.name}」移出`); loadOrders(); bootstrap.Modal.getInstance(document.getElementById('dish-detail-modal')).hide(); } }; } else { addBtn.textContent = '添加到聚会'; addBtn.className = 'btn btn-success flex-fill rounded-pill py-3'; addBtn.onclick = async () => { bootstrap.Modal.getInstance(document.getElementById('dish-detail-modal')).hide(); const inProgressOrders = orders.filter(o => o.status === 'in_progress'); if (inProgressOrders.length > 0) { const order = inProgressOrders[0]; const existingDishes = order.dishes || []; const newDishIds = [...new Set([...existingDishes, dish.id])]; await fetch(`${API_BASE}/orders/${order.id}/`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: order.name, dish_ids: newDishIds }) }); showAppToast(`已添加到「${order.name}」`); loadOrders(); } else { setTimeout(() => showOrderForm(null, dish), 300); } }; } new bootstrap.Modal(document.getElementById('dish-detail-modal')).show(); } function showDishForm(dish = null) { document.getElementById('dish-form').reset(); document.getElementById('dish-form-title').textContent = dish ? '编辑菜品' : '添加菜品'; document.getElementById('dish-id').value = dish ? dish.id : ''; document.getElementById('dish-image-url').value = dish && dish.cover_image ? dish.cover_image : ''; const preview = document.getElementById('image-preview'); if (dish && dish.cover_image) { preview.style.display = 'block'; preview.querySelector('img').src = dish.cover_image; } else { preview.style.display = 'none'; } const catSelect = document.getElementById('dish-category'); catSelect.innerHTML = categories.map(c => ``).join(''); if (dish) { document.getElementById('dish-name').value = dish.name; document.getElementById('dish-category').value = dish.category || ''; renderIngredients(dish.ingredients || []); renderSteps(dish.description || ''); } else { document.getElementById('ingredients-list').innerHTML = ''; renderSteps(''); } // Setup add step button document.getElementById('btn-add-step').onclick = addStep; new bootstrap.Modal(document.getElementById('dish-form-modal')).show(); } function renderSteps(description) { const container = document.getElementById('step-list'); let steps = []; // Parse existing description into steps if (description) { // Try to parse markdown into steps const lines = description.split('\n'); let currentStep = { text: '', images: [] }; let stepImages = []; lines.forEach(line => { if (line.match(/^```|!\[.*\]\(http/)) { // Image line const match = line.match(/!\[([^\]]*)\]\(([^)]+)\)/); if (match) { stepImages.push({ url: match[2], alt: match[1] }); } } else if (line.match(/^\d+[\.\)]\s/)) { // New step starts if (currentStep.text || stepImages.length > 0) { currentStep.images = [...stepImages]; steps.push(currentStep); } currentStep = { text: line.replace(/^\d+[\.\)]\s*/, ''), images: [] }; stepImages = []; } else if (line.trim()) { currentStep.text += (currentStep.text ? '\n' : '') + line; } }); // Add last step if (currentStep.text || stepImages.length > 0) { currentStep.images = [...stepImages]; steps.push(currentStep); } } // If no steps parsed, create empty step if (steps.length === 0) { steps = [{ text: '', images: [] }]; } container.innerHTML = steps.map((step, index) => `
${index + 1}
${steps.length > 1 ? ` ` : ''}
支持 JPG、PNG
${(step.images || []).map(img => `
${img.alt || ''}
`).join('')}
预览
${formatStepPreview(step.text, step.images || [])}
`).join(''); } function addStep() { const container = document.getElementById('step-list'); const stepCount = container.querySelectorAll('.step-item').length; const newStep = document.createElement('div'); newStep.className = 'step-item'; newStep.dataset.index = stepCount; newStep.innerHTML = `
${stepCount + 1}
支持 JPG、PNG
`; container.appendChild(newStep); renumberSteps(); } function deleteStep(btn) { const stepItem = btn.closest('.step-item'); const stepList = document.getElementById('step-list'); if (stepList.querySelectorAll('.step-item').length > 1) { stepItem.remove(); renumberSteps(); } } function renumberSteps() { const steps = document.querySelectorAll('.step-item'); steps.forEach((step, index) => { step.dataset.index = index; step.querySelector('.step-number').textContent = index + 1; const input = step.querySelector('.step-input'); input.placeholder = `输入步骤 ${index + 1} 的内容...`; }); } function updateStepPreview(textarea) { const stepItem = textarea.closest('.step-item'); const preview = stepItem.querySelector('.step-preview'); const previewContent = stepItem.querySelector('.step-preview-content'); const images = []; // Get existing images from preview stepItem.querySelectorAll('.step-image-preview img').forEach(img => { images.push({ url: img.src, alt: '' }); }); if (textarea.value.trim()) { preview.style.display = 'block'; previewContent.innerHTML = formatStepPreview(textarea.value, images); } else { preview.style.display = 'none'; } } function formatStepPreview(text, images) { let html = text.replace(/\n/g, '
'); // Add images with spacing before and after images.forEach(img => { html += `

${img.alt}
`; }); return html; } async function uploadStepImage(input) { const file = input.files[0]; if (!file) return; const formData = new FormData(); formData.append('image', file); try { const res = await fetch('/api/upload/', { method: 'POST', body: formData }); const data = await res.json(); if (data.url) { const stepItem = input.closest('.step-item'); const imagePreview = stepItem.querySelector('.step-image-preview'); const newImage = document.createElement('div'); newImage.style.position = 'relative'; newImage.innerHTML = ` 步骤图片 `; imagePreview.appendChild(newImage); // Update preview const textarea = stepItem.querySelector('.step-input'); updateStepPreview(textarea); } } catch (e) { console.error('上传图片失败', e); showAppToast('上传图片失败,请换张图片再试试。', 'error'); } } function removeStepImage(btn, url) { const stepItem = btn.closest('.step-item'); btn.parentElement.remove(); // Update preview const textarea = stepItem.querySelector('.step-input'); updateStepPreview(textarea); } function generateDescriptionFromSteps() { const steps = document.querySelectorAll('.step-item'); let description = ''; steps.forEach((step, index) => { const textarea = step.querySelector('.step-input'); let stepText = textarea.value.trim(); // Get images const images = []; step.querySelectorAll('.step-image-preview img').forEach(img => { images.push(img.src); }); if (stepText || images.length > 0) { // Add step number and text description += `${index + 1}. ${stepText}\n`; // Add images with blank lines before and after images.forEach(url => { description += `\n![步骤图](${url})\n`; }); description += '\n'; } }); return description.trim(); } function renderIngredients(ingredients) { const container = document.getElementById('ingredients-list'); container.innerHTML = ingredients.map((ing, i) => `
`).join(''); } function addIngredientRow() { const container = document.getElementById('ingredients-list'); const div = document.createElement('div'); div.className = 'ingredient-item'; div.innerHTML = ` `; container.appendChild(div); } async function handleImageUpload(e) { const file = e.target.files[0]; if (!file) return; const formData = new FormData(); formData.append('image', file); try { const res = await fetch('/api/upload/', { method: 'POST', body: formData }); const data = await res.json(); if (data.url) { document.getElementById('dish-image-url').value = data.url; const preview = document.getElementById('image-preview'); preview.style.display = 'block'; preview.querySelector('img').src = data.url; } } catch (e) { console.error('上传图片失败', e); showAppToast('上传图片失败,请换张图片再试试。', 'error'); } } async function saveDish() { const id = document.getElementById('dish-id').value; const name = document.getElementById('dish-name').value; const category = document.getElementById('dish-category').value; // Generate description from steps const description = generateDescriptionFromSteps(); const cover_image = document.getElementById('dish-image-url').value; if (!name) { showAppToast('先写下这道菜的名字,再保存到菜谱本里。', 'error'); return; } const ingredients = []; document.querySelectorAll('#ingredients-list .ingredient-item').forEach(item => { const inputs = item.querySelectorAll('input'); if (inputs[0].value) { ingredients.push({ name: inputs[0].value, amount: inputs[1].value }); } }); const data = { name, description, ingredients }; if (cover_image && cover_image.trim()) data.cover_image = cover_image.trim(); if (category) data.category_id = parseInt(category); try { const method = id ? 'PUT' : 'POST'; const url = id ? `${API_BASE}/dishes/${id}/` : `${API_BASE}/dishes/`; const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (res.ok) { bootstrap.Modal.getInstance(document.getElementById('dish-form-modal')).hide(); showAppToast(id ? '菜谱已经更新好了。' : '新菜谱已经收进菜谱本了。'); loadDishes(); } else { const err = await res.json(); showAppToast(getErrorMessage(err, '保存失败,请稍后再试。'), 'error'); } } catch (e) { console.error('保存失败', e); showAppToast('保存失败,请稍后再试。', 'error'); } } async function deleteDish(id) { const shouldDelete = await confirmAction('删掉后这道菜会从菜谱本里移除。', '确定要删除这道菜吗?'); if (!shouldDelete) return; try { await fetch(`${API_BASE}/dishes/${id}/`, { method: 'DELETE' }); bootstrap.Modal.getInstance(document.getElementById('dish-detail-modal')).hide(); showAppToast('这道菜已经从菜谱本移走了。'); loadDishes(); } catch (e) { showAppToast('删除失败,请稍后再试。', 'error'); } } async function parseUrl() { const url = document.getElementById('parse-url').value; if (!url) return; try { const res = await fetch(`${API_BASE}/parse-url/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) }); const data = await res.json(); if (data.error) { showAppToast(getErrorMessage(data.error, '解析失败,请换个链接再试试。'), 'error'); return; } window.parseData = data; document.getElementById('parse-name').textContent = `菜品: ${data.name || '未识别'}`; document.getElementById('parse-ingredients-count').textContent = `材料: ${data.ingredients ? data.ingredients.length : 0}项`; document.getElementById('parse-result').style.display = 'block'; showAppToast('链接里的菜谱内容已经整理好了。'); } catch (e) { showAppToast('解析失败,请换个链接再试试。', 'error'); } } function applyParse() { const data = window.parseData; if (!data) return; document.getElementById('dish-name').value = data.name || ''; document.getElementById('dish-image-url').value = data.cover_image || ''; if (data.cover_image) { const preview = document.getElementById('image-preview'); preview.style.display = 'block'; preview.querySelector('img').src = data.cover_image; } renderIngredients(data.ingredients || []); renderSteps(data.description || ''); // Setup add step button document.getElementById('btn-add-step').onclick = addStep; bootstrap.Modal.getInstance(document.getElementById('parse-modal')).hide(); document.getElementById('dish-form-title').textContent = '添加菜品'; document.getElementById('dish-id').value = ''; new bootstrap.Modal(document.getElementById('dish-form-modal')).show(); } function showOrderForm(order = null, preselectedDish = null) { document.getElementById('order-form-title').textContent = order ? '编辑聚会' : '新建聚会'; document.getElementById('order-id').value = order ? order.id : ''; document.getElementById('order-name').value = order ? order.name : ''; // Set party date (default to today if new order) const partyDateInput = document.getElementById('order-party-date'); if (order && order.party_date) { // Convert ISO string to datetime-local format using local time const date = new Date(order.party_date); const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000); partyDateInput.value = localDate.toISOString().slice(0, 16); } else if (!order) { // Default to today const now = new Date(); now.setMinutes(now.getMinutes() - now.getTimezoneOffset()); partyDateInput.value = now.toISOString().slice(0, 16); } else { partyDateInput.value = ''; } // Set participants const participantsTags = document.getElementById('order-participants-tags'); participantsTags.innerHTML = ''; window.orderParticipants = order && order.participants ? [...order.participants] : []; renderParticipantTags(); // Setup participant input setupParticipantInput(); const container = document.getElementById('order-dish-select'); container.innerHTML = dishes.map(dish => ` `).join(''); new bootstrap.Modal(document.getElementById('order-form-modal')).show(); } function setupParticipantInput() { const input = document.querySelector('.participant-input'); if (!input) return; input.addEventListener('keypress', function(e) { if (e.key === 'Enter' && this.value.trim()) { e.preventDefault(); const name = this.value.trim(); if (name && !window.orderParticipants.includes(name)) { window.orderParticipants.push(name); renderParticipantTags(); } this.value = ''; } }); } function renderParticipantTags() { const container = document.getElementById('order-participants-tags'); container.innerHTML = window.orderParticipants.map(name => ` ${name} `).join(''); } function removeParticipant(name) { window.orderParticipants = window.orderParticipants.filter(p => p !== name); renderParticipantTags(); } async function saveOrder() { const id = document.getElementById('order-id').value; const name = document.getElementById('order-name').value; if (!name) { showAppToast('先给这次聚会起个名字吧。', 'error'); return; } const partyDate = document.getElementById('order-party-date').value; const participants = window.orderParticipants || []; const dishIds = []; document.querySelectorAll('#order-dish-select input:checked').forEach(cb => { dishIds.push(parseInt(cb.value)); }); const data = { name, dish_ids: dishIds, party_date: partyDate || null, participants: participants }; try { const method = id ? 'PUT' : 'POST'; const url = id ? `${API_BASE}/orders/${id}/` : `${API_BASE}/orders/`; const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (res.ok) { bootstrap.Modal.getInstance(document.getElementById('order-form-modal')).hide(); showAppToast(id ? '聚会单已经更新好了。' : '新的备菜清单已经建好了。'); loadOrders(); } else { const err = await res.json().catch(() => null); showAppToast(getErrorMessage(err, '保存失败,请稍后再试。'), 'error'); } } catch (e) { showAppToast('保存失败,请稍后再试。', 'error'); } } function formatDateTime(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return `${year}-${month}-${day} ${hours}:${minutes}`; } async function showOrderDetail(id) { const order = orders.find(o => o.id == id); if (!order) return; document.getElementById('order-detail-modal').dataset.orderId = id; document.getElementById('order-detail-title').textContent = order.name; const statusEl = document.getElementById('order-detail-status'); statusEl.textContent = order.status === 'in_progress' ? '进行中' : '已完成'; statusEl.className = `order-detail-status ${order.status === 'in_progress' ? 'status-progress' : 'status-done'}`; // 显示聚会时间 const dateEl = document.getElementById('order-detail-date'); if (order.party_date) { const date = new Date(order.party_date); dateEl.textContent = formatDateTime(date); dateEl.style.display = 'inline'; } else { dateEl.style.display = 'none'; } const detailSummaryEl = document.getElementById('order-detail-summary'); const dishCountLabelEl = document.getElementById('order-dish-count-label'); const detailDishNoteEl = document.getElementById('order-detail-dish-note'); const dishCount = order.dishes_detail ? order.dishes_detail.length : 0; const participantCount = order.participants ? order.participants.length : 0; if (detailSummaryEl) { detailSummaryEl.textContent = order.status === 'in_progress' ? `这张备菜清单里有 ${dishCount} 道菜${participantCount > 0 ? `,共 ${participantCount} 位参与人` : ''}。` : `这次聚会已经完成,留下了 ${dishCount} 道上桌菜品的记录。`; } if (dishCountLabelEl) { dishCountLabelEl.textContent = dishCount > 0 ? `已经选了 ${dishCount} 道菜,随时可以继续调整。` : '先从下面勾选想做的菜。'; } if (detailDishNoteEl) { detailDishNoteEl.textContent = dishCount > 0 ? `共 ${dishCount} 道菜${order.status === 'in_progress' ? ',还可以继续删减或补充。' : ',都已经留在这次记录里。'}` : '还没选菜,先从菜谱页挑几道拿手菜。'; } // Render dishes with delete button (only for in_progress orders) const dishesContainer = document.getElementById('order-detail-dishes'); const renderDishMeta = dish => { const parts = []; if (dish.category_name) { parts.push(`${dish.category_name}`); } parts.push(`${order.status === 'in_progress' ? '备菜中,可随时调整' : '已完成记录'}`); return parts.join(''); }; if (order.status === 'in_progress') { dishesContainer.innerHTML = (order.dishes_detail || []).map(d => `
${d.name}
${d.name}
${renderDishMeta(d)}
`).join(''); } else { dishesContainer.innerHTML = (order.dishes_detail || []).map(d => `
${d.name}
${d.name}
${renderDishMeta(d)}
`).join(''); } const ingContainer = document.getElementById('order-detail-ingredients'); if (order.ingredients_summary && order.ingredients_summary.length > 0) { ingContainer.innerHTML = order.ingredients_summary.map(ing => `
${ing.name} ${ing.amount}
`).join(''); } else { ingContainer.innerHTML = '

暂时还没有汇总出食材,等选好菜后这里会自动整理。

'; } // Render participants with edit capability window.detailOrderParticipants = order && order.participants ? [...order.participants] : []; renderDetailParticipantTags(); setupDetailParticipantInput(); // Update summary to reflect changes updateDetailSummary(); document.getElementById('btn-complete-order').style.display = order.status === 'in_progress' ? 'block' : 'none'; // Hide ingredients section for completed orders const ingredientsSection = document.getElementById('order-ingredients-section'); ingredientsSection.style.display = order.status === 'completed' ? 'none' : 'block'; // Render party images renderOrderImages(order); // Add share button handler document.getElementById('btn-share-order').onclick = () => shareOrderAsImage(order); new bootstrap.Modal(document.getElementById('order-detail-modal')).show(); } function renderDetailParticipantTags() { const container = document.getElementById('detail-participants-tags'); if (!container) return; container.innerHTML = window.detailOrderParticipants.map(name => ` ${name} `).join(''); } function setupDetailParticipantInput() { const input = document.getElementById('detail-participant-input'); if (!input) return; input.onkeypress = function(e) { if (e.key === 'Enter' && this.value.trim()) { e.preventDefault(); const name = this.value.trim(); if (name && !window.detailOrderParticipants.includes(name)) { window.detailOrderParticipants.push(name); renderDetailParticipantTags(); saveDetailParticipants(); } this.value = ''; } }; } function removeDetailParticipant(name) { window.detailOrderParticipants = window.detailOrderParticipants.filter(p => p !== name); renderDetailParticipantTags(); saveDetailParticipants(); } async function saveDetailParticipants() { const modal = document.getElementById('order-detail-modal'); const orderId = modal.dataset.orderId; if (!orderId) return; try { const res = await fetch(`${API_BASE}/orders/${orderId}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ participants: window.detailOrderParticipants }) }); if (res.ok) { const updatedOrder = await res.json(); // Update local orders cache const idx = orders.findIndex(o => o.id == orderId); if (idx !== -1) { orders[idx] = updatedOrder; } updateDetailSummary(); // Refresh ranking if exists if (window.participantRanking) { calculateParticipantRanking(); if (document.getElementById('participant-ranking-modal').classList.contains('show')) { showParticipantRanking(); } } } } catch (err) { console.error('Failed to save participants:', err); } } function updateDetailSummary() { const modal = document.getElementById('order-detail-modal'); const orderId = modal.dataset.orderId; const order = orders.find(o => o.id == orderId); if (!order) return; const dishCount = order.dishes_detail ? order.dishes_detail.length : 0; const participantCount = window.detailOrderParticipants ? window.detailOrderParticipants.length : 0; const summaryEl = document.getElementById('order-detail-summary'); if (summaryEl) { summaryEl.textContent = order.status === 'in_progress' ? `这张备菜清单里有 ${dishCount} 道菜${participantCount > 0 ? `,共 ${participantCount} 位参与人` : ''}。` : `这次聚会已经完成,留下了 ${dishCount} 道上桌菜品${participantCount > 0 ? `和 ${participantCount} 位参与人` : ''}的记录。`; } } // Render party images function renderOrderImages(order) { const container = document.getElementById('order-detail-images'); const images = order.images || []; if (images.length === 0) { container.innerHTML = '

还没有留下聚会照片,饭桌热闹时记得补一张。

'; } else { container.innerHTML = images.map((img, idx) => `
聚会照片
`).join(''); } } // Remove image from party async function removePartyImage(idx) { const orderId = document.getElementById('order-detail-modal').dataset.orderId; const order = orders.find(o => o.id == orderId); if (!order) return; const images = order.images || []; images.splice(idx, 1); await updateOrderImages(orderId, images); order.images = images; renderOrderImages(order); } // Update order images via API async function updateOrderImages(orderId, images) { try { const res = await fetch(`${API_BASE}/orders/${orderId}/`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ images: images }) }); if (!res.ok) throw new Error('更新失败'); showAppToast('聚会照片已经更新。'); } catch (e) { console.error('更新图片失败:', e); showAppToast('更新图片失败,请稍后再试。', 'error'); } } // Handle party image upload document.addEventListener('DOMContentLoaded', function() { document.getElementById('party-image-upload').addEventListener('change', async function(e) { const files = e.target.files; if (!files || files.length === 0) return; const orderId = document.getElementById('order-detail-modal').dataset.orderId; if (!orderId) return; const order = orders.find(o => o.id == orderId); if (!order) return; const currentImages = order.images || []; let uploadFailed = false; // Upload each image for (let file of files) { try { const formData = new FormData(); formData.append('image', file); const res = await fetch(`/api/upload/`, { method: 'POST', body: formData }); const data = await res.json(); if (data.url) { currentImages.push(data.url); } } catch (e) { console.error('上传图片失败:', e); uploadFailed = true; } } await updateOrderImages(orderId, currentImages); order.images = currentImages; renderOrderImages(order); if (uploadFailed) { showAppToast('有照片没上传成功,剩下的已经先收进聚会记录里。', 'error'); } // Reset input this.value = ''; }); }); async function shareOrderAsImage(order) { // Disable share button and show loading state const shareBtn = document.getElementById('btn-share-order'); const originalBtnContent = shareBtn.innerHTML; shareBtn.disabled = true; shareBtn.innerHTML = ' 生成中...'; try { // Create a temporary container for the share image const shareContainer = document.createElement('div'); shareContainer.style.cssText = 'position: fixed; left: -9999px; top: 0; width: 400px; background: linear-gradient(135deg, #fff7ed 0%, #fffaf0 100%); padding: 30px; font-family: "Noto Sans SC", sans-serif;'; // Build the share content const allDishes = order.dishes_detail || []; const groupedDishes = { '热菜': allDishes.filter(d => !(d.category_name || '').includes('凉菜') && !(d.category_name || '').includes('汤')), '凉菜': allDishes.filter(d => (d.category_name || '').includes('凉菜')), '汤': allDishes.filter(d => (d.category_name || '').includes('汤')) }; const dishSections = Object.entries(groupedDishes) .filter(([, dishes]) => dishes.length > 0) .map(([title, dishes]) => `

${title}

${dishes.map(d => `
${d.name}
`).join('')}
`).join(''); shareContainer.innerHTML = `

${order.name}

${new Date(order.created_at).toLocaleDateString()} · ${allDishes.length}道菜

${dishSections || '

暂无菜品

'}
`; document.body.appendChild(shareContainer); // Generate image const canvas = await html2canvas(shareContainer, { scale: 2, useCORS: true, backgroundColor: null }); // Remove temp container document.body.removeChild(shareContainer); // Convert to blob and share canvas.toBlob(async (blob) => { const file = new File([blob], '聚会菜单.png', { type: 'image/png' }); if (navigator.share && navigator.canShare({ files: [file] })) { try { await navigator.share({ files: [file], title: order.name, text: `${order.name} 的菜品清单` }); showAppToast('分享图已经准备好了。'); } catch (e) { // User cancelled or share failed if (e.name !== 'AbortError') { downloadImage(canvas); showAppToast('系统分享没打开,已经改为下载到本地。'); } } } else { // Fallback: download downloadImage(canvas); showAppToast('设备不支持直接分享,已经下载到本地。'); } // Restore button shareBtn.disabled = false; shareBtn.innerHTML = originalBtnContent; }, 'image/png'); } catch (e) { console.error('分享失败', e); // Restore button shareBtn.disabled = false; shareBtn.innerHTML = originalBtnContent; showAppToast('分享失败,请重试。', 'error'); } } function showParticipantRanking() { const ranking = window.participantRanking || []; const container = document.getElementById('participant-ranking-list'); if (ranking.length === 0) { container.innerHTML = '

暂无参与记录

'; } else { const maxCount = Math.max(...ranking.map(r => r.count)); container.innerHTML = ranking.map((item, index) => { const percentage = maxCount > 0 ? (item.count / maxCount) * 100 : 0; let rankClass = ''; let gradient = ''; if (index === 0) { rankClass = 'rank-gold'; gradient = 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #d97706 100%)'; } else if (index === 1) { rankClass = 'rank-silver'; gradient = 'linear-gradient(135deg, #94a3b8 0%, #64748b 100%)'; } else if (index === 2) { rankClass = 'rank-bronze'; gradient = 'linear-gradient(135deg, #d97706 0%, #b45309 100%)'; } else { gradient = 'linear-gradient(90deg, #6366f1 0%, #8b5cf6 100%)'; } return `
${index + 1} ${item.name} ${item.count}次
`; }).join(''); } new bootstrap.Modal(document.getElementById('participant-ranking-modal')).show(); } function downloadImage(canvas) { const link = document.createElement('a'); link.download = '聚会菜单.png'; link.href = canvas.toDataURL('image/png'); link.click(); } async function removeDishFromOrder(orderId, dishId) { const shouldRemove = await confirmAction('移走后,这道菜会从这张备菜清单里消失。', '确定要移除这道菜吗?'); if (!shouldRemove) return; try { const order = orders.find(o => o.id == orderId); const existingDishes = order.dishes || []; const newDishIds = existingDishes.filter(d => d !== dishId && d !== dishId.toString()); await fetch(`${API_BASE}/orders/${orderId}/`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: order.name, dish_ids: newDishIds }) }); showAppToast('这道菜已经从聚会单里移走了。'); await loadOrders(); showOrderDetail(orderId); } catch (e) { showAppToast('移除失败,请稍后再试。', 'error'); } } async function deleteOrder(id) { const shouldDelete = await confirmAction('删掉后,这张聚会单和里面的记录会一起移除。', '确定要删除这个聚会吗?'); if (!shouldDelete) return; try { await fetch(`${API_BASE}/orders/${id}/`, { method: 'DELETE' }); bootstrap.Modal.getInstance(document.getElementById('order-detail-modal')).hide(); showAppToast('这张聚会单已经从菜谱本里移走了。'); loadOrders(); } catch (e) { showAppToast('删除失败,请稍后再试。', 'error'); } } async function completeOrder(id) { const shouldComplete = await confirmAction('完成后会保留这次聚会记录,并归档到已完成里。', '确定要完成这个聚会吗?'); if (!shouldComplete) return; try { // Use the complete endpoint to preserve dishes await fetch(`${API_BASE}/orders/${id}/complete/`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); bootstrap.Modal.getInstance(document.getElementById('order-detail-modal')).hide(); showAppToast('这次聚会已经归档完成。'); loadOrders(); } catch (e) { showAppToast('操作失败,请稍后再试。', 'error'); } } async function exportData() { try { const dishesRes = await fetch(`${API_BASE}/dishes/`); const dishes = await dishesRes.json(); const ordersRes = await fetch(`${API_BASE}/orders/`); const orders = await ordersRes.json(); const categoriesRes = await fetch(`${API_BASE}/categories/`); const categories = await categoriesRes.json(); const data = { dishes, orders, categories }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `zhangmenu-backup-${new Date().toISOString().split('T')[0]}.json`; a.click(); showAppToast('备份文件已经导出到本地。'); } catch (e) { showAppToast('导出失败,请稍后再试。', 'error'); } } function debounce(fn, delay) { let timer; return function(...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; }