From 65b52b052fb0c5f176fb13ec385bf24e957e0886 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 6 Aug 2026 21:11:14 +0800 Subject: [PATCH] =?UTF-8?q?=E8=90=A5=E5=85=BB=E6=A0=A1=E5=87=86=E3=80=81?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E7=BE=8E=E5=8C=96=E3=80=81=E9=9D=99=E6=80=81?= =?UTF-8?q?=E8=B5=B0=E5=AF=B9=E8=B1=A1=E5=AD=98=E5=82=A8=20CDN=E3=80=81?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E5=8F=AF=E7=BC=96=E8=BE=91=E3=80=81admin=20?= =?UTF-8?q?=E6=8B=BC=E9=9F=B3=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 菜品营养按食物成分表+烹饪方式校准(calibrate_nutrition 命令) - 后台接入 jazzmin 主题美化,移除 Google Fonts(模板覆盖) - 前端/后台静态资源指向 qnycdn 对象存储 CDN(vite base + DJANGO_STATIC_URL) - 菜单标题学校名称/周数可编辑,日期随周数计算 - admin 搜索支持拼音首字母/全拼(Dish.pinyin 索引,pypinyin) - Dockerfile 使用清华 pip 镜像源 --- .gitignore | 4 + backend/Dockerfile | 4 +- backend/config/settings.py | 63 ++- backend/meals/admin.py | 34 +- backend/meals/migrations/0005_dish_pinyin.py | 27 ++ backend/meals/models.py | 10 + backend/meals/views.py | 26 +- backend/requirements.txt | 2 + backend/templates/admin/base.html | 429 +++++++++++++++++++ backend/templates/registration/base.html | 90 ++++ deploy.sh | 19 + docker-compose.yml | 1 + frontend/nginx.conf | 19 +- frontend/src/App.jsx | 50 ++- frontend/src/index.css | 42 ++ frontend/vite.config.js | 1 + 16 files changed, 805 insertions(+), 16 deletions(-) create mode 100644 backend/meals/migrations/0005_dish_pinyin.py create mode 100644 backend/templates/admin/base.html create mode 100644 backend/templates/registration/base.html 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 %} + + + + + + + + + + + + {% block title %}{{ title }} | {{ jazzmin_settings.site_title }}{% endblock %} + + + + + + + + + + {% if jazzmin_settings.show_theme_chooser %} + + {% endif %} + + + + + {% if jazzmin_settings.custom_css %} + + + {% endif %} + + + + + + {% if jazzmin_settings.use_google_fonts_cdn %} + + + {% endif %} + + {% block extrastyle %} {% endblock %} + {% block extrahead %} {% endblock %} + + + +
+ + {% if not is_popup %} + + {% block sidebar %} + {% if jazzmin_settings.show_sidebar %} + {% get_side_menu as side_menu_list %} + + + {% endif %} + {% endblock %} + {% endif %} + +
+ {% block page_content %} + {% if not is_popup %} +
+
+
+
+

{% block content_title %}{% endblock %}

+
+
+
{% block breadcrumbs %}{% endblock %}
+ {% block page_actions %}{% endblock %} +
+
+
+
+ {% endif %} + +
+
+ {% block messages %} + {% for message in messages %} + {% if message.tags == 'success' %} + + {% elif message.tags == 'error' %} + + {% elif message.tags == 'warning' %} + + {% elif message.tags == 'info' %} + + {% endif %} + {% endfor %} + {% endblock messages %} +
+ {% block content %} {% endblock %} +
+
+
+ + + {% endblock %} +
+ +{% block footer %} + {% if not is_popup %} +
+
+ {% trans 'Jazzmin version' %} {% get_jazzmin_version %} +
+ {% autoescape off %} + {% trans 'Copyright' %} © {% now 'Y' %} {{ jazzmin_settings.copyright }}. {% trans 'All rights reserved.' %} + {% endautoescape %} +
+ {% if jazzmin_settings.show_ui_builder %} + {% include 'jazzmin/includes/ui_builder_panel.html' %} + {% endif %} + {% endif %} +{% endblock %} + +
+ +{% if jazzmin_settings.show_ui_builder %} + +{% endif %} + + + + + + + + + + +{% if jazzmin_settings.custom_js %} + +{% endif %} + +{% if jazzmin_settings.show_ui_builder %} + + +{% endif %} + +{% block extrajs %}{% endblock %} + + + + diff --git a/backend/templates/registration/base.html b/backend/templates/registration/base.html new file mode 100644 index 0000000..a331b74 --- /dev/null +++ b/backend/templates/registration/base.html @@ -0,0 +1,90 @@ +{% 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 %} + + + + + + + + + + {% block title %}{{ title }} | {{ jazzmin_settings.site_title }}{% endblock %} + + + + + + + + + {% if jazzmin_ui.theme.name != 'default' %} + + {% endif %} + + + + + {% if jazzmin_settings.custom_css %} + + + {% endif %} + + + + + + {% if jazzmin_settings.use_google_fonts_cdn %} + + + {% endif %} + + {% block extrastyle %} {% endblock %} + {% block extrahead %} {% endblock %} + + + +
+ + +
+ +
+
+ + + + + + + + +{% if jazzmin_settings.custom_js %} + +{% endif %} + + + + diff --git a/deploy.sh b/deploy.sh index 75b8959..911ddf7 100644 --- a/deploy.sh +++ b/deploy.sh @@ -72,6 +72,8 @@ server { server_name $DOMAIN; client_max_body_size 20M; + gzip on; + gzip_types text/plain text/css application/javascript application/json; location /api/ { proxy_pass http://127.0.0.1:8000; @@ -90,6 +92,23 @@ server { alias $APP_DIR/backend/staticfiles/; } + location /xx/web/ { + alias $APP_DIR/frontend/dist/; + index index.html; + expires 7d; + } + + location /xx/web/assets/ { + alias $APP_DIR/frontend/dist/assets/; + add_header Cache-Control "public, max-age=31536000, immutable"; + expires 1y; + } + + location /xx/admin/ { + alias $APP_DIR/backend/staticfiles/; + expires 7d; + } + location / { root $APP_DIR/frontend/dist; index index.html; diff --git a/docker-compose.yml b/docker-compose.yml index bc37991..40bc32e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: - DJANGO_ALLOWED_HOSTS=* - DJANGO_DEBUG=False - DJANGO_DB_PATH=/data/db.sqlite3 + - DJANGO_STATIC_URL=https://qnycdn.mymoyu.top/xx/admin/ volumes: - backend_data:/data - static_volume:/app/staticfiles diff --git a/frontend/nginx.conf b/frontend/nginx.conf index d83e2dd..ad62ef8 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,7 +4,7 @@ upstream django_backend { server { listen 80; - server_name xx.mymoyu.top _; + server_name xx.mymoyu.top qnycdn.mymoyu.top _; client_max_body_size 20m; gzip on; @@ -30,6 +30,23 @@ server { expires 7d; } + location /xx/web/ { + alias /usr/share/nginx/html/; + index index.html; + expires 7d; + } + + location /xx/web/assets/ { + alias /usr/share/nginx/html/assets/; + add_header Cache-Control "public, max-age=31536000, immutable"; + expires 1y; + } + + location /xx/admin/ { + alias /static/; + expires 7d; + } + location / { root /usr/share/nginx/html; index index.html; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 987c6ba..a7828ac 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -18,6 +18,18 @@ const TYPE_GROUPS = [ ] const TYPE_LABELS = { meats: '荤菜', vegs: '素菜', soup: '营养汤', staple: '主食' } const TYPE_TO_TAB = { meats: 'meat', vegs: 'veg', soup: 'soup', staple: 'staple' } +const DEFAULT_SCHOOL = '上海外国语大学附属宝山双语学校' + +const currentIsoWeek = () => { + const now = new Date() + const target = new Date(now.valueOf()) + const dayNr = (now.getDay() + 6) % 7 + target.setDate(target.getDate() - dayNr + 3) + const firstThursday = new Date(target.getFullYear(), 0, 4) + const firstDayNr = (firstThursday.getDay() + 6) % 7 + firstThursday.setDate(firstThursday.getDate() - firstDayNr + 3) + return 1 + Math.round((target - firstThursday) / (7 * 24 * 3600 * 1000)) +} const slotKey = (menu, day, slot, idx) => `${menu}|${day}|${slot}|${idx}` @@ -234,6 +246,8 @@ function App() { const [swapKeyword, setSwapKeyword] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState('') + const [schoolName, setSchoolName] = useState(DEFAULT_SCHOOL) + const [weekNo, setWeekNo] = useState(String(currentIsoWeek())) const [pinyinMap, setPinyinMap] = useState({}) useEffect(() => { @@ -275,7 +289,12 @@ function App() { const res = await fetch('/api/menu/generate/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ partial: true, selections }), + body: JSON.stringify({ + partial: true, + selections, + school_name: schoolName.trim() || DEFAULT_SCHOOL, + week_no: Number(weekNo), + }), }) const data = await res.json() if (!res.ok) throw new Error(data.detail || '生成失败') @@ -558,6 +577,35 @@ function App() {
+
+
+ + setSchoolName(e.target.value)} + placeholder="学校名称" + /> +
+
+ +
+ setWeekNo(e.target.value)} + /> + +
+
+

+ 标题预览:{schoolName.trim() || DEFAULT_SCHOOL}[第{weekNo || '?'}周]日期营养X菜单 +

+
diff --git a/frontend/src/index.css b/frontend/src/index.css index 43ef22a..266399c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -426,6 +426,48 @@ td:hover .swap-btn, align-items: start; } +.title-fields { + display: flex; + align-items: flex-end; + gap: 24px; + flex-wrap: wrap; + background: #f7f9fc; + border: 1px solid #e3e9f2; + border-radius: 12px; + padding: 16px 18px; + margin-bottom: 20px; +} + +.title-field .label { + margin-bottom: 6px; + font-size: 13px; +} + +.title-field input.pick-search { + min-width: 320px; +} + +.title-field.week-field input[type='number'] { + width: 90px; + padding: 9px 12px; + border: 1.5px solid #d5deea; + border-radius: 8px; + font-size: 14px; +} + +.week-input { + display: flex; + gap: 10px; + align-items: center; +} + +.title-preview { + width: 100%; + margin: 8px 0 0; + color: #2f5597; + font-weight: 600; +} + .slot-panel { border: 1px solid #e3e9f2; border-radius: 12px; diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 2f55436..ff2318b 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], + base: 'https://qnycdn.mymoyu.top/xx/web/', server: { port: 5173, proxy: {