diff --git a/.gitignore b/.gitignore index 65ab6d7..3df6256 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,7 @@ Thumbs.db .env *.pem git/ + +# 对象存储上传素材 / 临时静态 +静态文件上传/ +backend/static/ diff --git a/backend/Dockerfile b/backend/Dockerfile index dea5e67..6321a96 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,7 +1,9 @@ FROM python:3.12-slim ENV PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=1 + PIP_NO_CACHE_DIR=1 \ + PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple \ + PIP_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn WORKDIR /app diff --git a/backend/config/settings.py b/backend/config/settings.py index e0fb5b1..80ac39c 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -32,6 +32,7 @@ ALLOWED_HOSTS = [h.strip() for h in os.environ.get('DJANGO_ALLOWED_HOSTS', '*'). # Application definition INSTALLED_APPS = [ + 'jazzmin', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -59,7 +60,7 @@ ROOT_URLCONF = 'config.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': [BASE_DIR / 'templates'], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -119,8 +120,9 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/6.0/howto/static-files/ -STATIC_URL = 'static/' +STATIC_URL = os.environ.get('DJANGO_STATIC_URL', '/xx/admin/') STATIC_ROOT = BASE_DIR / 'staticfiles' +STATICFILES_DIRS = [BASE_DIR / 'static'] CORS_ALLOWED_ORIGINS = [ 'http://localhost:5173', @@ -129,6 +131,7 @@ CORS_ALLOWED_ORIGINS = [ 'http://xxcdn.mymoyu.top', 'https://xx.mymoyu.top', 'https://xxcdn.mymoyu.top', + 'https://qnycdn.mymoyu.top', ] REST_FRAMEWORK = { @@ -139,5 +142,59 @@ REST_FRAMEWORK = { CSRF_TRUSTED_ORIGINS = [h.strip() for h in os.environ.get( 'DJANGO_CSRF_TRUSTED_ORIGINS', - 'http://xx.mymoyu.top,http://xxcdn.mymoyu.top,https://xx.mymoyu.top,https://xxcdn.mymoyu.top', + 'http://xx.mymoyu.top,http://xxcdn.mymoyu.top,https://xx.mymoyu.top,https://xxcdn.mymoyu.top,https://qnycdn.mymoyu.top', ).split(',')] + +JAZZMIN_SETTINGS = { + 'site_title': '学校订餐菜单管理系统', + 'site_header': '学校订餐菜单生成器', + 'site_brand': '订餐菜单管理', + 'welcome_sign': '欢迎使用学校订餐菜单管理系统', + 'copyright': '上海外国语大学附属宝山双语学校', + 'search_model': ['meals.Dish', 'auth.User'], + 'show_sidebar': True, + 'navigation_expanded': True, + 'hide_apps': [], + 'hide_models': [], + 'icons': { + 'meals.Dish': 'fas fa-utensils', + 'auth.User': 'fas fa-user', + 'auth.Group': 'fas fa-users', + }, + 'default_icon_parents': 'fas fa-chevron-circle-right', + 'default_icon_children': 'fas fa-circle', + 'show_ui_builder': False, + 'changeform_format': 'horizontal_tabs', + 'related_modal_active': True, + 'custom_css': None, + 'custom_js': None, + 'theme': 'flatly', + 'dark_mode_theme': 'darkly', + 'header_classes': 'bg-primary', + 'order_with_respect_to': ['meals', 'auth'], +} + +JAZZMIN_UI_TWEAKS = { + 'navbar_small_text': False, + 'footer_small_text': False, + 'body_small_text': False, + 'brand_small_text': False, + 'brand_colour': False, + 'accent': 'accent-primary', + 'navbar': 'navbar-white navbar-light', + 'no_navbar_border': False, + 'navbar_fixed': True, + 'layout_boxed': False, + 'footer_fixed': False, + 'sidebar_fixed': True, + 'sidebar': 'sidebar-dark-primary', + 'sidebar_nav_small_text': False, + 'sidebar_disable_expand': False, + 'sidebar_nav_child_indent': False, + 'sidebar_nav_compact_style': False, + 'sidebar_nav_legacy_style': False, + 'sidebar_nav_flat_style': False, + 'theme': 'flatly', + 'dark_mode_theme': 'darkly', + 'button_classes': {'primary': 'btn-outline-primary', 'secondary': 'btn-outline-secondary', 'info': 'btn-outline-info', 'warning': 'btn-outline-warning', 'danger': 'btn-outline-danger', 'success': 'btn-outline-success'}, +} diff --git a/backend/meals/admin.py b/backend/meals/admin.py index f7e9bea..7f098d0 100644 --- a/backend/meals/admin.py +++ b/backend/meals/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin +from django.utils.html import format_html, mark_safe from .models import Dish @@ -15,10 +16,10 @@ def deactivate_dishes(modeladmin, request, queryset): @admin.register(Dish) class DishAdmin(admin.ModelAdmin): - list_display = ('name', 'cuisine', 'dish_type', 'ingredient_detail', 'protein', 'fat', 'calorie', 'is_active', 'created_at') + list_display = ('name', 'cuisine', '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') - list_editable = ('cuisine', 'dish_type', 'ingredient_detail', 'protein', 'fat', 'calorie', 'is_active') + search_fields = ('name', 'ingredient_detail', 'cuisine', 'pinyin') + list_editable = ('cuisine', 'ingredient_detail', 'protein', 'fat', 'calorie', 'is_active') list_per_page = 50 actions = [activate_dishes, deactivate_dishes] fieldsets = ( @@ -26,3 +27,30 @@ class DishAdmin(admin.ModelAdmin): ('营养信息', {'fields': ('ingredient_detail', 'protein', 'fat', 'calorie')}), ('其他', {'fields': ('description',), 'classes': ('collapse',)}), ) + + @admin.display(description='营养提示', ordering='calorie') + def nutri_badge(self, obj): + badges = [] + if obj.dish_type == 'meat' and obj.fat >= 20: + badges.append('高脂') + if obj.dish_type == 'meat' and obj.calorie >= 300: + badges.append('高热量') + if obj.dish_type in ('meat', 'veg') and obj.protein >= 15: + badges.append('高蛋白') + if obj.dish_type == 'veg' and obj.fat <= 3 and obj.calorie <= 60: + badges.append('清淡') + return mark_safe(' '.join(badges)) if badges else '—' + + @admin.display(description='类型') + def dish_type_display(self, obj): + colors = {'meat': '#d9534f', 'veg': '#5cb85c', 'soup': '#5bc0de', 'staple': '#f0ad4e'} + return format_html( + '{}', + colors.get(obj.dish_type, '#999'), + dict(Dish.TYPE_CHOICES).get(obj.dish_type, obj.dish_type), + ) + + +admin.site.site_header = '学校订餐菜单管理系统' +admin.site.site_title = '学校订餐菜单管理' +admin.site.index_title = '菜单管理后台' diff --git a/backend/meals/migrations/0005_dish_pinyin.py b/backend/meals/migrations/0005_dish_pinyin.py new file mode 100644 index 0000000..33186fc --- /dev/null +++ b/backend/meals/migrations/0005_dish_pinyin.py @@ -0,0 +1,27 @@ +from django.db import migrations, models +from pypinyin import Style, lazy_pinyin + + +def fill_pinyin(apps, schema_editor): + Dish = apps.get_model('meals', 'Dish') + for dish in Dish.objects.all(): + full = ''.join(lazy_pinyin(dish.name)) + initials = ''.join(lazy_pinyin(dish.name, style=Style.FIRST_LETTER)) + dish.pinyin = f'{full} {initials}' + dish.save(update_fields=['pinyin']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('meals', '0004_dish_cuisine'), + ] + + operations = [ + migrations.AddField( + model_name='dish', + name='pinyin', + field=models.CharField(blank=True, editable=False, max_length=200, verbose_name='拼音索引'), + ), + migrations.RunPython(fill_pinyin, migrations.RunPython.noop), + ] diff --git a/backend/meals/models.py b/backend/meals/models.py index 5f9035b..b916d56 100644 --- a/backend/meals/models.py +++ b/backend/meals/models.py @@ -1,5 +1,7 @@ from django.db import models +from pypinyin import Style, lazy_pinyin + class Dish(models.Model): TYPE_CHOICES = [ @@ -29,6 +31,7 @@ class Dish(models.Model): calorie = models.IntegerField('热量(kcal)', default=0) description = models.CharField('备注', max_length=200, blank=True) is_active = models.BooleanField('启用', default=True) + pinyin = models.CharField('拼音索引', max_length=200, blank=True, editable=False) created_at = models.DateTimeField('创建时间', auto_now_add=True) class Meta: @@ -38,3 +41,10 @@ class Dish(models.Model): def __str__(self): return f'{self.get_dish_type_display()}: {self.name}' + + def save(self, *args, **kwargs): + if self.name: + full = ''.join(lazy_pinyin(self.name)) + initials = ''.join(lazy_pinyin(self.name, style=Style.FIRST_LETTER)) + self.pinyin = f'{full} {initials}' + super().save(*args, **kwargs) diff --git a/backend/meals/views.py b/backend/meals/views.py index 7849bd6..146f6c1 100644 --- a/backend/meals/views.py +++ b/backend/meals/views.py @@ -223,23 +223,35 @@ def generate_week_menu(payload, menu_key='a'): return week -def build_menu_title(label): - today = date.today() - week_no = today.isocalendar().week - monday = today - timedelta(days=today.weekday()) +def build_menu_title(label, school_name=None, week_no=None): + school_name = school_name or SCHOOL_NAME + iso = date.today().isocalendar() + try: + week_no = int(week_no) if week_no is not None else iso.week + except (TypeError, ValueError): + 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}' - return f'{SCHOOL_NAME}[第{week_no}周]{date_range}营养{label}菜单' + return f'{school_name}[第{week_no}周]{date_range}营养{label}菜单' @api_view(['POST']) def generate_menu(request): + data = request.data + school = data.get('school_name') or SCHOOL_NAME + week_no = data.get('week_no') menus = {} for label in ('A', 'B'): key = label.lower() menus[key] = { - 'title': build_menu_title(label), - 'week': generate_week_menu(request.data, menu_key=key), + 'title': build_menu_title(label, school, week_no), + 'week': generate_week_menu(data, menu_key=key), } return Response(menus) diff --git a/backend/requirements.txt b/backend/requirements.txt index fefb80a..6d9a980 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,3 +3,5 @@ djangorestframework==3.17.1 django-cors-headers==4.9.0 openpyxl==3.1.5 gunicorn==23.0.0 +django-jazzmin==3.0.5 +pypinyin==0.54.0 diff --git a/backend/templates/admin/base.html b/backend/templates/admin/base.html new file mode 100644 index 0000000..e4455f6 --- /dev/null +++ b/backend/templates/admin/base.html @@ -0,0 +1,429 @@ +{% load i18n static jazzmin admin_urls %} +{% get_current_language as LANGUAGE_CODE %} +{% get_current_language_bidi as LANGUAGE_BIDI %} +{% get_jazzmin_settings request as jazzmin_settings %} +{% get_jazzmin_ui_tweaks as jazzmin_ui %} + + + +
+ + + + + + + ++ 标题预览:{schoolName.trim() || DEFAULT_SCHOOL}[第{weekNo || '?'}周]日期营养X菜单 +
+