Initial commit - Zhangmenu Django project

A menu management application built with Django, featuring:
- Dish management with cover images
- Order system with party dates and participants
- REST API with Django REST Framework
- Bootstrap-styled frontend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Developer
2026-03-12 11:27:23 +08:00
co-authored by Claude Opus 4.6
commit 0a50c09dba
47 changed files with 10268 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
// API 调用封装
const API_BASE = '/api';
const API = {
// 菜品相关
async getDishes(params = {}) {
const query = new URLSearchParams(params).toString();
const res = await fetch(`${API_BASE}/dishes/${query ? '?' + query : ''}`);
return res.json();
},
async getDish(id) {
const res = await fetch(`${API_BASE}/dishes/${id}/`);
return res.json();
},
async createDish(data) {
const res = await fetch(`${API_BASE}/dishes/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return res.json();
},
async updateDish(id, data) {
const res = await fetch(`${API_BASE}/dishes/${id}/`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return res.json();
},
async deleteDish(id) {
await fetch(`${API_BASE}/dishes/${id}/`, { method: 'DELETE' });
},
// 分类相关
async getCategories() {
const res = await fetch(`${API_BASE}/categories/`);
return res.json();
},
// 订单相关
async getOrders(params = {}) {
const query = new URLSearchParams(params).toString();
const res = await fetch(`${API_BASE}/orders/${query ? '?' + query : ''}`);
return res.json();
},
async createOrder(data) {
const res = await fetch(`${API_BASE}/orders/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return res.json();
},
async completeOrder(id) {
const res = await fetch(`${API_BASE}/orders/${id}/complete/`, { method: 'POST' });
return res.json();
},
async deleteOrder(id) {
await fetch(`${API_BASE}/orders/${id}/`, { method: 'DELETE' });
},
// URL解析
async parseUrl(url) {
const res = await fetch(`${API_BASE}/parse-url/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
return res.json();
},
// 图片上传
async uploadImage(file) {
const formData = new FormData();
formData.append('image', file);
const res = await fetch('/upload-image/', {
method: 'POST',
body: formData
});
return res.json();
}
};
+131
View File
@@ -0,0 +1,131 @@
// 应用入口
const App = {
// 初始化应用
async init() {
console.log('应用初始化...');
// 初始化事件监听
this.initEventListeners();
// 加载初始数据
await this.loadInitialData();
// 显示默认页面
this.switchPage('menu');
console.log('应用初始化完成');
},
// 初始化事件监听
initEventListeners() {
// 底部导航点击
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => {
const page = item.dataset.page;
if (page) {
App.switchPage(page);
}
});
});
// FAB按钮 - 根据当前页面决定行为
const fab = document.getElementById('fab-add');
if (fab) {
fab.addEventListener('click', () => {
if (AppState.currentPage === 'menu') {
// 菜单页:创建新菜品
showDishModal();
} else if (AppState.currentPage === 'orders') {
// 订单页:创建新订单
showOrderModal();
}
});
}
// 搜索按钮
const searchBtn = document.getElementById('search-btn');
if (searchBtn) {
searchBtn.addEventListener('click', () => {
const keyword = document.getElementById('search-input').value;
AppState.setPage('search');
document.getElementById('search-panel').classList.add('active');
MenuPage.searchDishes(keyword);
});
}
// 搜索面板关闭
const searchBackBtn = document.getElementById('search-panel-back');
if (searchBackBtn) {
searchBackBtn.addEventListener('click', () => {
document.getElementById('search-panel').classList.remove('active');
App.switchPage(AppState.currentPage || 'menu');
});
}
},
// 加载初始数据
async loadInitialData() {
try {
// 并行加载所有数据
const [categoriesRes, dishesRes, ordersRes] = await Promise.all([
fetch(`${API_BASE}/categories/`),
fetch(`${API_BASE}/dishes/`),
fetch(`${API_BASE}/orders/`)
]);
const categories = await categoriesRes.json();
const dishes = await dishesRes.json();
const orders = await ordersRes.json();
AppState.setCategories(categories);
AppState.setDishes(dishes);
AppState.setOrders(orders);
} catch (e) {
console.error('加载初始数据失败:', e);
}
},
// 切换页面
switchPage(page) {
// 更新状态
AppState.setPage(page);
// 更新导航
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.toggle('active', item.dataset.page === page);
});
// 更新页面显示
document.querySelectorAll('.page').forEach(p => {
p.classList.remove('active');
});
const pageEl = document.getElementById(`${page}-page`);
if (pageEl) {
pageEl.classList.add('active');
}
// 根据页面执行对应初始化
if (page === 'menu') {
MenuPage.init();
} else if (page === 'orders') {
OrdersPage.init();
} else if (page === 'stats') {
StatsPage.init();
}
}
};
// DOM 加载完成后初始化
document.addEventListener('DOMContentLoaded', () => {
// 初始化全局变量(保持向后兼容)
window.API_BASE = '/api';
// 初始化应用
App.init();
});
// 导出
window.App = App;
+43
View File
@@ -0,0 +1,43 @@
// 菜品卡片组件
// 渲染菜品卡片
function renderDishCard(dish, isInOrder = false) {
const indicatorClass = isInOrder ? 'in-order' : 'not-in-order';
const indicatorIcon = isInOrder ? '✓' : '+';
return `
<div class="dish-card" data-id="${dish.id}">
<div class="img-wrapper">
<img src="${dish.cover_image || getDefaultImage()}" alt="${dish.name}">
${dish.category_name ? `<span class="category-tag">${dish.category_name}</span>` : ''}
<span class="order-indicator ${indicatorClass}">${indicatorIcon}</span>
</div>
<div class="card-content">
<div class="dish-name">${dish.name}</div>
<div class="dish-meta">${dish.view_count || 0} 次浏览</div>
</div>
</div>
`;
}
// 渲染菜品网格
function renderDishGrid(dishes, containerSelector, onCardClick) {
const container = document.querySelector(containerSelector);
if (!container) return;
container.innerHTML = dishes.map(dish => renderDishCard(dish)).join('');
container.querySelectorAll('.dish-card').forEach(card => {
card.addEventListener('click', () => {
if (onCardClick) {
onCardClick(card.dataset.id);
}
});
});
}
// 导出
window.DishCard = {
render: renderDishCard,
renderGrid: renderDishGrid
};
+82
View File
@@ -0,0 +1,82 @@
// 弹窗组件
const Modal = {
// 显示弹窗
show(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = 'block';
document.body.style.overflow = 'hidden';
}
},
// 隐藏弹窗
hide(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = 'none';
document.body.style.overflow = '';
}
},
// 切换弹窗
toggle(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
if (modal.style.display === 'none') {
this.show(modalId);
} else {
this.hide(modalId);
}
}
},
// 显示确认对话框
confirm(message) {
return confirm(message);
},
// 显示加载状态
showLoading(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
const footer = modal.querySelector('.modal-footer');
if (footer) {
footer.dataset.originalContent = footer.innerHTML;
footer.innerHTML = '<span class="loading-text">加载中...</span>';
}
}
},
// 隐藏加载状态
hideLoading(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
const footer = modal.querySelector('.modal-footer');
if (footer && footer.dataset.originalContent) {
footer.innerHTML = footer.dataset.originalContent;
}
}
}
};
// Bootstrap 模态框包装
const BootstrapModal = {
init(modalId) {
return new bootstrap.Modal(document.getElementById(modalId));
},
show(modalId) {
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById(modalId));
modal.show();
},
hide(modalId) {
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById(modalId));
modal.hide();
}
};
// 导出
window.Modal = Modal;
window.BootstrapModal = BootstrapModal;
+52
View File
@@ -0,0 +1,52 @@
// 订单卡片组件
// 渲染订单卡片
function renderOrderCard(order) {
const statusClass = order.status === 'in_progress' ? 'status-progress' : 'status-done';
const statusText = order.status === 'in_progress' ? '进行中' : '已完成';
// 显示聚会时间或创建时间
const displayDate = order.party_date
? new Date(order.party_date).toLocaleDateString()
: (order.created_at ? new Date(order.created_at).toLocaleDateString() : '');
const dishCount = order.dishes_detail ? order.dishes_detail.length : 0;
return `
<div class="order-card" data-id="${order.id}">
<div class="order-top">
<span class="order-name">${order.name}</span>
<span class="order-status ${statusClass}">${statusText}</span>
</div>
<div class="order-date">${displayDate} · ${dishCount} 道菜</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('')}
</div>
</div>
`;
}
// 渲染订单列表
function renderOrderList(orders, containerSelector, onCardClick) {
const container = document.querySelector(containerSelector);
if (!container) return;
container.innerHTML = orders.map(order => renderOrderCard(order)).join('');
container.querySelectorAll('.order-card').forEach(card => {
card.addEventListener('click', () => {
if (onCardClick) {
onCardClick(card.dataset.id);
}
});
});
}
// 导出
window.OrderCard = {
render: renderOrderCard,
renderList: renderOrderList
};
+98
View File
@@ -0,0 +1,98 @@
// 菜单页面模块
const MenuPage = {
// 初始化
init() {
this.loadCategories();
this.loadDishes();
},
// 加载分类
async loadCategories() {
try {
const res = await fetch(`${API_BASE}/categories/`);
const data = await res.json();
AppState.setCategories(data);
this.renderCategories();
} catch (e) {
console.error('加载分类失败:', e);
}
},
// 加载菜品
async loadDishes() {
try {
const params = {};
if (AppState.currentCategory) {
const category = AppState.categories.find(c => c.name === AppState.currentCategory);
if (category) params.category = category.id;
}
const res = await fetch(`${API_BASE}/dishes/${new URLSearchParams(params).toString() ? '?' + new URLSearchParams(params).toString() : ''}`);
const data = await res.json();
AppState.setDishes(data);
this.renderDishes();
} catch (e) {
console.error('加载菜品失败:', e);
}
},
// 渲染分类
renderCategories() {
const container = document.getElementById('category-filter');
if (!container) return;
const html = `
<span class="category-chip ${!AppState.currentCategory ? 'active' : ''}" data-category="">全部</span>
${AppState.categories.map(c => `
<span class="category-chip ${AppState.currentCategory === c.name ? 'active' : ''}" data-category="${c.name}">${c.name}</span>
`).join('')}
`;
container.innerHTML = html;
// 绑定点击事件
container.querySelectorAll('.category-chip').forEach(chip => {
chip.addEventListener('click', () => {
AppState.setCurrentCategory(chip.dataset.category);
this.renderCategories();
this.loadDishes();
});
});
},
// 渲染菜品
renderDishes() {
const container = document.getElementById('dish-grid');
if (!container) return;
const currentOrder = AppState.getCurrentOrder();
const dishes = AppState.getFilteredDishes();
container.innerHTML = dishes.map(dish => {
const isInOrder = currentOrder && currentOrder.dishes && currentOrder.dishes.includes(dish.id);
return DishCard.render(dish, isInOrder);
}).join('');
// 绑定点击事件
container.querySelectorAll('.dish-card').forEach(card => {
card.addEventListener('click', () => {
showDishDetail(card.dataset.id);
});
});
},
// 搜索菜品
async searchDishes(keyword) {
try {
const res = await fetch(`${API_BASE}/dishes/?search=${encodeURIComponent(keyword)}`);
const data = await res.json();
AppState.setDishes(data);
this.renderDishes();
} catch (e) {
console.error('搜索失败:', e);
}
}
};
// 导出
window.MenuPage = MenuPage;
+104
View File
@@ -0,0 +1,104 @@
// 订单页面模块
const OrdersPage = {
// 初始化
init() {
this.loadOrders();
},
// 加载订单
async loadOrders() {
try {
const res = await fetch(`${API_BASE}/orders/`);
const data = await res.json();
AppState.setOrders(data);
this.renderOrders();
} catch (e) {
console.error('加载订单失败:', e);
}
},
// 渲染订单
renderOrders() {
const container = document.getElementById('order-list');
if (!container) return;
const orders = AppState.getFilteredOrders();
if (orders.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-icon">📋</div>
<div class="empty-title">暂无聚会</div>
<div class="empty-desc">点击下方按钮创建第一个聚会吧</div>
</div>
`;
return;
}
container.innerHTML = orders.map(order => OrderCard.render(order)).join('');
// 绑定点击事件
container.querySelectorAll('.order-card').forEach(card => {
card.addEventListener('click', () => {
showOrderDetail(card.dataset.id);
});
});
},
// 筛选订单
filterOrders(status) {
AppState.setCurrentOrderFilter(status);
this.renderOrders();
},
// 创建订单
async createOrder(name, dishIds, partyDate) {
try {
const data = {
name,
dish_ids: dishIds,
party_date: partyDate
};
const res = await fetch(`${API_BASE}/orders/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const order = await res.json();
await this.loadOrders();
return order;
} catch (e) {
console.error('创建订单失败:', e);
throw e;
}
},
// 完成订单
async completeOrder(orderId) {
try {
const res = await fetch(`${API_BASE}/orders/${orderId}/complete/`, {
method: 'POST'
});
await res.json();
await this.loadOrders();
} catch (e) {
console.error('完成订单失败:', e);
throw e;
}
},
// 删除订单
async deleteOrder(orderId) {
try {
await fetch(`${API_BASE}/orders/${orderId}/`, { method: 'DELETE' });
await this.loadOrders();
} catch (e) {
console.error('删除订单失败:', e);
throw e;
}
}
};
// 导出
window.OrdersPage = OrdersPage;
+130
View File
@@ -0,0 +1,130 @@
// 统计页面模块
const StatsPage = {
// 初始化
init() {
this.loadStats();
},
// 加载统计数据
async loadStats() {
try {
const [dishesRes, ordersRes] = await Promise.all([
fetch(`${API_BASE}/dishes/`),
fetch(`${API_BASE}/orders/`)
]);
const allDishes = await dishesRes.json();
const allOrders = await ordersRes.json();
// 计算菜品出现次数
const dishCompletedCounts = {};
allOrders
.filter(o => o.status === 'completed')
.forEach(order => {
(order.dishes_detail || []).forEach(dish => {
dishCompletedCounts[dish.id] = (dishCompletedCounts[dish.id] || 0) + 1;
});
});
AppState.setDishCompletedCounts(dishCompletedCounts);
// 统计数据
const totalDishes = allDishes.length;
const completedOrders = allOrders.filter(o => o.status === 'completed').length;
const inProgressOrders = allOrders.filter(o => o.status === 'in_progress').length;
// 统计参与人员
const participantCounts = {};
allOrders.forEach(order => {
(order.participants || []).forEach(p => {
participantCounts[p] = (participantCounts[p] || 0) + 1;
});
});
const uniqueParticipants = Object.keys(participantCounts).length;
// 渲染统计卡片
this.renderStatCards({ totalDishes, completedOrders, inProgressOrders, uniqueParticipants });
// 渲染菜品排行榜
this.renderDishRank(allDishes, dishCompletedCounts);
} catch (e) {
console.error('加载统计失败:', e);
}
},
// 渲染统计卡片
renderStatCards(stats) {
const container = document.getElementById('stats-cards');
if (!container) return;
container.innerHTML = `
<div class="stat-card">
<div class="stat-icon" style="background: linear-gradient(135deg, var(--primary-soft), #fef3c7); color: var(--primary);">
<i class="bi bi-book"></i>
</div>
<div class="stat-value">${stats.totalDishes}</div>
<div class="stat-label">菜品总数</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: var(--secondary-soft); color: var(--secondary);">
<i class="bi bi-check-circle"></i>
</div>
<div class="stat-value">${stats.completedOrders}</div>
<div class="stat-label">已完成聚会</div>
</div>
<div class="stat-card">
<div class="stat-icon" style="background: var(--accent-soft); color: #b45309;">
<i class="bi bi-clock"></i>
</div>
<div class="stat-value">${stats.inProgressOrders}</div>
<div class="stat-label">进行中</div>
</div>
`;
},
// 渲染菜品排行榜
renderDishRank(dishes, counts) {
const container = document.getElementById('dish-rank-list');
if (!container) return;
// 按出现次数排序
const dishesWithCounts = dishes.map(dish => ({
...dish,
orderCount: counts[dish.id] || 0
})).filter(d => d.orderCount > 0)
.sort((a, b) => b.orderCount - a.orderCount);
if (dishesWithCounts.length === 0) {
container.innerHTML = `
<div class="empty-rank">
<i class="bi bi-trophy"></i>
<p>暂无数据</p>
</div>
`;
return;
}
container.innerHTML = dishesWithCounts.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">
<span class="rank-number ${rankClass}">${index + 1}</span>
<img class="rank-img" src="${dish.cover_image || getDefaultImage()}" alt="${dish.name}">
<div class="rank-info">
<div class="rank-name">${dish.name}</div>
<div class="rank-count">出现 ${dish.orderCount} 次</div>
</div>
</div>
`;
}).join('');
}
};
// 导出
window.StatsPage = StatsPage;
+78
View File
@@ -0,0 +1,78 @@
// 状态管理
const state = {
currentPage: 'menu',
categories: [],
dishes: [],
orders: [],
currentCategory: '',
currentOrderFilter: '',
dishCompletedCounts: {},
// 设置状态
setPage(page) {
this.currentPage = page;
},
setCategories(categories) {
this.categories = categories;
},
setDishes(dishes) {
this.dishes = dishes;
},
setOrders(orders) {
this.orders = orders;
},
setCurrentCategory(category) {
this.currentCategory = category;
},
setCurrentOrderFilter(filter) {
this.currentOrderFilter = filter;
},
setDishCompletedCounts(counts) {
this.dishCompletedCounts = counts;
},
// 获取过滤后的数据
getFilteredDishes() {
let filtered = this.dishes;
if (this.currentCategory) {
filtered = filtered.filter(d => d.category_name === this.currentCategory);
}
return filtered;
},
getFilteredOrders() {
let filtered = this.orders;
if (this.currentOrderFilter) {
filtered = filtered.filter(o => o.status === this.currentOrderFilter);
}
return filtered;
},
// 获取菜品在当前订单中的状态
isDishInCurrentOrder(dishId) {
const currentOrder = this.orders.find(o => o.status === 'in_progress');
if (!currentOrder) return false;
return currentOrder.dishes && currentOrder.dishes.includes(dishId);
},
// 获取当前进行中的订单
getCurrentOrder() {
return this.orders.find(o => o.status === 'in_progress');
},
// 重置状态
reset() {
this.currentPage = 'menu';
this.currentCategory = '';
this.currentOrderFilter = '';
}
};
// 导出供全局使用
window.AppState = state;
+36
View File
@@ -0,0 +1,36 @@
// 工具函数
// 格式化日期时间
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}`;
}
// 防抖函数
function debounce(fn, delay) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// 生成默认图片
function getDefaultImage() {
return 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2248%22 height=%2248%22><rect fill=%22%23fed7aa%22 width=%2248%22 height=%2248%22/><text x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.3em%22 fill=%22%23c2410c%22>🍳</text></svg>';
}
// 确认对话框
function confirmAction(message) {
return confirm(message);
}
// 显示提示
function showToast(message, type = 'info') {
// 简单实现,实际可以使用更优雅的toast库
alert(message);
}