Files
zhangmenu/static/js/app.js
T
DeveloperandClaude Opus 4.6 94647e2b4b perf: 性能与资源优化 - 提取内联资源、数据库查询优化、限流与缓存增强
主要优化:
- templates/index.html: 220KB → 28KB,内联CSS/JS提取为静态文件
- menu/serializers.py: 修复 Order.ingredients_summary N+1 查询问题
- menu/services/dish_service.py: 移除多余查询,添加 only() 限制字段
- menu/views.py: 上传接口添加每分钟20次限流
- static/js/api.js: 统一API错误处理
- zhangmenu/settings.py: 添加Whitenoise压缩、Redis缓存支持、环境变量配置
- static/css/app.css, static/js/app.js: 提取的内联资源文件
- 新增 migrations, requirements.txt, .env.example

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:18:59 +08:00

1747 lines
71 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.
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 = `
<div class="app-toast-icon">
<i class="bi ${type === 'error' ? 'bi-exclamation-circle' : 'bi-check2-circle'}"></i>
</div>
<div class="app-toast-copy"></div>
`;
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 = `
<div class="empty-rank">
<i class="bi bi-trophy"></i>
<div>还没有被加入聚会的菜品</div>
</div>
`;
} 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 `
<div class="rank-item">
<div class="rank-number ${rankClass}">${index + 1}</div>
<img class="rank-img" src="${dish.cover_image || 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2248%22 height=%2248%22 viewBox=%220 0 48 48%22><rect fill=%22%23fed7aa%22 width=%2248%22 height=%2248%22 rx=%2214%22/><text x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.32em%22 font-size=%2212%22 font-family=%22Arial,sans-serif%22 fill=%22%23c2410c%22>菜谱</text></svg>'}" alt="${dish.name}">
<div class="rank-info">
<div class="rank-name">${dish.name}</div>
<div class="rank-count">${dish.orderCount} 次</div>
</div>
</div>
`;
}).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 = `<div class="category-chip ${!currentCategory ? 'active' : ''}" data-id="">全部</div>`;
categories.forEach(cat => {
container.innerHTML += `<div class="category-chip ${currentCategory == cat.id ? 'active' : ''}" data-id="${cat.id}">${cat.name}</div>`;
});
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 = `<div class="category-chip ${!currentCategory ? 'active' : ''}" data-id="">全部</div>`;
categories.forEach(cat => {
filterContainer.innerHTML += `<div class="category-chip ${currentCategory == cat.id ? 'active' : ''}" data-id="${cat.id}">${cat.name}</div>`;
});
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 = `
<div class="empty-state" style="grid-column: 1 / -1;">
<div class="empty-icon"><i class="bi bi-cup-hot"></i></div>
<div class="empty-title">${activeCategory ? `还没有“${activeCategory.name}”菜谱` : '还没有菜品'}</div>
<div class="empty-desc">${activeCategory ? '换个分类看看,或者先把这类家常菜记下来。' : '先记下第一道拿手菜,菜谱本就能慢慢丰富起来。'}</div>
</div>`;
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 `
<article class="dish-card" data-id="${dish.id}">
<div class="img-wrapper">
<img src="${dish.cover_image || 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22100%22 height=%22100%22><rect fill=%22%23efe2cf%22 width=%22100%22 height=%22100%22/><text x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.3em%22 fill=%22%23a85a32%22>菜谱</text></svg>'}" alt="${dish.name}">
<span class="category-tag">${dish.category_name || '未分类'}</span>
<span class="order-indicator ${inOrder ? 'in-order' : 'not-in-order'}">
<i class="bi ${inOrder ? 'bi-check-lg' : 'bi-bookmark'}"></i>
</span>
</div>
<div class="card-content">
<div class="dish-name">${dish.name}</div>
<div class="dish-meta">${noteText}</div>
<div class="dish-card-note ${inOrder ? 'is-in-order' : ''}">${statusText}</div>
</div>
</article>`;
}).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 = `
<div class="empty-state" style="grid-column: 1 / -1;">
<div class="empty-icon"><i class="bi bi-search"></i></div>
<div class="empty-title">没找到相关菜品</div>
<div class="empty-desc">换个菜名、食材或者口味词再试试。</div>
</div>`;
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 `
<article class="dish-card" data-id="${dish.id}">
<div class="img-wrapper">
<img src="${dish.cover_image || 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22100%22 height=%22100%22><rect fill=%22%23efe2cf%22 width=%22100%22 height=%22100%22/><text x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.3em%22 fill=%22%23a85a32%22>菜谱</text></svg>'}" alt="${dish.name}">
<span class="category-tag">${dish.category_name || '未分类'}</span>
<span class="order-indicator ${inOrder ? 'in-order' : 'not-in-order'}">
<i class="bi ${inOrder ? 'bi-check-lg' : 'bi-bookmark'}"></i>
</span>
</div>
<div class="card-content">
<div class="dish-name">${dish.name}</div>
<div class="dish-meta">${cookedCount > 0 ? `做过 ${cookedCount} 次` : '还没在聚会中做过'}</div>
<div class="dish-card-note ${inOrder ? 'is-in-order' : ''}">${inOrder ? '已加入当前聚会单' : '点开后可加入聚会单'}</div>
</div>
</article>
`;
}).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 = `
<div class="empty-state">
<div class="empty-icon"><i class="bi bi-receipt"></i></div>
<div class="empty-title">还没有聚会单</div>
<div class="empty-desc">点右下角新建一张备菜清单,把要做的菜先安排起来。</div>
</div>`;
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 `
<article class="order-card" data-id="${order.id}">
<div class="order-top">
<div>
<span class="order-name">${order.name}</span>
<div class="order-date">${summaryText}</div>
</div>
<span class="order-status ${order.status === 'in_progress' ? 'status-progress' : 'status-done'}">${order.status === 'in_progress' ? '备菜中' : '已完成'}</span>
</div>
<div class="dish-list">
${(order.dishes_detail || []).map(d => {
const isCold = d.category_name && d.category_name.includes('凉菜');
return `<span class="dish-tag ${isCold ? 'dish-tag-cold' : ''}">${d.name}</span>`;
}).join('') || '<span class="dish-tag">还没选菜</span>'}
</div>
</article>
`;
}).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 => `
<div class="ingredient-row">
<span class="ingredient-name">${ing.name}</span>
<span class="ingredient-amount">${ing.amount}</span>
</div>
`).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 => `<option value="${c.id}">${c.name}</option>`).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) => `
<div class="step-item" data-index="${index}">
<div class="step-item-header">
<div class="step-number">${index + 1}</div>
${steps.length > 1 ? `
<button type="button" class="step-delete-btn" onclick="deleteStep(this)">
<i class="bi bi-x-lg"></i>
</button>
` : ''}
</div>
<textarea class="step-input" placeholder="输入步骤 ${index + 1} 的内容..." oninput="updateStepPreview(this)">${step.text || ''}</textarea>
<div class="step-image-upload">
<label class="step-image-btn">
<i class="bi bi-image"></i>
上传图片
<input type="file" accept="image/*" style="display: none;" onchange="uploadStepImage(this)">
</label>
<span style="font-size: 12px; color: var(--text-muted);">支持 JPG、PNG</span>
</div>
<div class="step-image-preview">
${(step.images || []).map(img => `
<div style="position: relative;">
<img src="${img.url}" alt="${img.alt || ''}">
<button type="button" onclick="removeStepImage(this, '${img.url}')" style="position: absolute; top: -8px; right: -8px; width: 20px; height: 20px; border-radius: 50%; background: #dc2626; color: white; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 12px;">×</button>
</div>
`).join('')}
</div>
<div class="step-preview" style="display: ${step.text ? 'block' : 'none'};">
<div class="step-preview-label">预览</div>
<div class="step-preview-content">${formatStepPreview(step.text, step.images || [])}</div>
</div>
</div>
`).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 = `
<div class="step-item-header">
<div class="step-number">${stepCount + 1}</div>
<button type="button" class="step-delete-btn" onclick="deleteStep(this)">
<i class="bi bi-x-lg"></i>
</button>
</div>
<textarea class="step-input" placeholder="输入步骤 ${stepCount + 1} 的内容..." oninput="updateStepPreview(this)"></textarea>
<div class="step-image-upload">
<label class="step-image-btn">
<i class="bi bi-image"></i>
上传图片
<input type="file" accept="image/*" style="display: none;" onchange="uploadStepImage(this)">
</label>
<span style="font-size: 12px; color: var(--text-muted);">支持 JPG、PNG</span>
</div>
<div class="step-image-preview"></div>
<div class="step-preview" style="display: none;">
<div class="step-preview-label">预览</div>
<div class="step-preview-content"></div>
</div>
`;
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, '<br>');
// Add images with spacing before and after
images.forEach(img => {
html += `<br><br><img src="${img.url}" alt="${img.alt}" style="max-width: 50%; border-radius: 12px; margin: 12px 0; display: block;"><br>`;
});
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 = `
<img src="${data.url}" alt="步骤图片">
<button type="button" onclick="removeStepImage(this, '${data.url}')" style="position: absolute; top: -8px; right: -8px; width: 20px; height: 20px; border-radius: 50%; background: #dc2626; color: white; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 12px;">×</button>
`;
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) => `
<div class="ingredient-item">
<input type="text" placeholder="材料名" value="${ing.name || ''}">
<input type="text" placeholder="用量" value="${ing.amount || ''}">
<button type="button" class="remove-btn" onclick="this.parentElement.remove()">
<i class="bi bi-x-lg"></i>
</button>
</div>
`).join('');
}
function addIngredientRow() {
const container = document.getElementById('ingredients-list');
const div = document.createElement('div');
div.className = 'ingredient-item';
div.innerHTML = `
<input type="text" placeholder="材料名">
<input type="text" placeholder="用量">
<button type="button" class="remove-btn" onclick="this.parentElement.remove()">
<i class="bi bi-x-lg"></i>
</button>
`;
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 => `
<label class="dish-checkbox">
<input type="checkbox" value="${dish.id}"
${order && order.dishes.some(d => d.id === dish.id) ? 'checked' : ''}
${preselectedDish && preselectedDish.id === dish.id ? 'checked' : ''}>
<span class="dish-label">${dish.name}</span>
</label>
`).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 => `
<span class="badge bg-primary d-flex align-items-center gap-1">
${name}
<button type="button" class="btn-close btn-close-white ms-1" style="font-size: 8px;" onclick="removeParticipant('${name}')"></button>
</span>
`).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(`<span class="dish-meta-chip">${dish.category_name}</span>`);
}
parts.push(`<span class="dish-meta-note">${order.status === 'in_progress' ? '备菜中,可随时调整' : '已完成记录'}</span>`);
return parts.join('');
};
if (order.status === 'in_progress') {
dishesContainer.innerHTML = (order.dishes_detail || []).map(d => `
<div class="dish-item-row">
<div class="dish-info">
<img class="dish-thumb" src="${d.cover_image || 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2248%22 height=%2248%22 viewBox=%220 0 48 48%22><rect fill=%22%23fed7aa%22 width=%2248%22 height=%2248%22 rx=%2214%22/><text x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.32em%22 font-size=%2212%22 font-family=%22Arial,sans-serif%22 fill=%22%23c2410c%22>菜谱</text></svg>'}" alt="${d.name}">
<div class="dish-copy">
<span class="dish-name">${d.name}</span>
<div class="dish-item-meta">${renderDishMeta(d)}</div>
</div>
</div>
<button class="remove-dish-btn" aria-label="从聚会中移除${d.name}" onclick="removeDishFromOrder(${order.id}, ${d.id})">
<i class="bi bi-x-lg"></i>
</button>
</div>
`).join('');
} else {
dishesContainer.innerHTML = (order.dishes_detail || []).map(d => `
<div class="dish-item-row">
<div class="dish-info">
<img class="dish-thumb" src="${d.cover_image || 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2248%22 height=%2248%22 viewBox=%220 0 48 48%22><rect fill=%22%23fed7aa%22 width=%2248%22 height=%2248%22 rx=%2214%22/><text x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.32em%22 font-size=%2212%22 font-family=%22Arial,sans-serif%22 fill=%22%23c2410c%22>菜谱</text></svg>'}" alt="${d.name}">
<div class="dish-copy">
<span class="dish-name">${d.name}</span>
<div class="dish-item-meta">${renderDishMeta(d)}</div>
</div>
</div>
</div>
`).join('');
}
const ingContainer = document.getElementById('order-detail-ingredients');
if (order.ingredients_summary && order.ingredients_summary.length > 0) {
ingContainer.innerHTML = order.ingredients_summary.map(ing => `
<div class="ingredient-row">
<span class="ingredient-name">${ing.name}</span>
<span class="ingredient-amount">${ing.amount}</span>
</div>
`).join('');
} else {
ingContainer.innerHTML = '<p class="detail-empty-note">暂时还没有汇总出食材,等选好菜后这里会自动整理。</p>';
}
// 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 => `
<span class="participant-tag">
${name}
<button type="button" class="participant-remove-btn" onclick="removeDetailParticipant('${name}')">×</button>
</span>
`).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 = '<p class="detail-empty-note">还没有留下聚会照片,饭桌热闹时记得补一张。</p>';
} else {
container.innerHTML = images.map((img, idx) => `
<div class="party-image-item">
<img src="${img}" alt="聚会照片">
<button class="remove-image-btn" onclick="removePartyImage(${idx})">×</button>
</div>
`).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 = '<span class="spinner-border spinner-border-sm me-1" role="status"></span> 生成中...';
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]) => `
<div style="margin-bottom: 20px;">
<h3 style="font-size: 16px; font-weight: 700; color: #1f2937; margin-bottom: 12px;">${title}</h3>
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px;">
${dishes.map(d => `
<div style="display: flex; align-items: center; min-width: 0; padding: 12px; background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.06);">
<img src="${d.cover_image || ''}" style="width: 44px; height: 44px; border-radius: 10px; object-fit: cover; margin-right: 10px; flex-shrink: 0;" onerror="this.style.display='none'">
<span style="font-size: 14px; font-weight: 600; color: #1f2937; line-height: 1.4; word-break: break-word;">${d.name}</span>
</div>
`).join('')}
</div>
</div>
`).join('');
shareContainer.innerHTML = `
<div style="text-align: center; margin-bottom: 24px;">
<h1 style="font-size: 24px; font-weight: 800; color: #c2410c; margin-bottom: 8px;">${order.name}</h1>
<p style="font-size: 14px; color: #9ca3af;">${new Date(order.created_at).toLocaleDateString()} · ${allDishes.length}道菜</p>
</div>
<div>
${dishSections || '<p style="font-size: 14px; color: #9ca3af; text-align: center; margin: 0;">暂无菜品</p>'}
</div>
`;
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 = '<p class="text-center text-muted py-4">暂无参与记录</p>';
} 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 `
<div class="ranking-item ${rankClass}">
<div class="ranking-header">
<span class="ranking-rank">${index + 1}</span>
<span class="ranking-name">${item.name}</span>
<span class="ranking-count">${item.count}次</span>
</div>
<div class="ranking-progress">
<div class="ranking-bar" style="width: ${percentage}%; background: ${gradient};"></div>
</div>
</div>
`;
}).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);
};
}