feat: 现代化翻页组件 + 完整项目更新
- 新增 Pagination 通用组件:首页/上一页/页码/下一页/末页导航 - PickModal 和 SwapModal 集成分页功能,每页20项 - 搜索/筛选切换自动回到第1页 - 现代化CSS样式:hover浮起动效、主色高亮、移动端适配 - 新增 dashboard、admin 管理模板 - 菜系(cuisine)模型和迁移 - 构建脚本 build.py 支持 PyInstaller 打包 - 前端资源重新构建
This commit is contained in:
+35
-4
@@ -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
@@ -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))
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
@@ -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.')
|
||||
Reference in New Issue
Block a user