diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..07dcb43
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,17 @@
+# Django Secret Key (generate a new one for production!)
+DJANGO_SECRET_KEY=your-secret-key-here
+
+# Debug mode (set to false in production!)
+DJANGO_DEBUG=false
+
+# Allowed hosts (comma-separated)
+DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,your-domain.com
+
+# Database (PostgreSQL recommended for production)
+# DATABASE_URL=postgres://user:password@localhost:5432/zhangmenu
+
+# Redis cache (recommended for production)
+# REDIS_URL=redis://localhost:6379/1
+
+# CORS allowed origins (comma-separated)
+# CORS_ALLOWED_ORIGINS=http://localhost:8080,http://your-domain.com
diff --git a/.gitignore b/.gitignore
index 90f8790..c9adb5a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,11 @@
.claude/
.settings.local.json
+# Environment
+.env
+.env.local
+.env.production
+
# Python
__pycache__/
*.py[cod]
diff --git a/menu/admin.py b/menu/admin.py
index 8c38f3f..9a9a992 100644
--- a/menu/admin.py
+++ b/menu/admin.py
@@ -1,3 +1,26 @@
from django.contrib import admin
+from .models import Dish, DishCategory, Order
-# Register your models here.
+
+@admin.register(DishCategory)
+class DishCategoryAdmin(admin.ModelAdmin):
+ list_display = ['id', 'name', 'icon', 'order']
+ list_editable = ['name', 'icon', 'order']
+ search_fields = ['name']
+
+
+@admin.register(Dish)
+class DishAdmin(admin.ModelAdmin):
+ list_display = ['id', 'name', 'category', 'view_count', 'created_at']
+ list_filter = ['category', 'created_at']
+ search_fields = ['name', 'description']
+ raw_id_fields = ['category']
+
+
+@admin.register(Order)
+class OrderAdmin(admin.ModelAdmin):
+ list_display = ['id', 'name', 'status', 'party_date', 'created_at', 'completed_at']
+ list_filter = ['status', 'created_at']
+ search_fields = ['name', 'note']
+ readonly_fields = ['created_at', 'completed_at']
+ filter_horizontal = ['dishes']
diff --git a/menu/migrations/0007_add_indexes.py b/menu/migrations/0007_add_indexes.py
new file mode 100644
index 0000000..1413e63
--- /dev/null
+++ b/menu/migrations/0007_add_indexes.py
@@ -0,0 +1,39 @@
+# Generated by Django 5.2.11 on 2026-03-19 05:59
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('menu', '0006_order_images'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='order',
+ name='party_date',
+ field=models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='聚会时间'),
+ ),
+ migrations.AlterField(
+ model_name='order',
+ name='status',
+ field=models.CharField(choices=[('in_progress', '进行中'), ('completed', '已完成')], db_index=True, default='in_progress', max_length=20, verbose_name='状态'),
+ ),
+ migrations.AddIndex(
+ model_name='dish',
+ index=models.Index(fields=['name'], name='menu_dish_name_6ad09b_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='dish',
+ index=models.Index(fields=['-created_at'], name='menu_dish_created_8b2b23_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='order',
+ index=models.Index(fields=['status', '-created_at'], name='menu_order_status_7e2ff4_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='order',
+ index=models.Index(fields=['party_date'], name='menu_order_party_d_e06c2c_idx'),
+ ),
+ ]
diff --git a/menu/migrations/0008_remove_redundant_status_index.py b/menu/migrations/0008_remove_redundant_status_index.py
new file mode 100644
index 0000000..a7bd15b
--- /dev/null
+++ b/menu/migrations/0008_remove_redundant_status_index.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.11 on 2026-03-19 06:04
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('menu', '0007_add_indexes'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='order',
+ name='status',
+ field=models.CharField(choices=[('in_progress', '进行中'), ('completed', '已完成')], default='in_progress', max_length=20, verbose_name='状态'),
+ ),
+ ]
diff --git a/menu/models.py b/menu/models.py
index 2bb8c0c..1d796b8 100644
--- a/menu/models.py
+++ b/menu/models.py
@@ -19,7 +19,7 @@ class DishCategory(models.Model):
class Dish(models.Model):
"""菜品"""
name = models.CharField(max_length=100, verbose_name="菜品名称")
- category = models.ForeignKey(DishCategory, on_delete=models.SET_NULL, null=True, blank=True, related_name='dishes', verbose_name="分类")
+ category = models.ForeignKey(DishCategory, on_delete=models.SET_NULL, null=True, blank=True, related_name='dishes', verbose_name="分类", db_index=True)
cover_image = models.CharField(max_length=500, blank=True, null=True, verbose_name="封面图URL")
description = models.TextField(blank=True, verbose_name="做法描述(Markdown格式)")
ingredients = models.JSONField(default=list, verbose_name="材料清单")
@@ -31,6 +31,10 @@ class Dish(models.Model):
verbose_name = "菜品"
verbose_name_plural = "菜品"
ordering = ['-created_at']
+ indexes = [
+ models.Index(fields=['name']),
+ models.Index(fields=['-created_at']),
+ ]
def __str__(self):
return self.name
@@ -46,7 +50,7 @@ class Order(models.Model):
name = models.CharField(max_length=100, verbose_name="订单名称")
dishes = models.ManyToManyField(Dish, related_name='orders', verbose_name="菜品")
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='in_progress', verbose_name="状态")
- party_date = models.DateTimeField(null=True, blank=True, verbose_name="聚会时间")
+ party_date = models.DateTimeField(null=True, blank=True, verbose_name="聚会时间", db_index=True)
participants = models.JSONField(default=list, verbose_name="参与人员")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
completed_at = models.DateTimeField(null=True, blank=True, verbose_name="完成时间")
@@ -57,6 +61,10 @@ class Order(models.Model):
verbose_name = "订单"
verbose_name_plural = "订单"
ordering = ['-created_at']
+ indexes = [
+ models.Index(fields=['status', '-created_at']),
+ models.Index(fields=['party_date']),
+ ]
def __str__(self):
return self.name
diff --git a/menu/serializers.py b/menu/serializers.py
index c437b3c..e402f50 100644
--- a/menu/serializers.py
+++ b/menu/serializers.py
@@ -46,10 +46,20 @@ class OrderSerializer(serializers.ModelSerializer):
}
def get_ingredients_summary(self, obj):
- return obj.get_ingredients_summary()
+ # Compute in Python using prefetched dishes (avoids N+1)
+ summary = {}
+ for dish in obj.dishes.all():
+ for ing in dish.ingredients:
+ name = ing.get('name', '').strip()
+ amount = ing.get('amount', '').strip()
+ if name:
+ if name in summary:
+ if amount:
+ summary[name] += f" + {amount}"
+ else:
+ summary[name] = amount
+ return [{'name': k, 'amount': v} for k, v in summary.items()]
- def validate_dishes(self, value):
- return value
def create(self, validated_data):
dish_ids = validated_data.pop('dish_ids', [])
diff --git a/menu/services/dish_service.py b/menu/services/dish_service.py
index 052b392..18045dd 100644
--- a/menu/services/dish_service.py
+++ b/menu/services/dish_service.py
@@ -8,7 +8,10 @@ class DishService:
@staticmethod
def get_queryset(category_id=None, search=None):
"""获取菜品查询集"""
- queryset = Dish.objects.select_related('category')
+ queryset = Dish.objects.select_related('category').only(
+ 'id', 'name', 'category', 'category__name', 'cover_image',
+ 'description', 'ingredients', 'view_count', 'created_at', 'updated_at'
+ )
if category_id:
queryset = queryset.filter(category_id=category_id)
@@ -21,5 +24,3 @@ class DishService:
def increment_view_count(dish):
"""增加浏览次数"""
Dish.objects.filter(pk=dish.pk).update(view_count=F('view_count') + 1)
- dish.refresh_from_db(fields=['view_count'])
- return dish
diff --git a/menu/urls.py b/menu/urls.py
index 43dd745..5c15b43 100644
--- a/menu/urls.py
+++ b/menu/urls.py
@@ -3,9 +3,9 @@ from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
-router.register(r'categories', views.DishCategoryViewSet)
-router.register(r'dishes', views.DishViewSet)
-router.register(r'orders', views.OrderViewSet)
+router.register(r'categories', views.DishCategoryViewSet, basename='dishcategory')
+router.register(r'dishes', views.DishViewSet, basename='dish')
+router.register(r'orders', views.OrderViewSet, basename='order')
urlpatterns = [
path('', views.index, name='index'),
diff --git a/menu/views.py b/menu/views.py
index 4fa657d..73f1035 100644
--- a/menu/views.py
+++ b/menu/views.py
@@ -3,12 +3,14 @@ from urllib.parse import urljoin
import uuid
from django.conf import settings
+from django.contrib.auth.decorators import login_required
from django.core.files.storage import default_storage
from django.http import JsonResponse
from django.shortcuts import render
from django.views.decorators.csrf import csrf_exempt
from rest_framework import status, viewsets
from rest_framework.decorators import action
+from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -17,17 +19,28 @@ from menu.serializers import DishSerializer, DishCategorySerializer, OrderSerial
from menu.services import DishService, OrderService, ParserService
+@login_required
def index(request):
"""首页"""
return render(request, 'index.html')
@csrf_exempt
+@login_required
def upload_image(request):
"""上传图片"""
if request.method != 'POST':
return JsonResponse({'error': 'Method not allowed'}, status=405)
+ # Rate limit: 20 uploads per minute per user
+ from django.core.cache import cache
+ user_key = f'upload_throttle_{request.user.id}'
+ cache.add(user_key, 0, 60)
+ count = cache.get(user_key, 0)
+ if count >= 20:
+ return JsonResponse({'error': '上传太频繁,请稍后再试'}, status=429)
+ cache.set(user_key, count + 1, 60)
+
image = request.FILES.get('image')
if not image:
return JsonResponse({'error': 'No image provided'}, status=400)
@@ -55,12 +68,13 @@ class DishCategoryViewSet(viewsets.ModelViewSet):
"""分类视图"""
queryset = DishCategory.objects.all()
serializer_class = DishCategorySerializer
+ pagination_class = None
class DishViewSet(viewsets.ModelViewSet):
"""菜品视图"""
- queryset = Dish.objects.all()
serializer_class = DishSerializer
+ pagination_class = None
def get_queryset(self):
category_id = self.request.query_params.get('category')
@@ -76,8 +90,8 @@ class DishViewSet(viewsets.ModelViewSet):
class OrderViewSet(viewsets.ModelViewSet):
"""订单视图"""
- queryset = Order.objects.all()
serializer_class = OrderSerializer
+ pagination_class = None
def get_queryset(self):
status_filter = self.request.query_params.get('status')
@@ -108,6 +122,8 @@ class OrderViewSet(viewsets.ModelViewSet):
class ParseUrlView(APIView):
"""解析外部链接"""
+ throttle_scope = 'parse-url'
+ permission_classes = [AllowAny]
def post(self, request):
url = request.data.get('url', '')
@@ -115,6 +131,8 @@ class ParseUrlView(APIView):
result = ParserService.parse_url(url)
return Response(result)
except ValueError as e:
- return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)
- except Exception as e:
return Response({'error': f'解析失败: {str(e)}'}, status=status.HTTP_400_BAD_REQUEST)
+ except TypeError as e:
+ return Response({'error': f'解析失败: {str(e)}'}, status=status.HTTP_400_BAD_REQUEST)
+ except Exception as e:
+ return Response({'error': f'解析失败: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..ab1be62
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,11 @@
+# Core Django
+Django>=5.2,<6.0
+djangorestframework>=3.14,<4.0
+django-cors-headers>=4.0,<5.0
+
+# Production
+whitenoise>=6.0,<7.0
+dj-database-url>=2.0,<3.0 # Optional: for PostgreSQL support via DATABASE_URL
+
+# Development
+# sqlite3 (built-in)
diff --git a/static/css/app.css b/static/css/app.css
new file mode 100644
index 0000000..09d7164
--- /dev/null
+++ b/static/css/app.css
@@ -0,0 +1,4602 @@
+
+
+
+
+
+
+
+
+
输入关键词后,这里会显示匹配到的菜谱。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
还没有聚会单
+
点右下角新建一张备菜清单,把要做的菜先安排起来。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
生成分享图
+
正在把聚会清单整理成一张好看的菜单卡…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![菜品封面]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 已选菜品
+
+
正在整理这次要做的菜。
+
+
+
+
+
+
+
+
+ 聚会照片
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
确认一下这步操作
+
确定要继续吗?
+
+
+
+
+
diff --git a/static/js/api.js b/static/js/api.js
index cf549e4..04cf923 100644
--- a/static/js/api.js
+++ b/static/js/api.js
@@ -1,80 +1,93 @@
// API 调用封装
const API_BASE = '/api';
+async function apiRequest(url, options = {}) {
+ const res = await fetch(url, {
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ },
+ });
+
+ if (!res.ok) {
+ let errorMsg = `请求失败 (${res.status})`;
+ try {
+ const data = await res.json();
+ if (data.error) errorMsg = data.error;
+ else if (data.detail) errorMsg = data.detail;
+ } catch {
+ // response not JSON
+ }
+ throw new Error(errorMsg);
+ }
+
+ // 204 No Content
+ if (res.status === 204) return null;
+ return res.json();
+}
+
const API = {
// 菜品相关
async getDishes(params = {}) {
const query = new URLSearchParams(params).toString();
- const res = await fetch(`${API_BASE}/dishes/${query ? '?' + query : ''}`);
- return res.json();
+ return apiRequest(`${API_BASE}/dishes/${query ? '?' + query : ''}`);
},
async getDish(id) {
- const res = await fetch(`${API_BASE}/dishes/${id}/`);
- return res.json();
+ return apiRequest(`${API_BASE}/dishes/${id}/`);
},
async createDish(data) {
- const res = await fetch(`${API_BASE}/dishes/`, {
+ return apiRequest(`${API_BASE}/dishes/`, {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(data)
+ body: JSON.stringify(data),
});
- return res.json();
},
async updateDish(id, data) {
- const res = await fetch(`${API_BASE}/dishes/${id}/`, {
+ return apiRequest(`${API_BASE}/dishes/${id}/`, {
method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(data)
+ body: JSON.stringify(data),
});
- return res.json();
},
async deleteDish(id) {
- await fetch(`${API_BASE}/dishes/${id}/`, { method: 'DELETE' });
+ return apiRequest(`${API_BASE}/dishes/${id}/`, { method: 'DELETE' });
},
// 分类相关
async getCategories() {
- const res = await fetch(`${API_BASE}/categories/`);
- return res.json();
+ return apiRequest(`${API_BASE}/categories/`);
},
// 订单相关
async getOrders(params = {}) {
const query = new URLSearchParams(params).toString();
- const res = await fetch(`${API_BASE}/orders/${query ? '?' + query : ''}`);
- return res.json();
+ return apiRequest(`${API_BASE}/orders/${query ? '?' + query : ''}`);
},
async createOrder(data) {
- const res = await fetch(`${API_BASE}/orders/`, {
+ return apiRequest(`${API_BASE}/orders/`, {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(data)
+ body: JSON.stringify(data),
});
- return res.json();
},
async completeOrder(id) {
- const res = await fetch(`${API_BASE}/orders/${id}/complete/`, { method: 'POST' });
- return res.json();
+ return apiRequest(`${API_BASE}/orders/${id}/complete/`, { method: 'POST' });
},
async deleteOrder(id) {
- await fetch(`${API_BASE}/orders/${id}/`, { method: 'DELETE' });
+ return apiRequest(`${API_BASE}/orders/${id}/`, { method: 'DELETE' });
},
// URL解析
async parseUrl(url) {
- const res = await fetch(`${API_BASE}/parse-url/`, {
+ return apiRequest(`${API_BASE}/parse-url/`, {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ url })
+ body: JSON.stringify({ url }),
});
- return res.json();
},
// 图片上传
@@ -83,8 +96,19 @@ const API = {
formData.append('image', file);
const res = await fetch('/upload-image/', {
method: 'POST',
- body: formData
+ body: formData,
});
+
+ if (!res.ok) {
+ let errorMsg = `上传失败 (${res.status})`;
+ try {
+ const data = await res.json();
+ if (data.error) errorMsg = data.error;
+ } catch {
+ // response not JSON
+ }
+ throw new Error(errorMsg);
+ }
return res.json();
- }
+ },
};
diff --git a/static/js/app.js b/static/js/app.js
index c691859..092b05a 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -1,131 +1,1746 @@
-// 应用入口
+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
-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();
- }
+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();
+ });
+}
-// DOM 加载完成后初始化
document.addEventListener('DOMContentLoaded', () => {
- // 初始化全局变量(保持向后兼容)
- window.API_BASE = '/api';
-
- // 初始化应用
- App.init();
+ loadCategories();
+ loadDishes();
+ loadOrders();
+ initEventListeners();
+ switchPage('menu');
});
-// 导出
-window.App = App;
+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.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.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.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) => `
+
+
+
+
+
+ 支持 JPG、PNG
+
+
+ ${(step.images || []).map(img => `
+
+

+
+
+ `).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 = `
+
+
+
+
+ 支持 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 += `

`;
+ });
+
+ 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\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}
+
${renderDishMeta(d)}
+
+
+
+
+ `).join('');
+ } else {
+ dishesContainer.innerHTML = (order.dishes_detail || []).map(d => `
+
+
+

+
+
${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 `
+
+ `;
+ }).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);
+ };
+}
diff --git a/templates/admin/login.html b/templates/admin/login.html
new file mode 100644
index 0000000..53d99d2
--- /dev/null
+++ b/templates/admin/login.html
@@ -0,0 +1,308 @@
+
+
+
+
+
+ 登录 - 玉姐的菜单
+
+
+
+
+
+
+
+
+
+
+
+
请登录以继续
+
+ {% if form.errors %}
+
+
+ 用户名或密码错误,请重试
+
+ {% endif %}
+
+
+
+
+
+
+
diff --git a/templates/index.html b/templates/index.html
index 011bfd1..e94e6dc 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -10,4055 +10,7 @@
-
-
+
@@ -4171,6 +123,13 @@
把这本家里的菜谱本保存好,换设备时也能继续翻。
+