feat: 现代化翻页组件 + 完整项目更新

- 新增 Pagination 通用组件:首页/上一页/页码/下一页/末页导航
- PickModal 和 SwapModal 集成分页功能,每页20项
- 搜索/筛选切换自动回到第1页
- 现代化CSS样式:hover浮起动效、主色高亮、移动端适配
- 新增 dashboard、admin 管理模板
- 菜系(cuisine)模型和迁移
- 构建脚本 build.py 支持 PyInstaller 打包
- 前端资源重新构建
This commit is contained in:
Chunyu
2026-08-28 13:44:21 +08:00
parent 65b52b052f
commit d7553520ce
51 changed files with 4318 additions and 441 deletions
+35 -4
View File
@@ -1,7 +1,32 @@
from django.contrib import admin
from django.utils.html import format_html, mark_safe
from .models import Dish
from .models import Cuisine, Dish
@admin.action(description='启用所选菜系')
def activate_cuisines(modeladmin, request, queryset):
queryset.update(is_active=True)
@admin.action(description='停用所选菜系')
def deactivate_cuisines(modeladmin, request, queryset):
queryset.update(is_active=False)
@admin.register(Cuisine)
class CuisineAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'sort_order', 'is_active', 'dish_count_display', 'created_at')
list_editable = ('sort_order', 'is_active')
list_filter = ('is_active',)
search_fields = ('name',)
list_per_page = 50
actions = [activate_cuisines, deactivate_cuisines]
@admin.display(description='菜品数')
def dish_count_display(self, obj):
count = obj.dishes.count()
return format_html('<span style="font-weight:600">{}</span>', count)
@admin.action(description='启用所选菜品(参与自动生成)')
@@ -16,10 +41,10 @@ def deactivate_dishes(modeladmin, request, queryset):
@admin.register(Dish)
class DishAdmin(admin.ModelAdmin):
list_display = ('name', 'cuisine', 'dish_type_display', 'ingredient_detail', 'protein', 'fat', 'calorie', 'nutri_badge', 'is_active', 'created_at')
list_display = ('name', 'cuisine_display', 'dish_type_display', 'ingredient_detail', 'protein', 'fat', 'calorie', 'nutri_badge', 'is_active', 'created_at')
list_filter = ('dish_type', 'cuisine', 'is_active')
search_fields = ('name', 'ingredient_detail', 'cuisine', 'pinyin')
list_editable = ('cuisine', 'ingredient_detail', 'protein', 'fat', 'calorie', 'is_active')
search_fields = ('name', 'ingredient_detail', 'cuisine__name', 'pinyin')
list_editable = ('ingredient_detail', 'protein', 'fat', 'calorie', 'is_active')
list_per_page = 50
actions = [activate_dishes, deactivate_dishes]
fieldsets = (
@@ -28,6 +53,12 @@ class DishAdmin(admin.ModelAdmin):
('其他', {'fields': ('description',), 'classes': ('collapse',)}),
)
@admin.display(description='菜系', ordering='cuisine')
def cuisine_display(self, obj):
if obj.cuisine:
return obj.cuisine.name
return '—'
@admin.display(description='营养提示', ordering='calorie')
def nutri_badge(self, obj):
badges = []
@@ -0,0 +1,29 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
"""Step 1: Create the Cuisine table (no changes to Dish yet)."""
dependencies = [
('meals', '0005_dish_pinyin'),
]
operations = [
migrations.CreateModel(
name='Cuisine',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50, unique=True, verbose_name='菜系名称')),
('description', models.CharField(blank=True, default='', max_length=200, verbose_name='描述')),
('sort_order', models.IntegerField(default=0, help_text='数值越小越靠前', verbose_name='排序')),
('is_active', models.BooleanField(default=True, verbose_name='启用')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
],
options={
'verbose_name': '菜系',
'verbose_name_plural': '菜系',
'ordering': ['sort_order', 'id'],
},
),
]
@@ -0,0 +1,93 @@
import django.db.models.deletion
from django.db import migrations, models
CUISINE_SEEDS = [
('家常菜', 0),
('本帮菜', 10),
('江浙菜', 20),
('川菜', 30),
('粤菜', 40),
('鲁菜', 50),
('湘菜', 60),
('西北菜', 70),
('面点小吃', 80),
]
def seed_cuisines(apps, schema_editor):
"""Create Cuisine rows and save dish->cuisine mapping to a temp table."""
Cuisine = apps.get_model('meals', 'Cuisine')
name_to_id = {}
for name, order in CUISINE_SEEDS:
obj, _ = Cuisine.objects.get_or_create(name=name, defaults={'sort_order': order})
name_to_id[name] = obj.pk
from django.db import connection
with connection.cursor() as cursor:
cursor.execute("SELECT id, cuisine FROM meals_dish WHERE cuisine IS NOT NULL AND cuisine != ''")
rows = cursor.fetchall()
for _, cuisine_name in rows:
if cuisine_name not in name_to_id:
obj, _ = Cuisine.objects.get_or_create(name=cuisine_name, defaults={'sort_order': 90})
name_to_id[cuisine_name] = obj.pk
with connection.cursor() as cursor:
cursor.execute("CREATE TABLE _temp_dish_cuisine (dish_id INTEGER, cuisine_id INTEGER)")
for dish_id, cuisine_name in rows:
cuisine_id = name_to_id.get(cuisine_name)
if cuisine_id:
cursor.execute(
"INSERT INTO _temp_dish_cuisine (dish_id, cuisine_id) VALUES (%s, %s)",
[dish_id, cuisine_id],
)
def update_cuisine_fk(apps, schema_editor):
"""Populate Dish.cuisine_id from the temp table, then clean up."""
from django.db import connection
with connection.cursor() as cursor:
cursor.execute(
"UPDATE meals_dish SET cuisine_id = "
"(SELECT cuisine_id FROM _temp_dish_cuisine "
"WHERE _temp_dish_cuisine.dish_id = meals_dish.id) "
"WHERE EXISTS (SELECT 1 FROM _temp_dish_cuisine "
"WHERE _temp_dish_cuisine.dish_id = meals_dish.id)"
)
cursor.execute("DROP TABLE _temp_dish_cuisine")
def reverse_seed(apps, schema_editor):
"""Reverse: clean up temp table and delete Cuisine rows."""
from django.db import connection
with connection.cursor() as cursor:
cursor.execute("DROP TABLE IF EXISTS _temp_dish_cuisine")
Cuisine = apps.get_model('meals', 'Cuisine')
Cuisine.objects.all().delete()
class Migration(migrations.Migration):
"""Step 2: Seed cuisines from existing data, then alter Dish.cuisine to FK."""
dependencies = [
('meals', '0006_cuisine_dish_cuisine_fk'),
]
operations = [
migrations.RunPython(seed_cuisines, reverse_seed),
migrations.AlterField(
model_name='dish',
name='cuisine',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='dishes',
to='meals.cuisine',
verbose_name='菜系',
),
),
migrations.RunPython(update_cuisine_fk, migrations.RunPython.noop),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.8 on 2026-08-27 06:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('meals', '0007_seed_cuisines_and_update_dish'),
]
operations = [
migrations.AlterField(
model_name='cuisine',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]
+29 -12
View File
@@ -3,6 +3,23 @@ from django.db import models
from pypinyin import Style, lazy_pinyin
class Cuisine(models.Model):
"""菜系:可动态管理的菜系分类"""
name = models.CharField('菜系名称', max_length=50, unique=True)
description = models.CharField('描述', max_length=200, blank=True, default='')
sort_order = models.IntegerField('排序', default=0, help_text='数值越小越靠前')
is_active = models.BooleanField('启用', default=True)
created_at = models.DateTimeField('创建时间', auto_now_add=True)
class Meta:
ordering = ['sort_order', 'id']
verbose_name = '菜系'
verbose_name_plural = '菜系'
def __str__(self):
return self.name
class Dish(models.Model):
TYPE_CHOICES = [
('meat', '荤菜'),
@@ -10,21 +27,17 @@ class Dish(models.Model):
('soup', '汤'),
('staple', '主食'),
]
CUISINE_CHOICES = [
('家常菜', '家常菜'),
('本帮菜', '本帮菜'),
('江浙菜', '江浙菜'),
('川菜', '川菜'),
('粤菜', '粤菜'),
('鲁菜', '鲁菜'),
('湘菜', '湘菜'),
('西北菜', '西北菜'),
('面点小吃', '面点小吃'),
]
name = models.CharField('菜名', max_length=100, unique=True)
dish_type = models.CharField('类型', max_length=10, choices=TYPE_CHOICES)
cuisine = models.CharField('菜系', max_length=20, choices=CUISINE_CHOICES, blank=True, default='家常菜')
cuisine = models.ForeignKey(
Cuisine,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='dishes',
verbose_name='菜系',
)
ingredient_detail = models.CharField('配料明细', max_length=200, blank=True, help_text='例如:牛心菜85g五花肉片15g')
protein = models.DecimalField('蛋白质(g)', max_digits=6, decimal_places=1, default=0)
fat = models.DecimalField('脂肪(g)', max_digits=6, decimal_places=1, default=0)
@@ -42,6 +55,10 @@ class Dish(models.Model):
def __str__(self):
return f'{self.get_dish_type_display()}: {self.name}'
@property
def cuisine_name(self):
return self.cuisine.name if self.cuisine else ''
def save(self, *args, **kwargs):
if self.name:
full = ''.join(lazy_pinyin(self.name))
+14 -2
View File
@@ -1,11 +1,23 @@
from rest_framework import serializers
from .models import Dish
from .models import Cuisine, Dish
class CuisineSerializer(serializers.ModelSerializer):
dish_count = serializers.SerializerMethodField()
class Meta:
model = Cuisine
fields = ['id', 'name', 'description', 'sort_order', 'is_active', 'dish_count', 'created_at']
def get_dish_count(self, obj):
return obj.dishes.count()
class DishSerializer(serializers.ModelSerializer):
dish_type_display = serializers.CharField(source='get_dish_type_display', read_only=True)
cuisine_name = serializers.CharField(read_only=True)
class Meta:
model = Dish
fields = ['id', 'name', 'dish_type', 'dish_type_display', 'cuisine', 'ingredient_detail', 'protein', 'fat', 'calorie', 'description', 'is_active', 'created_at']
fields = ['id', 'name', 'dish_type', 'dish_type_display', 'cuisine', 'cuisine_name', 'ingredient_detail', 'protein', 'fat', 'calorie', 'description', 'is_active', 'created_at']
+1
View File
@@ -5,6 +5,7 @@ from . import views
router = DefaultRouter()
router.register('dishes', views.DishViewSet)
router.register('cuisines', views.CuisineViewSet)
urlpatterns = [
path('dishes/bulk/', views.bulk_create_dishes, name='bulk_create_dishes'),
+36 -17
View File
@@ -8,13 +8,14 @@ from openpyxl.utils import get_column_letter
from rest_framework import status, viewsets
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAdminUser
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from .models import Dish
from .serializers import DishSerializer
from .models import Cuisine, Dish
from .serializers import DishSerializer, CuisineSerializer
WEEKDAYS = ['周一', '周二', '周三', '周四', '周五']
SCHOOL_NAME = '上海外国语大学附属宝山双语学校'
SCHOOL_NAME = '学校菜单'
COLOR_HEADER = PatternFill('solid', fgColor='2F5597')
COLOR_DAY = PatternFill('solid', fgColor='D9E2F3')
@@ -27,9 +28,16 @@ BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
CENTER = Alignment(horizontal='center', vertical='center', wrap_text=True)
class CuisineViewSet(viewsets.ModelViewSet):
queryset = Cuisine.objects.all()
serializer_class = CuisineSerializer
permission_classes = [AllowAny]
class DishViewSet(viewsets.ModelViewSet):
queryset = Dish.objects.all()
serializer_class = DishSerializer
permission_classes = [AllowAny]
def get_queryset(self):
qs = Dish.objects.all()
@@ -59,9 +67,14 @@ def bulk_create_dishes(request):
if not name or dish_type not in ('meat', 'veg', 'soup', 'staple'):
skipped.append(name or '未命名')
continue
# Resolve cuisine FK by name
cuisine_name = str(raw.get('cuisine', '') or '').strip()
cuisine_obj = None
if cuisine_name:
cuisine_obj, _ = Cuisine.objects.get_or_create(name=cuisine_name)
defaults = {
'dish_type': dish_type,
'cuisine': str(raw.get('cuisine', '家常菜') or '家常菜'),
'cuisine': cuisine_obj,
'ingredient_detail': str(raw.get('ingredient_detail', '') or raw.get('ingredient', '')),
'protein': float(raw.get('protein', 0) or 0),
'fat': float(raw.get('fat', 0) or 0),
@@ -94,7 +107,7 @@ def _pop_next(pool, shuffle_src):
def _serialize_dish(d):
return {
'id': d.id, 'name': d.name, 'ingredient': d.ingredient_detail, 'dish_type': d.dish_type,
'cuisine': d.cuisine, 'protein': float(d.protein), 'fat': float(d.fat), 'calorie': d.calorie,
'cuisine': d.cuisine_name, 'protein': float(d.protein), 'fat': float(d.fat), 'calorie': d.calorie,
}
@@ -211,19 +224,19 @@ def generate_week_menu(payload, menu_key='a'):
week.append({
'day': day,
'meats': [{'id': d.id, 'name': d.name, 'ingredient': d.ingredient_detail, 'dish_type': d.dish_type, 'cuisine': d.cuisine,
'meats': [{'id': d.id, 'name': d.name, 'ingredient': d.ingredient_detail, 'dish_type': d.dish_type, 'cuisine': d.cuisine_name,
'protein': float(d.protein), 'fat': float(d.fat), 'calorie': d.calorie} for d in day_meat_list],
'vegs': [{'id': d.id, 'name': d.name, 'ingredient': d.ingredient_detail, 'dish_type': d.dish_type, 'cuisine': d.cuisine,
'vegs': [{'id': d.id, 'name': d.name, 'ingredient': d.ingredient_detail, 'dish_type': d.dish_type, 'cuisine': d.cuisine_name,
'protein': float(d.protein), 'fat': float(d.fat), 'calorie': d.calorie} for d in day_veg],
'soup': {'id': soup.id, 'name': soup.name, 'ingredient': soup.ingredient_detail, 'dish_type': soup.dish_type, 'cuisine': soup.cuisine,
'soup': {'id': soup.id, 'name': soup.name, 'ingredient': soup.ingredient_detail, 'dish_type': soup.dish_type, 'cuisine': soup.cuisine_name,
'protein': float(soup.protein), 'fat': float(soup.fat), 'calorie': soup.calorie} if soup else None,
'staple': {'id': staple.id, 'name': staple.name, 'ingredient': staple.ingredient_detail, 'dish_type': staple.dish_type, 'cuisine': staple.cuisine,
'staple': {'id': staple.id, 'name': staple.name, 'ingredient': staple.ingredient_detail, 'dish_type': staple.dish_type, 'cuisine': staple.cuisine_name,
'protein': float(staple.protein), 'fat': float(staple.fat), 'calorie': staple.calorie} if staple else None,
})
return week
def build_menu_title(label, school_name=None, week_no=None):
def build_menu_title(label, school_name=None, week_no=None, date_start=None, date_end=None):
school_name = school_name or SCHOOL_NAME
iso = date.today().isocalendar()
try:
@@ -232,12 +245,16 @@ def build_menu_title(label, school_name=None, week_no=None):
week_no = iso.week
if not 1 <= week_no <= 53:
week_no = iso.week
try:
monday = date.fromisocalendar(iso.year, week_no, 1)
except ValueError:
monday = date.today() - timedelta(days=date.today().weekday())
friday = monday + timedelta(days=4)
date_range = f'{monday.month}.{monday.day}--{friday.month}.{friday.day}'
# Use custom dates if provided, otherwise compute from week number
if date_start and date_end:
date_range = f'{date_start}--{date_end}'
else:
try:
monday = date.fromisocalendar(iso.year, week_no, 1)
except ValueError:
monday = date.today() - timedelta(days=date.today().weekday())
friday = monday + timedelta(days=4)
date_range = f'{monday.month}.{monday.day}--{friday.month}.{friday.day}'
return f'{school_name}[第{week_no}周]{date_range}营养{label}菜单'
@@ -246,11 +263,13 @@ def generate_menu(request):
data = request.data
school = data.get('school_name') or SCHOOL_NAME
week_no = data.get('week_no')
date_start = data.get('date_start')
date_end = data.get('date_end')
menus = {}
for label in ('A', 'B'):
key = label.lower()
menus[key] = {
'title': build_menu_title(label, school, week_no),
'title': build_menu_title(label, school, week_no, date_start, date_end),
'week': generate_week_menu(data, menu_key=key),
}
return Response(menus)
+90
View File
@@ -0,0 +1,90 @@
"""Dashboard API & view – visualization panel for the school meal planner."""
import json
from collections import Counter
from django.http import JsonResponse
from django.shortcuts import render
from django.db.models import Avg, Count, Max, Min, Sum
from .models import Dish
def _dashboard_stats():
"""Return aggregate statistics for the dashboard."""
total = Dish.objects.count()
active = Dish.objects.filter(is_active=True).count()
inactive = total - active
# Count by type
type_counts = dict(
Dish.objects.values_list('dish_type').annotate(c=Count('id')).values_list('dish_type', 'c')
)
# Count by cuisine
cuisine_counts = dict(
Dish.objects.values_list('cuisine').annotate(c=Count('id')).values_list('cuisine', 'c')
)
# Nutrition averages (active dishes only, meat + veg only for meaningful stats)
nutrition = Dish.objects.filter(is_active=True, dish_type__in=('meat', 'veg')).aggregate(
avg_protein=Avg('protein'),
avg_fat=Avg('fat'),
avg_calorie=Avg('calorie'),
max_calorie=Max('calorie'),
min_calorie=Min('calorie'),
)
# Per-type nutrition averages
type_nutrition = {}
for dt in ('meat', 'veg', 'soup', 'staple'):
agg = Dish.objects.filter(is_active=True, dish_type=dt).aggregate(
avg_protein=Avg('protein'),
avg_fat=Avg('fat'),
avg_calorie=Avg('calorie'),
count=Count('id'),
)
type_nutrition[dt] = agg
# Recent 10 dishes
recent = list(
Dish.objects.order_by('-created_at')[:10].values(
'id', 'name', 'dish_type', 'cuisine', 'protein', 'fat', 'calorie', 'is_active', 'created_at',
)
)
for r in recent:
r['created_at'] = r['created_at'].strftime('%Y-%m-%d %H:%M') if r['created_at'] else ''
return {
'total': total,
'active': active,
'inactive': inactive,
'type_counts': type_counts,
'cuisine_counts': cuisine_counts,
'nutrition': {
'avg_protein': round(float(nutrition['avg_protein'] or 0), 1),
'avg_fat': round(float(nutrition['avg_fat'] or 0), 1),
'avg_calorie': round(float(nutrition['avg_calorie'] or 0)),
'max_calorie': int(nutrition['max_calorie'] or 0),
'min_calorie': int(nutrition['min_calorie'] or 0),
},
'type_nutrition': {
k: {
'avg_protein': round(float(v['avg_protein'] or 0), 1),
'avg_fat': round(float(v['avg_fat'] or 0), 1),
'avg_calorie': round(float(v['avg_calorie'] or 0)),
'count': v['count'],
}
for k, v in type_nutrition.items()
},
'recent': recent,
}
def dashboard_api(request):
"""GET /api/dashboard/ -> JSON stats for the dashboard."""
return JsonResponse(_dashboard_stats())
def dashboard_view(request):
"""GET /dashboard/ -> serve the dashboard HTML page."""
return render(request, 'dashboard.html')
+56
View File
@@ -0,0 +1,56 @@
import mimetypes
import os
from django.http import FileResponse, HttpResponseNotFound
from django.views.decorators.csrf import ensure_csrf_cookie
_FE_DIR_CACHE = None
_INDEX_CACHE = None
def _get_frontend_dir():
global _FE_DIR_CACHE
if _FE_DIR_CACHE is None:
import sys
if getattr(sys, 'frozen', False):
_FE_DIR_CACHE = os.path.join(sys._MEIPASS, 'backend', 'static', 'frontend')
else:
env_dir = os.environ.get('FRONTEND_DIR')
if env_dir and os.path.isdir(env_dir):
_FE_DIR_CACHE = env_dir
else:
_FE_DIR_CACHE = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'static', 'frontend',
)
return _FE_DIR_CACHE
def _get_index_path():
global _INDEX_CACHE
if _INDEX_CACHE is None:
p = os.path.join(_get_frontend_dir(), 'index.html')
_INDEX_CACHE = p if os.path.isfile(p) else ''
return _INDEX_CACHE
def _serve_file(filepath):
ct, _ = mimetypes.guess_type(filepath)
return FileResponse(open(filepath, 'rb'), content_type=ct or 'application/octet-stream')
@ensure_csrf_cookie
def spa_serve(request, path=None):
"""Serve built React assets or fall back to index.html."""
fdir = _get_frontend_dir()
req_path = (path or '').lstrip('/')
if req_path:
target = os.path.normpath(os.path.join(fdir, req_path))
if target.startswith(os.path.normpath(fdir)) and os.path.isfile(target):
return _serve_file(target)
index = _get_index_path()
if index:
return _serve_file(index)
return HttpResponseNotFound('Frontend not built. Run build.py first.')