commit 75f214dc406b21b97d9bfe6b7c3cd715c2526d4e Author: root Date: Thu Aug 6 14:09:03 2026 +0800 学校订餐菜单生成器:Django+DRF 后端与 React 前端,支持 A/B 双套营养周菜单生成、部分自选、拼音搜索、营养校准、Excel 导出 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65ab6d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Python +__pycache__/ +*.pyc +backend/.venv/ +.venv/ + +# Node +frontend/node_modules/ +frontend/dist/ + +# Data +backend/db.sqlite3 +backend/staticfiles/ + +# Logs +*.log +vite.log +djangodev.log + +# OS +Thumbs.db +.DS_Store + +# Env / secrets +.env +*.pem +git/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..067a3ac --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# 学校订餐菜单生成器 + +自动生成周一至周五的营养菜单,支持导出 Excel,含后台菜品管理。 + +- 后端:Django + Django REST Framework + openpyxl +- 前端:React 18 + Vite + +## 功能 + +- **两种生成模式** + - 全部自动生成:荤菜、素菜、汤、主食全部自动分配,一周内尽量不重复 + - 自选两个荤菜:每天使用所选 2 个荤菜,素菜等其余自动生成 +- 每天固定 2 荤 2 素,一次随机生成营养 A / B 两套菜单,可逐道替换菜品 +- 菜品带**菜系**分类(本帮/家常/川/粤/鲁/湘/江浙/西北/面点小吃),替换时按菜系分组选择 +- 一键导出带样式的 Excel(左右并排 A/B 两套,各自带配料明细与营养成分分析) +- 后台管理添加/编辑/停用菜品,支持批量导入 + +## 快速启动 + +双击 `start.bat`(首次会自动安装依赖并写入示例菜品数据)。 + +- 前端页面:http://localhost:5173 +- 后台管理:http://127.0.0.1:8000/admin (账号 `admin` / 密码 `admin123`) + +## 手动启动 + +```bash +# 后端 +python -m venv .venv +.venv\Scripts\pip install -r backend\requirements.txt +.venv\Scripts\python backend\manage.py migrate +.venv\Scripts\python backend\manage.py seed_dishes # 写入示例菜品 +.venv\Scripts\python backend\manage.py runserver 0.0.0.0:8000 + +# 前端(另开终端) +cd frontend +npm install +npm run dev +``` + +## 目录结构 + +``` +backend/ + config/ # Django 项目配置 + meals/ # 主应用:菜品模型、生成逻辑、Excel 导出 + management/commands/seed_dishes.py # 示例菜品 + requirements.txt +frontend/ + src/App.jsx # 菜单生成页面 + src/index.css # 页面样式 +start.bat # 一键启动脚本 +``` + +## API 说明 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET/POST/PUT/DELETE | `/api/dishes/` | 菜品增删改查(`?type=meat/veg/soup/staple` 过滤) | +| POST | `/api/dishes/bulk/` | 批量导入菜品(需管理员登录),支持中文类型名与菜系 | +| POST | `/api/menu/generate/` | 生成 A/B 两套周菜单 | +| POST | `/api/menu/export/` | 将生成的菜单导出为 xlsx(A/B 左右并排,含营养成分) | + +## 批量导入示例 + +```bash +# 管理命令,每行:类型|菜名|配料|蛋白质|脂肪|热量|菜系 +.venv\Scripts\python backend\manage.py import_dishes dishes.txt + +# 或调用 API(需先登录 admin) +curl -u admin:admin123 -H "Content-Type: application/json" \ + -d '{"dishes":[{"name":"清蒸鲈鱼","dish_type":"荤菜","cuisine":"粤菜","protein":16,"fat":5,"calorie":140}]}' \ + http://127.0.0.1:8000/api/dishes/bulk/ +``` diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..e45e3d6 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +db.sqlite3 +staticfiles/ +.venv/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..dea5e67 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["sh", "-c", "\ + python manage.py migrate --noinput && \ + python manage.py seed_dishes && \ + python manage.py collectstatic --noinput && \ + python manage.py shell -c \"from django.contrib.auth import get_user_model; U=get_user_model(); U.objects.filter(username='admin').exists() or U.objects.create_superuser('admin','admin@example.com','admin123')\" && \ + gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 60"] diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/config/asgi.py b/backend/config/asgi.py new file mode 100644 index 0000000..ffbb5f5 --- /dev/null +++ b/backend/config/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for config project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_asgi_application() diff --git a/backend/config/settings.py b/backend/config/settings.py new file mode 100644 index 0000000..e0fb5b1 --- /dev/null +++ b/backend/config/settings.py @@ -0,0 +1,143 @@ +""" +Django settings for config project. + +Generated by 'django-admin startproject' using Django 6.0.8. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/6.0/ref/settings/ +""" + +from pathlib import Path +import os + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'django-insecure-xnp0_=m=#5%7dwqib5d+u=1v42dm+!tm7yl-%(#p_+)rspcb#0') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.environ.get('DJANGO_DEBUG', 'True') == 'True' + +ALLOWED_HOSTS = [h.strip() for h in os.environ.get('DJANGO_ALLOWED_HOSTS', '*').split(',')] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'rest_framework', + 'corsheaders', + 'meals', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/6.0/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.environ.get('DJANGO_DB_PATH', str(BASE_DIR / 'db.sqlite3')), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/6.0/topics/i18n/ + +LANGUAGE_CODE = 'zh-hans' + +TIME_ZONE = 'Asia/Shanghai' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/6.0/howto/static-files/ + +STATIC_URL = 'static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' + +CORS_ALLOWED_ORIGINS = [ + 'http://localhost:5173', + 'http://127.0.0.1:5173', + 'http://xx.mymoyu.top', + 'http://xxcdn.mymoyu.top', + 'https://xx.mymoyu.top', + 'https://xxcdn.mymoyu.top', +] + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.BasicAuthentication', + ], +} + +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', +).split(',')] diff --git a/backend/config/urls.py b/backend/config/urls.py new file mode 100644 index 0000000..41a344b --- /dev/null +++ b/backend/config/urls.py @@ -0,0 +1,23 @@ +""" +URL configuration for config project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/6.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path('admin/', admin.site.urls), + path('api/', include('meals.urls')), +] diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py new file mode 100644 index 0000000..4ced574 --- /dev/null +++ b/backend/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for config project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..8e7ac79 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/backend/meals/__init__.py b/backend/meals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/meals/admin.py b/backend/meals/admin.py new file mode 100644 index 0000000..f7e9bea --- /dev/null +++ b/backend/meals/admin.py @@ -0,0 +1,28 @@ +from django.contrib import admin + +from .models import Dish + + +@admin.action(description='启用所选菜品(参与自动生成)') +def activate_dishes(modeladmin, request, queryset): + queryset.update(is_active=True) + + +@admin.action(description='停用所选菜品(不再参与自动生成)') +def deactivate_dishes(modeladmin, request, queryset): + queryset.update(is_active=False) + + +@admin.register(Dish) +class DishAdmin(admin.ModelAdmin): + list_display = ('name', 'cuisine', 'dish_type', 'ingredient_detail', 'protein', 'fat', 'calorie', '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') + list_per_page = 50 + actions = [activate_dishes, deactivate_dishes] + fieldsets = ( + (None, {'fields': ('name', 'dish_type', 'cuisine', 'is_active')}), + ('营养信息', {'fields': ('ingredient_detail', 'protein', 'fat', 'calorie')}), + ('其他', {'fields': ('description',), 'classes': ('collapse',)}), + ) diff --git a/backend/meals/apps.py b/backend/meals/apps.py new file mode 100644 index 0000000..832619c --- /dev/null +++ b/backend/meals/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class MealsConfig(AppConfig): + name = 'meals' diff --git a/backend/meals/management/commands/calibrate_nutrition.py b/backend/meals/management/commands/calibrate_nutrition.py new file mode 100644 index 0000000..9ebae23 --- /dev/null +++ b/backend/meals/management/commands/calibrate_nutrition.py @@ -0,0 +1,234 @@ +"""按中国食物成分表校准菜品营养成分。 + +原理:解析每道菜配料明细中的食材与克数,乘以对应食材营养密度(每 100g), +再按烹饪方式(炒/红烧/油炸/蒸煮)修正油脂与糖的用量,得到更符合实际的值。 +用法:python manage.py calibrate_nutrition [--apply] [--export-seed] +""" + +import re + +from django.core.management.base import BaseCommand + +from meals.models import Dish + +# 每 100g 可食部:蛋白质g, 脂肪g, 热量kcal(参考中国食物成分表/常见值) +NUTRITION = { + '五花肉': (9.5, 35.0, 400), '猪五花': (9.5, 35.0, 400), + '梅花肉': (20.0, 8.0, 150), '里脊肉': (20.3, 6.2, 143), '瘦肉': (20.3, 6.2, 143), + '肉丝': (20.0, 7.0, 150), '肉片': (20.0, 8.0, 155), '肉糜': (18.0, 12.0, 180), + '肉馅': (18.0, 12.0, 180), '肉末': (18.0, 12.0, 180), + '猪大排': (12.6, 12.6, 168), '大排': (12.6, 12.6, 168), + '肋排': (12.8, 14.3, 185), '排骨': (12.8, 14.3, 185), '小排': (12.8, 14.3, 185), + '汤骨': (15.0, 15.0, 220), '牛腩': (20.0, 8.0, 150), '牛肉': (20.2, 4.2, 125), + '羊肉': (19.0, 14.1, 203), '鸡胸': (24.0, 1.5, 118), '鸡丁': (19.0, 6.0, 140), + '鸡块': (19.0, 8.0, 150), '鸡肉': (19.3, 9.4, 167), '鸡腿': (14.0, 9.0, 150), + '鸡翅': (17.4, 11.8, 194), '烤翅': (19.0, 16.0, 240), '鸭腿': (16.0, 18.0, 230), + '鸭块': (15.5, 19.7, 240), '鸭肉': (15.5, 19.7, 240), + '鱼片': (17.0, 4.0, 110), '鱼块': (16.6, 5.2, 113), '草鱼': (16.6, 5.2, 113), + '鲈鱼': (18.6, 3.4, 105), '鲫鱼': (17.1, 2.7, 108), + '基围虾': (18.6, 0.8, 93), '虾仁': (18.6, 0.8, 93), '虾皮': (30.7, 2.2, 153), + '鸡蛋': (13.3, 8.8, 144), '咸蛋黄': (16.0, 30.0, 330), '皮蛋': (14.2, 10.7, 171), + '豆腐': (8.1, 3.7, 81), '素鸡': (16.5, 12.5, 192), '烤麸': (22.5, 0.7, 121), + '百叶结': (24.5, 9.0, 205), '油面筋': (17.1, 17.6, 244), '油方': (17.1, 17.6, 244), + '腐竹': (44.6, 21.7, 459), '粉丝': (0.8, 0.2, 338), '花生米': (24.1, 44.4, 574), + '花生': (24.1, 44.4, 574), '贡丸': (12.0, 12.0, 200), '红肠': (12.0, 15.0, 220), + '烤肠': (10.0, 20.0, 230), + '土豆': (2.0, 0.2, 77), '红薯': (1.1, 0.2, 90), '南瓜': (0.7, 0.1, 23), + '山药': (1.9, 0.2, 57), '玉米棒': (4.0, 1.2, 112), '玉米': (4.0, 1.2, 112), + '冬瓜': (0.4, 0.2, 12), '西葫芦': (0.8, 0.2, 19), '丝瓜': (1.0, 0.2, 20), + '番茄': (0.9, 0.2, 20), '西红柿': (0.9, 0.2, 20), '茄子': (1.1, 0.2, 23), + '青椒': (1.0, 0.2, 22), '辣椒': (1.3, 0.4, 32), '干辣椒': (15.0, 12.0, 212), + '彩椒': (1.0, 0.2, 22), '大白菜': (1.5, 0.1, 17), '白菜': (1.5, 0.1, 17), + '卷心菜': (1.5, 0.2, 22), '牛心菜': (1.5, 0.2, 22), '包菜': (1.5, 0.2, 24), + '娃娃菜': (1.7, 0.3, 15), '青菜': (1.7, 0.3, 15), '菠菜': (2.6, 0.3, 28), + '空心菜': (2.2, 0.3, 20), '菜心': (2.8, 0.5, 22), '生菜': (1.3, 0.3, 15), + '杭白菜': (1.8, 0.4, 18), '茼蒿菜': (1.9, 0.3, 24), '茼蒿': (1.9, 0.3, 24), + '莴笋': (1.0, 0.1, 15), '芦笋': (2.6, 0.2, 22), '西兰花': (4.1, 0.6, 36), + '莲藕': (1.9, 0.2, 73), '藕片': (1.9, 0.2, 73), '藕丁': (1.9, 0.2, 73), + '茭白': (1.2, 0.2, 26), '蚕豆': (8.8, 0.4, 104), '荷兰豆': (2.5, 0.3, 30), + '四季豆': (2.0, 0.4, 31), '豆芽': (2.1, 0.1, 18), '香菇': (2.2, 0.3, 26), + '菌菇': (2.5, 0.3, 25), '木耳': (1.5, 0.2, 27), '紫菜': (26.7, 1.1, 250), + '海带': (1.2, 0.1, 13), '黄瓜': (0.8, 0.2, 16), '洋葱': (1.1, 0.2, 40), + '大葱': (1.7, 0.3, 30), '韭菜': (2.4, 0.4, 26), '胡萝卜': (1.0, 0.2, 39), + '马蹄': (1.2, 0.2, 61), '荸荠': (1.2, 0.2, 61), '酸菜': (1.2, 0.2, 18), + '蒜苗': (2.1, 0.4, 37), '豆豉': (24.0, 8.0, 220), '葱油': (0.0, 100.0, 900), + '笋丝': (2.6, 0.2, 25), '笋': (2.6, 0.2, 25), '芹菜': (1.2, 0.2, 22), + '红枣': (3.2, 0.5, 276), '剁椒': (2.0, 1.0, 40), '荸荠': (1.2, 0.2, 61), + '米饭': (2.6, 0.3, 116), '黑米饭': (3.2, 0.6, 122), '麦片饭': (4.0, 1.2, 130), + '杂粮饭': (3.5, 0.8, 125), '炒面': (4.5, 6.5, 175), '豆沙包': (8.5, 3.0, 220), + '奶黄包': (7.0, 4.0, 200), '小笼包': (9.0, 5.0, 220), '花卷': (7.0, 1.0, 220), + '馒头': (7.0, 1.0, 220), +} + +# 非克单位 → 每单位可食部克数 +UNIT_GRAMS = { + '只': {'鸡翅': 30, '烤翅': 35, '鸡蛋': 50, '蛋': 50, '基围虾': 10, '虾': 10, + '鸡腿': 100, '鸭腿': 100, '油面筋': 20, '油方': 20, '小笼包': 25}, + '根': {'烤肠': 50, '火腿肠': 50}, + '块': {'大排': 70, '排骨': 100, '鱼块': 100, '鸡块': 35, '素鸡': 50, '豆腐': 100}, +} +UNIT_DEFAULT = {'只': 30, '根': 50, '块': 80} + +# 烹饪方式修正(额外油/糖,每份):加 蛋白质g、脂肪g、热量kcal +COOK_OIL = [ + (('油炸', '炸'), (0.0, 12.0, 108.0)), + (('地三鲜', '水煮', '回锅'), (0.0, 8.0, 72.0)), + (('红烧', '糖醋', '蜜汁', '拔丝', '可乐', '鱼香', '酱爆', '酱鸭', '东坡', '焖', '油焖'), (0.0, 5.0, 80.0)), + (('炒', '爆', '干煸', '蒜香', '烤', '焗'), (0.0, 5.0, 45.0)), + (('蒸', '炖', '煮', '白灼', '凉拌', '汤', '清炒', '蒜蓉'), (0.0, 2.0, 18.0)), + (('糖拌',), (0.0, 0.0, 45.0)), + (('塞肉',), (4.5, 3.0, 45.0)), +] + +# 类型合理范围 clamp:(protein, fat, kcal) +CLAMP = { + 'meat': ((4.0, 20.0), (2.0, 28.0), (90.0, 340.0)), + 'veg': ((1.0, 10.0), (0.5, 12.0), (35.0, 170.0)), + 'soup': ((1.5, 7.0), (1.0, 7.0), (30.0, 100.0)), + 'staple': ((2.0, 9.0), (0.5, 10.0), (70.0, 320.0)), +} + + +def _parse_items(text): + """解析 '食材12g食材34g' / '食材2只' → [(食材名, 克数)]""" + items = [] + for m in re.finditer(r'([\u4e00-\u9fa5]{1,10}?)(\d+\.?\d*)g', text): + items.append((m.group(1), float(m.group(2)))) + for m in re.finditer(r'([\u4e00-\u9fa5]{1,10}?)(\d+)(?:-(\d+))?(只|根|块)', text): + name, unit = m.group(1), m.group(4) + n = int(m.group(3) or m.group(2)) + grams = UNIT_GRAMS.get(unit, {}).get(name) + if grams is None: + grams = next((v for k, v in UNIT_GRAMS.get(unit, {}).items() if k in name), UNIT_DEFAULT[unit]) + items.append((name, grams * n)) + return items + + +def _food_nutrition(name): + for key in sorted(NUTRITION, key=len, reverse=True): + if key in name: + return NUTRITION[key] + return None + + +def _cook_fix(name): + for keywords, fix in COOK_OIL: + for k in keywords: + if k in name: + return fix + return (0.0, 3.0, 27.0) + + +def _calc(name, dish_type, text): + items = _parse_items(text) + protein = fat = kcal = 0.0 + matched = 0 + for food, grams in items: + n = _food_nutrition(food) + if n: + matched += 1 + p, f, k = n + protein += p * grams / 100 + fat += f * grams / 100 + kcal += k * grams / 100 + if not matched: + return None + if dish_type != 'staple': + fix = _cook_fix(name) + fat += fix[1] + kcal += fix[2] + (p_min, p_max), (f_min, f_max), (k_min, k_max) = CLAMP[dish_type] + protein = min(max(protein, p_min), p_max) + fat = min(max(fat, f_min), f_max) + kcal = min(max(round(kcal), int(k_min)), int(k_max)) + return round(protein, 1), round(fat, 1), int(kcal) + + +class Command(BaseCommand): + help = '按食物成分表校准菜品营养成分(--apply 写入数据库,--export-seed 重新生成 seed_dishes.py)' + + def add_arguments(self, parser): + parser.add_argument('--apply', action='store_true', help='写入数据库(默认仅预览)') + parser.add_argument('--export-seed', action='store_true', help='重新生成 seed_dishes.py') + + def handle(self, *args, **options): + apply_changes = options['apply'] + updated, unchanged, skipped = [], [], [] + rows = [] + for dish in Dish.objects.filter(is_active=True).order_by('dish_type', 'cuisine', 'id'): + result = _calc(dish.name, dish.dish_type, dish.ingredient_detail or '') + if result is None: + skipped.append(dish.name) + continue + p, f, k = result + old = (float(dish.protein), float(dish.fat), int(dish.calorie)) + rows.append((dish, old, result)) + if abs(old[0] - p) < 0.05 and abs(old[1] - f) < 0.05 and old[2] == k: + unchanged.append(dish.name) + continue + updated.append(dish.name) + self.stdout.write( + f'{dish.name}: 蛋白 {old[0]}→{p} | 脂肪 {old[1]}→{f} | 热量 {old[2]}→{k}' + ) + + self.stdout.write(self.style.WARNING(f'\n将更新 {len(updated)} 道,不变 {len(unchanged)} 道,无法解析 {len(skipped)} 道')) + if skipped: + self.stdout.write('无法解析(保留原值): ' + '、'.join(skipped)) + + if apply_changes: + for dish, _, (p, f, k) in rows: + dish.protein, dish.fat, dish.calorie = p, f, k + dish.save(update_fields=['protein', 'fat', 'calorie']) + self.stdout.write(self.style.SUCCESS('已写入数据库')) + + if options['export_seed']: + self._export_seed(rows, skipped) + + def _export_seed(self, rows, skipped): + path = __file__.replace('calibrate_nutrition.py', 'seed_dishes.py') + grouped = {} + for dish, _, (p, f, k) in rows: + grouped.setdefault(dish.dish_type, []).append(dish) + lines = [] + lines.append('from django.core.management.base import BaseCommand') + lines.append('') + lines.append('from meals.models import Dish') + lines.append('') + lines.append('# (菜名, 菜系, 配料明细, 蛋白质g, 脂肪g, 热量kcal)') + lines.append('SAMPLE_DISHES = {') + for t in ('meat', 'veg', 'soup', 'staple'): + dishes = [d for d in grouped.get(t, []) if d.name not in skipped] + lines.append(f" '{t}': [") + for d in dishes: + lines.append( + f" ('{d.name}', '{d.cuisine}', '{d.ingredient_detail}', {d.protein}, {d.fat}, {d.calorie})," + ) + lines.append(' ],') + lines.append('}') + tail = ''' + +class Command(BaseCommand): + help = '写入示例菜品数据(含菜系)' + + def handle(self, *args, **options): + count = 0 + for dish_type, items in SAMPLE_DISHES.items(): + for name, cuisine, ingredient, protein, fat, calorie in items: + _, created = Dish.objects.update_or_create( + name=name, + defaults={ + 'dish_type': dish_type, + 'cuisine': cuisine, + 'ingredient_detail': ingredient, + 'protein': protein, + 'fat': fat, + 'calorie': calorie, + }, + ) + if created: + count += 1 + self.stdout.write(self.style.SUCCESS(f'示例菜品已就绪(新增 {count} 道,库中共 {Dish.objects.count()} 道)')) +''' + with open(path, 'w', encoding='utf-8') as f: + f.write('\n'.join(lines) + tail) + self.stdout.write(self.style.SUCCESS(f'seed_dishes.py 已重新生成:{path}')) diff --git a/backend/meals/management/commands/import_dishes.py b/backend/meals/management/commands/import_dishes.py new file mode 100644 index 0000000..6cb4067 --- /dev/null +++ b/backend/meals/management/commands/import_dishes.py @@ -0,0 +1,53 @@ +from django.core.management.base import BaseCommand + +from meals.models import Dish + +TYPE_MAP = {'荤菜': 'meat', '素菜': 'veg', '汤': 'soup', '主食': 'staple'} +TYPE_MAP_REV = {v: k for k, v in TYPE_MAP.items()} + + +class Command(BaseCommand): + help = '从文本文件批量导入菜品。每行格式:类型|菜名|配料明细|蛋白质|脂肪|热量|菜系 (类型:荤菜/素菜/汤/主食,营养与菜系可省略)' + + def add_arguments(self, parser): + parser.add_argument('file', help='菜品文本文件路径') + + def handle(self, *args, **options): + path = options['file'] + created, skipped = 0, 0 + with open(path, encoding='utf-8-sig') as f: + for line_no, line in enumerate(f, 1): + line = line.strip() + if not line or line.startswith('#'): + continue + parts = [p.strip() for p in line.split('|')] + if len(parts) < 2: + self.stderr.write(f'第 {line_no} 行格式错误,已跳过: {line}') + skipped += 1 + continue + label, name = parts[0], parts[1] + dish_type = TYPE_MAP.get(label) + if not dish_type or not name: + self.stderr.write(f'第 {line_no} 行类型或菜名无效,已跳过: {line}') + skipped += 1 + continue + ingredient = parts[2] if len(parts) > 2 else '' + protein = float(parts[3]) if len(parts) > 3 and parts[3] else 0 + fat = float(parts[4]) if len(parts) > 4 and parts[4] else 0 + calorie = int(float(parts[5])) if len(parts) > 5 and parts[5] else 0 + cuisine = parts[6] if len(parts) > 6 and parts[6] else '家常菜' + dish, is_created = Dish.objects.update_or_create( + name=name, + defaults={ + 'dish_type': dish_type, + 'cuisine': cuisine, + 'ingredient_detail': ingredient, + 'protein': protein, + 'fat': fat, + 'calorie': calorie, + }, + ) + if is_created: + created += 1 + self.stdout.write(f'新增: {label} - {name}({cuisine})') + self.stdout.write(self.style.SUCCESS(f'导入完成:新增 {created} 道,跳过 {skipped} 条')) diff --git a/backend/meals/management/commands/seed_dishes.py b/backend/meals/management/commands/seed_dishes.py new file mode 100644 index 0000000..81c0524 --- /dev/null +++ b/backend/meals/management/commands/seed_dishes.py @@ -0,0 +1,165 @@ +from django.core.management.base import BaseCommand + +from meals.models import Dish + +# (菜名, 菜系, 配料明细, 蛋白质g, 脂肪g, 热量kcal) +SAMPLE_DISHES = { + 'meat': [ + ('可乐鸡翅', '家常菜', '鸡翅2只', 10.4, 12.1, 196), + ('奥尔良烤翅', '家常菜', '烤翅2只', 13.3, 16.2, 213), + ('红烧三角油方塞肉', '家常菜', '三角油方1只', 4.0, 8.5, 129), + ('牛心菜炒五花肉片', '家常菜', '牛心菜85g五花肉片15g', 4.0, 10.4, 124), + ('五花肉烧百叶结', '家常菜', '五花肉85g百叶结20g', 13.0, 28.0, 340), + ('火山石烤肠', '家常菜', '烤肠1根', 5.0, 15.0, 160), + ('肉糜蒸蛋', '家常菜', '肉糜65g鸡蛋25g', 15.0, 12.0, 171), + ('蚝油牛肉片', '家常菜', '牛肉片85g洋葱50g', 17.6, 9.9, 179), + ('原味鸡块', '家常菜', '鸡块2块', 13.3, 8.6, 132), + ('青椒茭白肉丝', '家常菜', '青椒5g茭白75g肉丝15g', 4.0, 4.2, 90), + ('蒜香骨', '家常菜', '肋排100g', 12.8, 19.3, 230), + ('番茄牛腩', '家常菜', '牛腩80g番茄70g', 16.6, 9.5, 161), + ('土豆烧鸡块', '家常菜', '鸡块100g土豆60g', 20.0, 11.1, 223), + ('青椒肉丝', '家常菜', '肉丝70g青椒40g', 14.4, 8.0, 141), + ('洋葱炒肉片', '家常菜', '肉片70g洋葱50g', 14.6, 10.7, 174), + ('香菇滑鸡', '家常菜', '鸡块110g香菇20g', 20.0, 11.9, 197), + ('冬瓜烧排骨', '家常菜', '排骨80g冬瓜60g', 10.5, 14.6, 182), + ('家常回锅肉', '家常菜', '五花肉80g蒜苗30g', 8.2, 28.0, 340), + ('木须肉', '家常菜', '肉片50g鸡蛋30g木耳10g黄瓜20g', 14.3, 9.7, 154), + ('红烧鸡腿', '家常菜', '鸡腿1只', 14.0, 14.0, 230), + ('玉米炖排骨', '家常菜', '排骨80g玉米70g', 13.0, 14.3, 244), + ('啤酒鸭', '家常菜', '鸭块120g青椒30g', 18.9, 26.7, 322), + ('宫保鸡丁', '川菜', '鸡丁80g花生米20g', 20.0, 16.7, 254), + ('鱼香肉丝', '川菜', '肉丝70g笋丝30g', 14.8, 10.0, 192), + ('辣子鸡丁', '川菜', '鸡丁100g干辣椒10g', 20.0, 10.2, 188), + ('水煮肉片', '川菜', '肉片90g豆芽40g', 18.8, 15.2, 219), + ('酸菜鱼', '川菜', '鱼片100g酸菜50g', 17.6, 7.1, 146), + ('回锅肉', '川菜', '五花肉85g蒜苗25g', 8.6, 28.0, 340), + ('蚂蚁上树', '川菜', '粉丝50g肉末30g', 5.8, 6.7, 250), + ('鱼香茄子煲', '川菜', '茄子100g肉末25g', 5.6, 8.2, 148), + ('红烧肉', '本帮菜', '五花肉100g', 9.5, 28.0, 340), + ('糖醋排骨', '本帮菜', '肋排100g', 12.8, 19.3, 265), + ('红烧狮子头', '本帮菜', '肉糜80g马蹄20g', 14.6, 14.6, 236), + ('油面筋塞肉', '本帮菜', '油面筋2只肉馅50g', 15.8, 16.0, 233), + ('酱鸭', '本帮菜', '鸭腿150g', 20.0, 28.0, 340), + ('葱油鸡', '本帮菜', '鸡腿150g葱油10g', 20.0, 26.5, 340), + ('熏鱼', '本帮菜', '草鱼块100g', 16.6, 8.2, 140), + ('百叶结烧肉', '本帮菜', '五花肉80g百叶结30g', 14.9, 28.0, 340), + ('红烧基围虾', '江浙菜', '基围虾3-4只', 7.4, 5.3, 117), + ('白灼虾', '江浙菜', '基围虾4只', 7.4, 2.3, 90), + ('清蒸狮子头', '江浙菜', '肉糜80g荸荠20g', 14.6, 11.6, 174), + ('西湖醋鱼', '江浙菜', '草鱼块120g', 19.9, 9.2, 163), + ('东坡肉', '江浙菜', '五花肉90g', 8.6, 28.0, 340), + ('小炒黄牛肉', '湘菜', '牛肉片80g芹菜30g', 16.4, 11.5, 176), + ('农家小炒肉', '湘菜', '五花肉70g青椒50g', 7.2, 28.0, 336), + ('辣椒炒肉', '湘菜', '瘦肉60g青椒60g', 12.8, 8.8, 144), + ('剁椒鱼块', '湘菜', '鱼块110g剁椒15g', 18.6, 8.9, 157), + ('白切鸡', '粤菜', '鸡腿150g姜葱适量', 20.0, 16.5, 252), + ('豉汁蒸排骨', '粤菜', '肋排100g豆豉5g', 14.0, 16.7, 214), + ('清蒸鲈鱼', '粤菜', '鲈鱼块120g', 19.9, 8.2, 154), + ('蒜蓉粉丝蒸虾', '粤菜', '基围虾4只粉丝20g', 7.6, 2.4, 123), + ('蜜汁叉烧', '粤菜', '梅花肉100g', 20.0, 13.0, 230), + ('蜜汁鸡腿', '粤菜', '鸡腿1只', 14.0, 14.0, 230), + ('虾仁滑蛋', '粤菜', '虾仁50g鸡蛋60g', 17.3, 8.7, 160), + ('大盘鸡', '西北菜', '鸡块110g土豆60g', 20.0, 11.9, 238), + ('孜然羊肉', '西北菜', '羊肉片90g洋葱40g', 18.4, 10.3, 182), + ('土豆炖牛肉', '西北菜', '牛肉80g土豆70g', 17.6, 5.5, 172), + ('糖醋里脊', '鲁菜', '里脊肉80g', 16.2, 10.0, 194), + ('酱爆鸡丁', '鲁菜', '鸡丁90g黄瓜30g', 17.3, 10.5, 211), + ('木须肉片', '鲁菜', '肉片50g鸡蛋25g木耳15g', 13.5, 9.2, 145), + ('红烧大排', '鲁菜', '猪大排1块', 8.8, 13.8, 198), + ('葱爆牛肉', '鲁菜', '牛肉片80g大葱50g', 16.9, 11.6, 184), + ], + 'veg': [ + ('醋溜土豆丝', '家常菜', '土豆丝100g', 2.0, 3.2, 104), + ('香菇青菜', '家常菜', '香菇20g青菜80g', 1.8, 3.3, 44), + ('酸辣白菜', '家常菜', '白菜100g', 1.5, 3.1, 44), + ('清炒莴笋', '家常菜', '莴笋100g', 1.0, 5.1, 60), + ('手撕包菜', '家常菜', '包菜100g', 1.5, 3.2, 51), + ('韭菜炒蛋', '家常菜', '韭菜80g鸡蛋20g', 4.6, 7.1, 95), + ('西红柿炒蛋', '家常菜', '西红柿80g鸡蛋20g', 3.4, 6.9, 90), + ('蒜泥茼蒿菜', '家常菜', '茼蒿菜100g', 1.9, 3.3, 51), + ('炒什锦', '家常菜', '莴笋60g油面筋10g胡萝卜10g肉片15g', 5.4, 8.0, 106), + ('蒜泥杭白菜', '家常菜', '杭白菜100g', 1.8, 3.4, 45), + ('虾皮西葫芦', '家常菜', '西葫芦100g虾皮5g', 2.3, 3.3, 54), + ('彩椒炒鸡蛋', '家常菜', '彩椒80g鸡蛋20g', 3.5, 6.9, 91), + ('麻婆豆腐', '川菜', '豆腐100g肉末15g', 10.0, 8.5, 135), + ('鱼香茄子', '川菜', '茄子100g肉末15g', 3.8, 7.0, 130), + ('干煸四季豆', '川菜', '四季豆100g肉末10g', 3.8, 6.6, 94), + ('虎皮青椒', '川菜', '青椒100g', 1.0, 3.2, 49), + ('凉拌木耳', '川菜', '木耳50g黄瓜50g', 1.1, 2.2, 40), + ('酸辣藕丁', '川菜', '莲藕100g', 1.9, 3.2, 100), + ('红烧素鸡', '本帮菜', '汉康素鸡1块', 8.2, 11.2, 170), + ('四喜烤麸', '本帮菜', '烤麸60g香菇15g木耳15g花生10g', 10.0, 9.9, 170), + ('油焖茭白', '本帮菜', '茭白100g', 1.2, 5.2, 106), + ('葱油蚕豆', '本帮菜', '蚕豆100g', 8.8, 3.4, 131), + ('蒜泥西兰花', '江浙菜', '西兰花100g', 4.1, 3.6, 63), + ('清炒芦笋', '江浙菜', '芦笋100g', 2.6, 5.2, 67), + ('荷兰豆炒藕片', '江浙菜', '荷兰豆50g藕片50g', 2.2, 5.2, 96), + ('糖拌西红柿', '江浙菜', '西红柿150g', 1.4, 0.5, 75), + ('剁椒蒸豆腐', '湘菜', '豆腐100g剁椒10g', 8.3, 5.8, 103), + ('湘味藕片', '湘菜', '莲藕100g', 1.9, 3.2, 100), + ('白灼菜心', '粤菜', '菜心100g', 2.8, 2.5, 40), + ('蚝油生菜', '粤菜', '生菜100g', 1.3, 3.3, 42), + ('蒜蓉空心菜', '粤菜', '空心菜100g', 2.2, 2.3, 38), + ('上汤娃娃菜', '粤菜', '娃娃菜100g皮蛋10g', 3.1, 3.4, 50), + ('虾皮冬瓜', '粤菜', '冬瓜100g虾皮5g', 1.9, 3.3, 47), + ('韭菜炒豆芽', '西北菜', '豆芽80g韭菜20g', 2.2, 5.2, 65), + ('番茄炒包菜', '西北菜', '包菜70g番茄30g', 1.3, 5.2, 68), + ('蒜香茄子', '西北菜', '茄子100g', 1.1, 5.2, 68), + ('红枣南瓜', '面点小吃', '南瓜100g红枣5g', 1.0, 3.1, 64), + ('蛋黄焗南瓜', '面点小吃', '南瓜100g咸蛋黄15g', 3.1, 9.6, 118), + ('拔丝红薯', '面点小吃', '红薯100g', 1.1, 5.2, 170), + ('地三鲜', '鲁菜', '土豆50g茄子50g青椒20g', 1.8, 8.2, 126), + ('醋溜白菜', '鲁菜', '白菜100g', 1.5, 3.1, 44), + ('蒜蓉菠菜', '鲁菜', '菠菜100g', 2.6, 2.3, 46), + ('家常豆腐', '鲁菜', '豆腐100g青椒20g', 8.3, 6.7, 112), + ('清炒山药', '鲁菜', '山药100g木耳10g', 2.0, 5.2, 105), + ], + 'soup': [ + ('番茄蛋汤', '家常菜', '西红柿20g鸡蛋10g', 1.5, 2.9, 36), + ('冬瓜排骨汤', '家常菜', '冬瓜25g排骨15g', 2.0, 4.2, 49), + ('大白菜贡丸汤', '家常菜', '大白菜20g贡丸20g', 2.7, 4.4, 61), + ('小排玉米棒胡萝卜汤', '家常菜', '小排20g玉米棒15g胡萝卜5g', 3.2, 5.0, 74), + ('紫菜虾皮蛋汤', '家常菜', '紫菜5g虾皮1g鸡蛋10g', 3.0, 3.0, 46), + ('汤骨海带丝汤', '家常菜', '汤骨15g海带丝15g', 2.4, 4.3, 53), + ('罗宋汤', '家常菜', '卷心菜15g土豆20g西红柿10g红肠5g', 1.5, 2.8, 50), + ('番茄菌菇汤', '家常菜', '西红柿20g菌菇20g', 1.5, 2.1, 30), + ('丝瓜蛋汤', '家常菜', '丝瓜25g鸡蛋10g', 1.6, 2.9, 37), + ('山药排骨汤', '江浙菜', '山药25g排骨15g', 2.4, 4.2, 60), + ('莲藕排骨汤', '江浙菜', '莲藕25g排骨15g', 2.4, 4.2, 64), + ('鲫鱼豆腐汤', '江浙菜', '鲫鱼20g豆腐15g', 4.6, 3.1, 52), + ], + 'staple': [ + ('米饭', '面点小吃', '米饭150g', 3.9, 0.5, 174), + ('黑米饭', '面点小吃', '黑米饭150g', 4.8, 0.9, 183), + ('麦片饭', '面点小吃', '麦片饭150g', 6.0, 1.8, 195), + ('炒面', '面点小吃', '炒面150g', 6.8, 9.8, 262), + ('豆沙包', '面点小吃', '豆沙包30g', 2.5, 0.9, 70), + ('杂粮饭', '面点小吃', '杂粮饭150g', 5.2, 1.2, 188), + ('奶黄包', '面点小吃', '奶黄包30g', 2.1, 1.2, 70), + ('小笼包', '面点小吃', '小笼包3只', 6.8, 3.8, 165), + ('花卷', '面点小吃', '花卷50g', 3.5, 0.5, 110), + ('黄金馒头', '面点小吃', '馒头50g', 3.5, 0.5, 110), + ], +} + +class Command(BaseCommand): + help = '写入示例菜品数据(含菜系)' + + def handle(self, *args, **options): + count = 0 + for dish_type, items in SAMPLE_DISHES.items(): + for name, cuisine, ingredient, protein, fat, calorie in items: + _, created = Dish.objects.update_or_create( + name=name, + defaults={ + 'dish_type': dish_type, + 'cuisine': cuisine, + 'ingredient_detail': ingredient, + 'protein': protein, + 'fat': fat, + 'calorie': calorie, + }, + ) + if created: + count += 1 + self.stdout.write(self.style.SUCCESS(f'示例菜品已就绪(新增 {count} 道,库中共 {Dish.objects.count()} 道)')) diff --git a/backend/meals/migrations/0001_initial.py b/backend/meals/migrations/0001_initial.py new file mode 100644 index 0000000..bbdf981 --- /dev/null +++ b/backend/meals/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0.8 on 2026-08-05 05:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Dish', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True, verbose_name='菜名')), + ('dish_type', models.CharField(choices=[('meat', '荤菜'), ('veg', '素菜'), ('soup', '汤'), ('staple', '主食')], max_length=10, verbose_name='类型')), + ('description', models.CharField(blank=True, max_length=200, 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': ['dish_type', 'id'], + }, + ), + ] diff --git a/backend/meals/migrations/0002_dish_ingredient_detail.py b/backend/meals/migrations/0002_dish_ingredient_detail.py new file mode 100644 index 0000000..beddc54 --- /dev/null +++ b/backend/meals/migrations/0002_dish_ingredient_detail.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.8 on 2026-08-05 05:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('meals', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='dish', + name='ingredient_detail', + field=models.CharField(blank=True, help_text='例如:牛心菜85g五花肉片15g', max_length=200, verbose_name='配料明细'), + ), + ] diff --git a/backend/meals/migrations/0003_dish_calorie_dish_fat_dish_protein.py b/backend/meals/migrations/0003_dish_calorie_dish_fat_dish_protein.py new file mode 100644 index 0000000..4cef5d0 --- /dev/null +++ b/backend/meals/migrations/0003_dish_calorie_dish_fat_dish_protein.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.8 on 2026-08-05 05:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('meals', '0002_dish_ingredient_detail'), + ] + + operations = [ + migrations.AddField( + model_name='dish', + name='calorie', + field=models.IntegerField(default=0, verbose_name='热量(kcal)'), + ), + migrations.AddField( + model_name='dish', + name='fat', + field=models.DecimalField(decimal_places=1, default=0, max_digits=6, verbose_name='脂肪(g)'), + ), + migrations.AddField( + model_name='dish', + name='protein', + field=models.DecimalField(decimal_places=1, default=0, max_digits=6, verbose_name='蛋白质(g)'), + ), + ] diff --git a/backend/meals/migrations/0004_dish_cuisine.py b/backend/meals/migrations/0004_dish_cuisine.py new file mode 100644 index 0000000..7e71eae --- /dev/null +++ b/backend/meals/migrations/0004_dish_cuisine.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.8 on 2026-08-05 13:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('meals', '0003_dish_calorie_dish_fat_dish_protein'), + ] + + operations = [ + migrations.AddField( + model_name='dish', + name='cuisine', + field=models.CharField(blank=True, choices=[('家常菜', '家常菜'), ('本帮菜', '本帮菜'), ('江浙菜', '江浙菜'), ('川菜', '川菜'), ('粤菜', '粤菜'), ('鲁菜', '鲁菜'), ('湘菜', '湘菜'), ('西北菜', '西北菜'), ('面点小吃', '面点小吃')], default='家常菜', max_length=20, verbose_name='菜系'), + ), + ] diff --git a/backend/meals/migrations/__init__.py b/backend/meals/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/meals/models.py b/backend/meals/models.py new file mode 100644 index 0000000..5f9035b --- /dev/null +++ b/backend/meals/models.py @@ -0,0 +1,40 @@ +from django.db import models + + +class Dish(models.Model): + TYPE_CHOICES = [ + ('meat', '荤菜'), + ('veg', '素菜'), + ('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='家常菜') + 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) + calorie = models.IntegerField('热量(kcal)', default=0) + description = models.CharField('备注', max_length=200, blank=True) + is_active = models.BooleanField('启用', default=True) + created_at = models.DateTimeField('创建时间', auto_now_add=True) + + class Meta: + ordering = ['dish_type', 'id'] + verbose_name = '菜品' + verbose_name_plural = '菜品' + + def __str__(self): + return f'{self.get_dish_type_display()}: {self.name}' diff --git a/backend/meals/serializers.py b/backend/meals/serializers.py new file mode 100644 index 0000000..234a2d2 --- /dev/null +++ b/backend/meals/serializers.py @@ -0,0 +1,11 @@ +from rest_framework import serializers + +from .models import Dish + + +class DishSerializer(serializers.ModelSerializer): + dish_type_display = serializers.CharField(source='get_dish_type_display', 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'] diff --git a/backend/meals/tests.py b/backend/meals/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/meals/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/meals/urls.py b/backend/meals/urls.py new file mode 100644 index 0000000..527e30b --- /dev/null +++ b/backend/meals/urls.py @@ -0,0 +1,14 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from . import views + +router = DefaultRouter() +router.register('dishes', views.DishViewSet) + +urlpatterns = [ + path('dishes/bulk/', views.bulk_create_dishes, name='bulk_create_dishes'), + path('', include(router.urls)), + path('menu/generate/', views.generate_menu, name='generate_menu'), + path('menu/export/', views.export_excel, name='export_excel'), +] diff --git a/backend/meals/views.py b/backend/meals/views.py new file mode 100644 index 0000000..7849bd6 --- /dev/null +++ b/backend/meals/views.py @@ -0,0 +1,422 @@ +import random +from datetime import date, timedelta + +from django.http import HttpResponse +from openpyxl import Workbook +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side +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.response import Response + +from .models import Dish +from .serializers import DishSerializer + +WEEKDAYS = ['周一', '周二', '周三', '周四', '周五'] +SCHOOL_NAME = '上海外国语大学附属宝山双语学校' + +COLOR_HEADER = PatternFill('solid', fgColor='2F5597') +COLOR_DAY = PatternFill('solid', fgColor='D9E2F3') +COLOR_MEAT = PatternFill('solid', fgColor='FCE4D6') +COLOR_VEG = PatternFill('solid', fgColor='E2EFDA') +COLOR_STAPLE = PatternFill('solid', fgColor='FDE9D9') +COLOR_SOUP = PatternFill('solid', fgColor='DDEBF7') +THIN = Side(style='thin', color='808080') +BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN) +CENTER = Alignment(horizontal='center', vertical='center', wrap_text=True) + + +class DishViewSet(viewsets.ModelViewSet): + queryset = Dish.objects.all() + serializer_class = DishSerializer + + def get_queryset(self): + qs = Dish.objects.all() + dish_type = self.request.query_params.get('type') + if dish_type: + qs = qs.filter(dish_type=dish_type) + return qs + + +TYPE_LABEL_MAP = {'荤菜': 'meat', '素菜': 'veg', '汤': 'soup', '主食': 'staple'} + + +@api_view(['POST']) +@permission_classes([IsAdminUser]) +def bulk_create_dishes(request): + items = request.data.get('dishes') + if not isinstance(items, list) or not items: + return Response({'detail': '请提供 dishes 列表'}, status=status.HTTP_400_BAD_REQUEST) + + created, skipped = [], [] + for raw in items: + if not isinstance(raw, dict): + continue + name = str(raw.get('name', '')).strip() + dish_type = str(raw.get('dish_type', '')).strip() + dish_type = TYPE_LABEL_MAP.get(dish_type, dish_type) + if not name or dish_type not in ('meat', 'veg', 'soup', 'staple'): + skipped.append(name or '未命名') + continue + defaults = { + 'dish_type': dish_type, + 'cuisine': str(raw.get('cuisine', '家常菜') or '家常菜'), + '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), + 'calorie': int(raw.get('calorie', 0) or 0), + 'is_active': bool(raw.get('is_active', True)), + } + dish, was_created = Dish.objects.update_or_create(name=name, defaults=defaults) + (created if was_created else skipped).append(name) + + return Response({ + 'detail': f'新增 {len(created)} 道,更新/跳过 {len(skipped)} 道', + 'created': created, + 'skipped': skipped, + }) + + +def _pick_unique(pool, count): + picked = random.sample(pool, min(count, len(pool))) + remaining = [d for d in pool if d not in picked] + return picked, remaining + + +def _pop_next(pool, shuffle_src): + if not pool: + random.shuffle(shuffle_src) + pool = shuffle_src[:] + return pool.pop(0), pool + + +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, + } + + +def _generate_partial_week(payload, meats, vegs, soups, staples, menu_key): + pools = {'meat': meats[:], 'veg': vegs[:], 'soup': soups[:], 'staple': staples[:]} + src_pools = {'meat': meats, 'veg': vegs, 'soup': soups, 'staple': staples} + for p in pools.values(): + random.shuffle(p) + + day_selected = {} + for s in payload.get('selections') or []: + if s.get('menu', 'a') != menu_key: + continue + day = s.get('day') + slot = s.get('slot') + if day not in WEEKDAYS or slot not in ('meats', 'vegs', 'soup', 'staple'): + continue + dish = Dish.objects.filter(pk=s.get('dish_id'), is_active=True).first() if s.get('dish_id') else None + if not dish: + continue + key = f'{slot}{s.get("idx", 0)}' if slot in ('meats', 'vegs') else slot + day_selected.setdefault(day, {})[key] = dish + + def fill(dish_type, used_ids): + pool = pools[dish_type] + while pool: + d = pool.pop(0) + if d.id not in used_ids: + return d + random.shuffle(src_pools[dish_type]) + pool = pools[dish_type] = src_pools[dish_type][:] + while pool: + d = pool.pop(0) + if d.id not in used_ids: + return d + return None + + week = [] + for day in WEEKDAYS: + sel = day_selected.get(day, {}) + used_ids = {d.id for d in sel.values()} + m0 = sel.get('meats0') or fill('meat', used_ids) + m1 = sel.get('meats1') or fill('meat', used_ids) + v0 = sel.get('vegs0') or fill('veg', used_ids) + v1 = sel.get('vegs1') or fill('veg', used_ids) + soup = sel.get('soup') or fill('soup', used_ids) + staple = sel.get('staple') or fill('staple', used_ids) + week.append({ + 'day': day, + 'meats': [_serialize_dish(d) for d in (m0, m1) if d], + 'vegs': [_serialize_dish(d) for d in (v0, v1) if d], + 'soup': _serialize_dish(soup) if soup else None, + 'staple': _serialize_dish(staple) if staple else None, + }) + return week + + +def generate_week_menu(payload, menu_key='a'): + auto_all = payload.get('auto_all', True) + veg_per_day = 2 # 每天固定 2 个素菜 + with_soup = bool(payload.get('with_soup', True)) + with_staple = bool(payload.get('with_staple', True)) + selected_ids = payload.get('selected_meat_ids', []) + + meats = list(Dish.objects.filter(dish_type='meat', is_active=True)) + vegs = list(Dish.objects.filter(dish_type='veg', is_active=True)) + soups = list(Dish.objects.filter(dish_type='soup', is_active=True)) + staples = list(Dish.objects.filter(dish_type='staple', is_active=True)) + + if payload.get('partial') or payload.get('selections'): + return _generate_partial_week(payload, meats, vegs, soups, staples, menu_key) + + random.shuffle(vegs) + veg_pool = vegs[:] + random.shuffle(soups) + soup_pool = soups[:] + random.shuffle(staples) + staple_pool = staples[:] + random.shuffle(meats) + meat_pool = meats[:] + + if auto_all: + day_meats = None + else: + selected = Dish.objects.filter(id__in=selected_ids, dish_type='meat') + day_meats = list(selected) + [d for d in meats if d not in selected] + day_meats = day_meats[:2] + + week = [] + for day in WEEKDAYS: + if auto_all: + day_meat_list = [] + for _ in range(2): + item, meat_pool = _pop_next(meat_pool, meats) + day_meat_list.append(item) + else: + day_meat_list = day_meats[:] if day_meats else [] + if len(day_meat_list) < 2: + extra, _ = _pick_unique([d for d in meats if d not in day_meat_list], 2 - len(day_meat_list)) + day_meat_list += extra + + day_veg = [] + for _ in range(veg_per_day): + item, veg_pool = _pop_next(veg_pool, vegs) + day_veg.append(item) + + soup = None + if with_soup and soup_pool: + soup, soup_pool = _pop_next(soup_pool, soups) + + staple = None + if with_staple and staple_pool: + staple, staple_pool = _pop_next(staple_pool, staples) + + week.append({ + 'day': day, + 'meats': [{'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} 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, + '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, + '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, + 'protein': float(staple.protein), 'fat': float(staple.fat), 'calorie': staple.calorie} if staple else None, + }) + return week + + +def build_menu_title(label): + today = date.today() + week_no = today.isocalendar().week + monday = today - timedelta(days=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}菜单' + + +@api_view(['POST']) +def generate_menu(request): + 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), + } + return Response(menus) + + +def compute_daily_nutrition(week): + proteins, fats, calories = [], [], [] + for day_menu in week: + items = list(day_menu['meats']) + list(day_menu['vegs']) + if day_menu.get('soup'): + items.append(day_menu['soup']) + if day_menu.get('staple'): + items.append(day_menu['staple']) + proteins.append(round(sum(x.get('protein', 0) or 0 for x in items), 1)) + fats.append(round(sum(x.get('fat', 0) or 0 for x in items), 1)) + calories.append(sum(x.get('calorie', 0) or 0 for x in items)) + return min(proteins), max(fats), min(calories) + + +def nutrition_text(week): + min_p, max_f, min_c = compute_daily_nutrition(week) + return f'营养分析:蛋白质≥{min_p}g 脂肪≤{max_f}g 热量≥{min_c}kcal' + + +def build_week_sheet(ws, week, title, start_col=1): + headers = ['菜式', '主食', '荤菜', '素菜', '营养汤'] + n_cols = len(headers) + off = start_col - 1 + nutrition = nutrition_text(week) + + ws.merge_cells(start_row=1, start_column=start_col, end_row=1, end_column=start_col + n_cols - 1) + cell = ws.cell(row=1, column=start_col, value=title) + cell.font = Font(size=16, bold=True) + cell.alignment = CENTER + ws.row_dimensions[1].height = 36 + + for col, name in enumerate(headers, start=1): + c = ws.cell(row=2, column=col + off, value=name) + c.font = Font(bold=True, color='FFFFFF') + c.fill = COLOR_HEADER + c.alignment = CENTER + c.border = BORDER + ws.row_dimensions[2].height = 26 + + def dish_lines(items): + lines = [] + for d in items: + line = d['name'] + if d.get('ingredient'): + line += f'\n({d["ingredient"]})' + lines.append(line) + return '\n'.join(lines) + + for r, day_menu in enumerate(week, start=3): + values = [day_menu['day']] + values.append(dish_lines([day_menu['staple']]) if day_menu.get('staple') else '') + values.append(dish_lines(day_menu['meats'])) + values.append(dish_lines(day_menu['vegs'])) + values.append(dish_lines([day_menu['soup']]) if day_menu.get('soup') else '') + + for col, val in enumerate(values, start=1): + c = ws.cell(row=r, column=col + off, value=val) + c.border = BORDER + if col == 1: + c.alignment = CENTER + c.fill = COLOR_DAY + c.font = Font(bold=True) + elif col == 2: + c.fill = COLOR_STAPLE + elif col == 3: + c.fill = COLOR_MEAT + elif col == 4: + c.fill = COLOR_VEG + else: + c.fill = COLOR_SOUP + if col > 1: + c.alignment = Alignment(vertical='top', horizontal='center', wrap_text=True) + ws.row_dimensions[r].height = 84 + + nut_row = len(week) + 3 + ws.merge_cells(start_row=nut_row, start_column=start_col, end_row=nut_row, end_column=start_col + n_cols - 1) + nc = ws.cell(row=nut_row, column=start_col, value=nutrition) + nc.font = Font(bold=True, size=11) + nc.alignment = CENTER + nc.fill = PatternFill('solid', fgColor='FFF2CC') + for col in range(off + 1, off + n_cols + 1): + ws.cell(row=nut_row, column=col).border = BORDER + ws.row_dimensions[nut_row].height = 26 + + seen = {} + for day_menu in week: + for d in day_menu['meats'] + day_menu['vegs']: + seen.setdefault(d['id'], d) + dishes_detail = list(seen.values()) + if dishes_detail: + detail_start = nut_row + 2 + ws.merge_cells(start_row=detail_start, start_column=start_col, end_row=detail_start, end_column=start_col + n_cols - 1) + dc = ws.cell(row=detail_start, column=start_col, value='本周菜品营养分析(每份)') + dc.font = Font(size=13, bold=True) + dc.alignment = CENTER + dc.fill = COLOR_HEADER + for col in range(off + 1, off + n_cols + 1): + ws.cell(row=detail_start, column=col).border = BORDER + ws.row_dimensions[detail_start].height = 28 + + detail_headers = ['菜品', '类型', '蛋白质(g)', '脂肪(g)', '热量(kcal)'] + for col, name in enumerate(detail_headers, start=1): + c = ws.cell(row=detail_start + 1, column=col + off, value=name) + c.font = Font(bold=True) + c.alignment = CENTER + c.border = BORDER + c.fill = PatternFill('solid', fgColor='D9E2F3') + ws.row_dimensions[detail_start + 1].height = 22 + + type_map = {'meat': '荤菜', 'veg': '素菜'} + for i, d in enumerate(dishes_detail, start=detail_start + 2): + row_vals = [d['name'], type_map.get(d.get('dish_type', ''), d.get('dish_type', '')), + d['protein'], d['fat'], d['calorie']] + for col, val in enumerate(row_vals, start=1): + c = ws.cell(row=i, column=col + off, value=val) + c.alignment = CENTER + c.border = BORDER + if col == 3 or col == 4: + c.number_format = '0.0' + if d.get('dish_type') == 'meat': + for col in range(off + 1, off + n_cols + 1): + ws.cell(row=i, column=col).fill = COLOR_MEAT + else: + for col in range(off + 1, off + n_cols + 1): + ws.cell(row=i, column=col).fill = COLOR_VEG + ws.row_dimensions[i].height = 20 + total_row = detail_start + 2 + len(dishes_detail) + ws.merge_cells(start_row=total_row, start_column=start_col, end_row=total_row, end_column=start_col + n_cols - 1) + tc = ws.cell(row=total_row, column=start_col, value=nutrition) + tc.font = Font(bold=True, size=11) + tc.alignment = CENTER + tc.fill = PatternFill('solid', fgColor='FFF2CC') + for col in range(off + 1, off + n_cols + 1): + ws.cell(row=total_row, column=col).border = BORDER + ws.row_dimensions[total_row].height = 24 + + widths = {1: 10, 2: 16, 3: 34, 4: 34, 5: 28} + for i in range(n_cols): + ws.column_dimensions[get_column_letter(start_col + i)].width = widths[i + 1] + + return total_row if dishes_detail else nut_row + + +@api_view(['POST']) +def export_excel(request): + wb = Workbook() + ws = wb.active + ws.title = '周菜单' + + menus = request.data.get('menus') + last_row = 0 + if menus and isinstance(menus, list): + labels = {m.get('label', ''): m for m in menus} + a = labels.get('营养A菜单') or labels.get('A') + b = labels.get('营养B菜单') or labels.get('B') + if a and a.get('week'): + last_row = max(last_row, build_week_sheet(ws, a['week'], a.get('title', '营养A菜单'), start_col=1)) + if b and b.get('week'): + last_row = max(last_row, build_week_sheet(ws, b['week'], b.get('title', '营养B菜单'), start_col=7)) + else: + week = request.data.get('week') + if not week: + return Response({'detail': '缺少 week 数据'}, status=status.HTTP_400_BAD_REQUEST) + title = request.data.get('title') or build_menu_title('A') + last_row = build_week_sheet(ws, week, title, start_col=1) + + ws.freeze_panes = 'B3' + ws.print_area = f'A1:K{last_row}' + + response = HttpResponse( + content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + week_no = date.today().isocalendar().week + response['Content-Disposition'] = f'attachment; filename="学校订餐周菜单_第{week_no}周.xlsx"' + wb.save(response) + return response diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..fefb80a --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,5 @@ +Django==6.0.8 +djangorestframework==3.17.1 +django-cors-headers==4.9.0 +openpyxl==3.1.5 +gunicorn==23.0.0 diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..75b8959 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# 学校订餐菜单生成器 - 一键部署脚本 (Ubuntu/Debian) +# 用法: bash deploy.sh <域名> +set -e + +DOMAIN="${1:-_}" +APP_DIR=/opt/school-meal +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "==> [1/8] 安装系统依赖" +export DEBIAN_FRONTEND=noninteractive +apt-get update -y +apt-get install -y python3 python3-venv python3-pip nginx curl + +echo "==> [2/8] 安装 Node.js 20" +if ! command -v node >/dev/null 2>&1; then + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - + apt-get install -y nodejs +fi + +echo "==> [3/8] 放置代码到 $APP_DIR" +mkdir -p "$APP_DIR" +cp -r "$SCRIPT_DIR/backend" "$SCRIPT_DIR/frontend" "$APP_DIR/" + +echo "==> [4/8] 后端环境、迁移与初始化" +cd "$APP_DIR/backend" +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt -q +.venv/bin/python manage.py migrate +.venv/bin/python manage.py seed_dishes +.venv/bin/python manage.py collectstatic --noinput +.venv/bin/python manage.py shell -c " +from django.contrib.auth import get_user_model +U = get_user_model() +if not U.objects.filter(username='admin').exists(): + U.objects.create_superuser('admin', 'admin@example.com', 'admin123') + print('已创建管理员 admin / admin123') +else: + print('管理员 admin 已存在') +" + +echo "==> [5/8] 后端 systemd 服务 (gunicorn)" +cat > /etc/systemd/system/school-meal.service </dev/null || true +systemctl daemon-reload +systemctl enable --now school-meal.service +systemctl restart school-meal.service + +echo "==> [6/8] 前端构建" +cd "$APP_DIR/frontend" +npm install --silent +npm run build + +echo "==> [7/8] 配置 Nginx (域名: $DOMAIN)" +cat > /etc/nginx/sites-available/school-meal < [8/8] 验证" +sleep 2 +curl -s -o /dev/null -w "后端 API: HTTP %{http_code}\n" http://127.0.0.1:8000/api/dishes/ +curl -s -o /dev/null -w "前端页面: HTTP %{http_code}\n" http://127.0.0.1/$DOMAIN + +echo "" +echo "部署完成!" +echo " 前台页面: http://$DOMAIN/" +echo " 后台管理: http://$DOMAIN/admin/ (admin / admin123,请尽快修改密码)" +echo " 域名解析: 请确保 $DOMAIN 的 A 记录指向本服务器 IP" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bc37991 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + backend: + build: ./backend + container_name: school-meal-backend + environment: + - DJANGO_SECRET_KEY=sm-c5f9k2x8p3zq7v4n6m1b0j9h4g2f6d8 + - DJANGO_ALLOWED_HOSTS=* + - DJANGO_DEBUG=False + - DJANGO_DB_PATH=/data/db.sqlite3 + volumes: + - backend_data:/data + - static_volume:/app/staticfiles + restart: always + + frontend: + build: ./frontend + container_name: school-meal-frontend + ports: + - "80:80" + volumes: + - static_volume:/static:ro + depends_on: + - backend + restart: always + +volumes: + backend_data: + static_volume: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..6a61852 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json ./ +RUN npm install --silent +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..2c50180 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + 学校订餐菜单生成器 + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..d83e2dd --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,38 @@ +upstream django_backend { + server backend:8000; +} + +server { + listen 80; + server_name xx.mymoyu.top _; + + client_max_body_size 20m; + gzip on; + gzip_types text/plain text/css application/javascript application/json; + + location /api/ { + proxy_pass http://django_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /admin/ { + proxy_pass http://django_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /static/ { + alias /static/; + expires 7d; + } + + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..62955f6 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1705 @@ +{ + "name": "school-meal-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "school-meal-frontend", + "version": "1.0.0", + "dependencies": { + "pinyin-pro": "^3.28.2", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.11" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.401", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.401.tgz", + "integrity": "sha512-H6ViHN68nGYlChEvlIU67fn8O2/tpbWQPwck98yaJmh+08LSvHiydzDQ6oXNccLU3kNRVIRS9A4mA7CG+i6fLQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.52", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.52.tgz", + "integrity": "sha512-MRlTqhAfoMx/4mhEbPo3Hi02g9LJZaJkka69V6h67Cb1gjrAG0jsTE4CZX1eptNx+VCAwJmfpnDIF4P0Nh1A7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pinyin-pro": { + "version": "3.28.2", + "resolved": "https://registry.npmjs.org/pinyin-pro/-/pinyin-pro-3.28.2.tgz", + "integrity": "sha512-jV38yxXHLfidirMC4hrXasLDozLCSq/4DfX88GnHcSEJ2+GpSedG6I9VOiEXJu6iQ5dbJC/RjmzyMuS5h/wH5A==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f8d3f14 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "school-meal-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "pinyin-pro": "^3.28.2", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.11" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..987c6ba --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,629 @@ +import { useEffect, useState } from 'react' +import { pinyin } from 'pinyin-pro' + +const DAYS = ['周一', '周二', '周三', '周四', '周五'] +const SLOT_TYPES = [ + ['meats', 0, '荤菜1'], + ['meats', 1, '荤菜2'], + ['vegs', 0, '素菜1'], + ['vegs', 1, '素菜2'], + ['soup', 0, '营养汤'], + ['staple', 0, '主食'], +] +const TYPE_GROUPS = [ + ['meat', '荤菜'], + ['veg', '素菜'], + ['soup', '营养汤'], + ['staple', '主食'], +] +const TYPE_LABELS = { meats: '荤菜', vegs: '素菜', soup: '营养汤', staple: '主食' } +const TYPE_TO_TAB = { meats: 'meat', vegs: 'veg', soup: 'soup', staple: 'staple' } + +const slotKey = (menu, day, slot, idx) => `${menu}|${day}|${slot}|${idx}` + +function SwapModal({ editing, menus, dishes, pinyinMap, swapKeyword, swapType, setSwapKeyword, setSwapType, onReplace, onClose }) { + const [hover, setHover] = useState(null) + if (!editing || !menus) return null + const { menuKey, day, type, idx } = editing + + const dayMenu = menus[menuKey].week.find((d) => d.day === day) + const isList = type === 'meats' || type === 'vegs' + const current = isList ? dayMenu[type][idx] : dayMenu[type] + const menuLabel = menuKey === 'a' ? 'A餐' : 'B餐' + const slotLabel = `${TYPE_LABELS[type]}${type === 'soup' || type === 'staple' ? '' : ` ${idx + 1}`}` + const slotTab = TYPE_TO_TAB[type] + const slotTabLabel = TYPE_GROUPS.find(([t]) => t === slotTab)?.[1] || TYPE_LABELS[type] + const kw = swapKeyword.trim().toLowerCase() + const match = (d) => { + if (!kw) return true + const p = pinyinMap[d.id] + return d.name.toLowerCase().includes(kw) || (p && (p.full.includes(kw) || p.initials.includes(kw))) + } + const groups = (swapType === 'all' ? TYPE_GROUPS : TYPE_GROUPS.filter(([t]) => t === swapType)) + .map(([t, label]) => [t, label, dishes[t].filter(match)]) + .filter(([, , pool]) => pool.length) + + return ( +
{ + if (e.target === e.currentTarget) onClose() + }} + > +
e.stopPropagation()}> +
+

+ 替换{menuLabel}·{day}「{slotLabel}」 + {current ? `(当前:${current.name})` : ''} +

+ +
+
+
+ + +
+ setSwapKeyword(e.target.value)} + /> +
+
+ {groups.map(([t, label, pool]) => { + const cuisines = [...new Set(pool.map((d) => d.cuisine || '未分类'))] + return ( +
+
{label}
+ {cuisines.map((g) => ( +
+
{g}
+
+ {pool + .filter((d) => (d.cuisine || '未分类') === g) + .map((d) => ( + + ))} +
+
+ ))} +
+ ) + })} + {!groups.length &&

没有匹配的菜品

} +
+
+ {hover && } +
+ ) +} + +function PickModal({ pick, slots, dishes, pinyinMap, keyword, pickType, setKeyword, setPickType, onChoose, onClose }) { + const [hover, setHover] = useState(null) + if (!pick) return null + const { menu, day, slot, idx } = pick + const key = slotKey(menu, day, slot, idx) + const current = slots[key] + const menuLabel = menu === 'a' ? 'A餐' : 'B餐' + const slotLabel = `${TYPE_LABELS[slot]}${slot === 'soup' || slot === 'staple' ? '' : ` ${idx + 1}`}` + const kw = keyword.trim().toLowerCase() + const match = (d) => { + if (!kw) return true + const p = pinyinMap[d.id] + return d.name.toLowerCase().includes(kw) || (p && (p.full.includes(kw) || p.initials.includes(kw))) + } + const groups = (pickType === 'all' ? TYPE_GROUPS : TYPE_GROUPS.filter(([t]) => t === pickType)) + .map(([t, label]) => [t, label, dishes[t].filter(match)]) + .filter(([, , pool]) => pool.length) + + return ( +
{ + if (e.target === e.currentTarget) onClose() + }} + > +
e.stopPropagation()}> +
+

+ 自选{menuLabel}·{day}「{slotLabel}」 + {current ? `(当前:${current.name})` : '(未选择,可留空自动生成)'} +

+ +
+
+
+ + {TYPE_GROUPS.map(([t, label]) => ( + + ))} +
+ setKeyword(e.target.value)} + /> +
+
+ {groups.map(([t, label, pool]) => { + const cuisines = [...new Set(pool.map((d) => d.cuisine || '未分类'))] + return ( +
+
{label}
+ {cuisines.map((g) => ( +
+
{g}
+
+ {pool + .filter((d) => (d.cuisine || '未分类') === g) + .map((d) => ( + + ))} +
+
+ ))} +
+ ) + })} + {!groups.length &&

没有匹配的菜品

} +
+
+ {hover && } +
+ ) +} + +function DishTooltip({ dish, x, y }) { + const style = { left: x + 14, top: y + 14 } + return ( +
+
{dish.name}
+ {dish.ingredient_detail &&
配料:{dish.ingredient_detail}
} +
+ 蛋白质 {Number(dish.protein || 0).toFixed(1)}g + 脂肪 {Number(dish.fat || 0).toFixed(1)}g + 热量 {Math.round(Number(dish.calorie || 0))}kcal +
+
+ ) +} + +function App() { + const [dishes, setDishes] = useState({ meat: [], veg: [], soup: [], staple: [] }) + const [slots, setSlots] = useState({}) + const [pick, setPick] = useState(null) + const [pickType, setPickType] = useState('all') + const [keyword, setKeyword] = useState('') + const [menus, setMenus] = useState(null) + const [editing, setEditing] = useState(null) + const [swapType, setSwapType] = useState('all') + const [swapKeyword, setSwapKeyword] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [pinyinMap, setPinyinMap] = useState({}) + + useEffect(() => { + const map = {} + for (const list of Object.values(dishes)) { + for (const d of list) { + const full = pinyin(d.name, { toneType: 'none', type: 'array' }).join('').toLowerCase() + const initials = pinyin(d.name, { pattern: 'first', toneType: 'none', type: 'array' }).join('').toLowerCase() + map[d.id] = { full, initials } + } + } + setPinyinMap(map) + }, [dishes]) + + useEffect(() => { + fetch('/api/dishes/') + .then((r) => r.json()) + .then((data) => + setDishes({ + meat: data.filter((d) => d.dish_type === 'meat' && d.is_active), + veg: data.filter((d) => d.dish_type === 'veg' && d.is_active), + soup: data.filter((d) => d.dish_type === 'soup' && d.is_active), + staple: data.filter((d) => d.dish_type === 'staple' && d.is_active), + }), + ) + .catch(() => setError('无法连接后端服务,请确认 Django 已启动')) + }, []) + + const selectedCount = Object.keys(slots).length + + const generate = async () => { + setLoading(true) + setError('') + try { + const selections = Object.entries(slots).map(([key, dish]) => { + const [menu, day, slot, idx] = key.split('|') + return { menu, day, slot, idx: Number(idx), dish_id: dish.id } + }) + const res = await fetch('/api/menu/generate/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ partial: true, selections }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.detail || '生成失败') + setMenus({ a: data.a, b: data.b }) + setEditing(null) + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + } + } + + const clearSlots = () => { + setSlots({}) + setMenus(null) + } + + const downloadBlob = (blob, filename) => { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) + } + + const exportExcel = async (key) => { + setError('') + try { + const label = key === 'a' ? '营养A菜单' : '营养B菜单' + const menu = menus[key] + const res = await fetch('/api/menu/export/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + menus: [{ label, title: menu.title, week: menu.week }], + }), + }) + if (!res.ok) throw new Error('导出失败') + const blob = await res.blob() + downloadBlob(blob, `${label}.xlsx`) + } catch (e) { + setError(e.message) + } + } + + const replaceDish = (menuKey, day, type, idx, newId) => { + const allDishes = [...dishes.meat, ...dishes.veg, ...dishes.soup, ...dishes.staple] + const newDish = allDishes.find((d) => d.id === newId) + if (!newDish) return + setMenus((prev) => { + const week = prev[menuKey].week.map((d) => ({ ...d })) + const dayMenu = week.find((d) => d.day === day) + const isList = type === 'meats' || type === 'vegs' + const list = isList ? dayMenu[type].slice() : [dayMenu[type]] + if (list[idx] && list[idx].id === newDish.id) return prev + const replaced = { + ...newDish, + dish_type: newDish.dish_type, + name: newDish.name, + ingredient: newDish.ingredient_detail, + protein: Number(newDish.protein), + fat: Number(newDish.fat), + calorie: newDish.calorie, + } + list[idx] = replaced + dayMenu[type] = isList ? list : replaced + return { ...prev, [menuKey]: { ...prev[menuKey], week } } + }) + setEditing(null) + } + + const chooseSlot = (menu, day, slot, idx, dish) => { + setSlots((prev) => ({ ...prev, [slotKey(menu, day, slot, idx)]: dish })) + setPick(null) + } + + const DishCell = ({ menuKey, day, type, idx, item }) => { + if (!item) return - + return ( +
+
{item.name}
+ {item.ingredient &&
({item.ingredient})
} + +
+ ) + } + + const SlotCell = ({ menu, day, slot, idx }) => { + const key = slotKey(menu, day, slot, idx) + const dish = slots[key] + return ( + setPick({ menu, day, slot, idx })}> + {dish ? ( + <> +
{dish.name}
+ {dish.ingredient_detail &&
({dish.ingredient_detail})
} + + + ) : ( + 点击自选 + )} + + ) + } + + const SlotGrid = ({ menu }) => { + const title = menu === 'a' ? '营养A菜单' : '营养B菜单' + return ( +
+
+

{title}自选

+ 选中的位置固定为该菜,未选位置自动随机 +
+
+ + + + + {SLOT_TYPES.map(([slot, idx, label]) => ( + + ))} + + + + {DAYS.map((day) => ( + + + {SLOT_TYPES.map(([slot, idx]) => ( + + ))} + + ))} + +
日期{label}
{day}
+
+
+ ) + } + + const MenuBlock = ({ menu, idx }) => { + const menuKey = idx === 0 ? 'a' : 'b' + const seen = new Map() + for (const d of menu.week) { + for (const item of [...d.meats, ...d.vegs]) { + if (!seen.has(item.id)) seen.set(item.id, item) + } + } + const nutritionList = [...seen.values()] + + const proteins = [] + const fats = [] + const calories = [] + for (const d of menu.week) { + const items = [...d.meats, ...d.vegs] + if (d.soup) items.push(d.soup) + if (d.staple) items.push(d.staple) + proteins.push(items.reduce((s, x) => s + Number(x.protein || 0), 0)) + fats.push(items.reduce((s, x) => s + Number(x.fat || 0), 0)) + calories.push(items.reduce((s, x) => s + Number(x.calorie || 0), 0)) + } + const nutritionText = `营养分析:蛋白质≥${Math.min(...proteins).toFixed(1)}g 脂肪≤${Math.max(...fats).toFixed(1)}g 热量≥${Math.round(Math.min(...calories))}kcal` + return ( +
+
+

{idx === 0 ? '营养A菜单' : '营养B菜单'}

+ + 点击每道菜下方的「换」可从菜品库中替换(仅影响本套菜单),营养分析与导出会同步更新 + +
+
+ + + + + + + + + + + + + {menu.week.map((d) => ( + + + + + + + + ))} + + + + + + +
{menu.title}
菜式主食荤菜素菜营养汤
{d.day} + + + {d.meats.map((m, i) => ( + + ))} + + {d.vegs.map((v, i) => ( + + ))} + + +
+ {nutritionText} +
+
+ +
+

本周菜品营养分析(每份)

+
+ + + + + + + + + + + + {nutritionList.map((d) => ( + + + + + + + + ))} + + + + + + +
菜品类型蛋白质(g)脂肪(g)热量(kcal)
{d.name}{d.dish_type === 'meat' ? '荤菜' : '素菜'}{d.protein}{d.fat}{d.calorie}
+ {nutritionText} +
+
+
+
+ ) + } + + return ( +
+
+

学校订餐菜单生成器

+

自选任意菜品到任意位置(也可留空),未选位置自动生成营养 A / B 两套周菜单,不满意可逐道替换,最后导出 Excel

+
+ +
+
+
+ + {selectedCount > 0 && ( + + )} +
+
+ + +
+

+ 每套菜单 5 天 × 6 个位置(荤1/荤2/素1/素2/营养汤/主食),点击格子可自选任意菜品(也可留空); + 选菜弹窗支持按类型分类切换和菜名搜索(支持拼音首字母,如 hsr = 红烧肉),悬停菜品可查看营养成分; + 未选位置自动随机填充,每天保持 2 荤 + 2 素 + 1 汤 + 1 主食,周内不重复 +

+
+ +
+ + + +
+ {error &&

{error}

} +
+ + {menus && ( +
+ + +
+ )} + setEditing(null)} + /> + setPick(null)} + /> +
+ ) +} + +export default App diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..43ef22a --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,630 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif; + background: #eef2f7; + color: #2c3e50; +} + +.page { + max-width: 1920px; + margin: 0 auto; + padding: 32px 20px 60px; +} + +.hero { + text-align: center; + padding: 18px 0 28px; +} + +.hero h1 { + font-size: 30px; + color: #1f3a5f; + margin-bottom: 8px; +} + +.hero p { + color: #6b7a90; +} + +.panel { + background: #fff; + border-radius: 14px; + padding: 24px; + box-shadow: 0 4px 18px rgba(31, 58, 95, 0.08); + margin-bottom: 24px; +} + +.field { + margin-bottom: 20px; +} + +.label { + display: block; + font-weight: 600; + margin-bottom: 10px; + color: #1f3a5f; +} + +.seg { + display: inline-flex; + background: #eef2f7; + border-radius: 10px; + padding: 4px; + gap: 4px; +} + +.seg-btn { + border: none; + background: transparent; + padding: 9px 22px; + border-radius: 8px; + font-size: 14px; + cursor: pointer; + color: #4a5a72; +} + +.seg-btn.active { + background: #2f5597; + color: #fff; + font-weight: 600; +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.chip { + border: 1.5px solid #d5deea; + background: #fff; + padding: 8px 18px; + border-radius: 999px; + font-size: 14px; + cursor: pointer; + transition: all 0.15s; +} + +.chip:hover:not(:disabled) { + border-color: #2f5597; +} + +.chip.on { + background: #2f5597; + border-color: #2f5597; + color: #fff; + font-weight: 600; +} + +.chip:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.row-fields { + display: flex; + align-items: flex-end; + gap: 40px; + flex-wrap: wrap; +} + +select { + padding: 9px 14px; + border: 1.5px solid #d5deea; + border-radius: 8px; + font-size: 14px; + background: #fff; + min-width: 120px; +} + +.checks { + display: flex; + gap: 24px; + padding-bottom: 10px; + font-size: 14px; +} + +.checks label { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; +} + +.actions { + display: flex; + gap: 14px; + margin-top: 6px; +} + +.btn { + padding: 12px 32px; + border: 1.5px solid #2f5597; + background: #fff; + color: #2f5597; + border-radius: 10px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; +} + +.btn:hover:not(:disabled) { + background: #eaf0fa; +} + +.btn.primary { + background: #2f5597; + color: #fff; +} + +.btn.primary:hover:not(:disabled) { + background: #24447c; +} + +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.error { + margin-top: 14px; + color: #c0392b; + font-size: 14px; +} + +.panel-head { + display: flex; + align-items: baseline; + gap: 16px; + margin-bottom: 16px; +} + +.panel-head h2 { + font-size: 18px; + color: #1f3a5f; +} + +.table-scroll { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +table.excel-like caption { + background: #2f5597; + color: #fff; + font-size: 16px; + font-weight: 700; + padding: 12px; + letter-spacing: 2px; + border: 1px solid #2f5597; +} + +table.excel-like th, +table.excel-like td { + border: 1px solid #808080; + padding: 12px 14px; + text-align: center; +} + +table.excel-like th { + background: #2f5597; + color: #fff; + font-weight: 600; + min-width: 70px; +} + +table.excel-like td { + vertical-align: top; + line-height: 1.5; +} + +td.day { + font-weight: 700; + color: #1f3a5f; + background: #d9e2f3; + vertical-align: middle; +} + +td.meat { + background: #fce4d6; + color: #9a4a12; +} + +td.veg { + background: #e2efda; + color: #2d6a2d; +} + +td.staple { + background: #fde9d9; +} + +td.soup { + background: #ddebf7; +} + +td.nutrition { + background: #fff2cc; + font-weight: 600; + font-size: 13px; + color: #7a5c00; +} + +.dish-cell { + position: relative; + padding: 4px 2px; +} + +.swap-btn { + display: block; + margin: 4px auto 0; + border: 1px solid #b9c4d4; + background: #fff; + color: #5a6b84; + font-size: 11px; + border-radius: 6px; + padding: 1px 10px; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s; +} + +td:hover .swap-btn, +.dish-cell:hover .swap-btn { + opacity: 1; +} + +.swap-btn:hover { + border-color: #2f5597; + color: #2f5597; +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(15, 28, 47, 0.5); + z-index: 100; + display: flex; + align-items: center; + justify-content: center; +} + +.modal { + background: #fff; + border-radius: 14px; + width: min(760px, 94vw); + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(15, 28, 47, 0.35); + overflow: hidden; +} + +.modal-head { + padding: 16px 20px; + border-bottom: 1px solid #e3e9f2; + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; +} + +.modal-head h3 { + font-size: 16px; + color: #1f3a5f; +} + +.modal-close { + border: none; + background: none; + font-size: 24px; + line-height: 1; + color: #8a97a8; + cursor: pointer; + padding: 0 4px; +} + +.modal-close:hover { + color: #c0392b; +} + +.modal-body { + padding: 18px 20px 22px; + overflow-y: auto; +} + +.modal-group { + margin-bottom: 16px; +} + +.modal-group-label { + font-weight: 700; + color: #2f5597; + margin-bottom: 8px; + font-size: 13px; +} + +.modal-items { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.modal-item { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + border: 1.5px solid #d5deea; + background: #fff; + border-radius: 8px; + padding: 8px 12px; + cursor: pointer; + font-size: 13px; + color: #2c3e50; + transition: all 0.15s; +} + +.modal-item:hover { + border-color: #2f5597; + background: #eaf0fa; +} + +.modal-item.current { + border-color: #2f5597; + background: #2f5597; + color: #fff; +} + +.modal-item .item-ing { + font-size: 11px; + color: #8a97a8; +} + +.modal-item.current .item-ing { + color: #cfe0f5; +} + +.hint { + font-size: 13px; + color: #6b7a90; + padding-bottom: 10px; +} + +.label-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.label-row .label { + margin-bottom: 0; +} + +.btn.small { + padding: 6px 16px; + font-size: 13px; + border-radius: 8px; +} + +.slot-row { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; + align-items: start; +} + +.slot-panel { + border: 1px solid #e3e9f2; + border-radius: 12px; + padding: 16px; + background: #fbfcfe; +} + +.slot-panel .panel-head { + margin-bottom: 12px; +} + +.slot-panel .panel-head h2 { + font-size: 15px; +} + +.slot-panel .tip { + font-size: 12px; + color: #8a97a8; +} + +table.excel-like.slot-table th { + min-width: 84px; + font-size: 13px; +} + +table.excel-like.slot-table td { + vertical-align: middle; +} + +td.slot-cell { + cursor: pointer; + transition: background 0.15s; + min-height: 56px; +} + +td.slot-cell:hover { + background: #eaf0fa; +} + +td.slot-cell.filled { + background: #f4f8ff; +} + +td.slot-cell .dish-name { + display: inline; +} + +.slot-empty { + color: #aab6c6; + font-size: 13px; +} + +.slot-clear { + display: block; + margin: 6px auto 0; + border: 1px solid #d5deea; + background: #fff; + color: #8a97a8; + font-size: 11px; + border-radius: 6px; + padding: 2px 10px; + cursor: pointer; +} + +.slot-clear:hover { + border-color: #c0392b; + color: #c0392b; +} + +.modal-toolbar { + padding: 12px 20px; + border-bottom: 1px solid #e3e9f2; + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + flex-shrink: 0; +} + +.pick-search { + flex: 1; + min-width: 160px; + padding: 9px 14px; + border: 1.5px solid #d5deea; + border-radius: 8px; + font-size: 14px; + outline: none; +} + +.pick-search:focus { + border-color: #2f5597; +} + +.modal-subgroup { + margin-bottom: 12px; +} + +.modal-subgroup-label { + font-size: 12px; + color: #8a97a8; + margin-bottom: 6px; + padding-left: 4px; +} + +.dish-tooltip { + position: fixed; + z-index: 200; + pointer-events: none; + background: rgba(28, 41, 61, 0.95); + color: #fff; + border-radius: 8px; + padding: 10px 14px; + font-size: 12.5px; + max-width: 280px; + box-shadow: 0 8px 24px rgba(15, 28, 47, 0.35); +} + +.dish-tooltip .tooltip-name { + font-weight: 700; + margin-bottom: 4px; +} + +.dish-tooltip .tooltip-ing { + color: #c9d4e4; + margin-bottom: 6px; + line-height: 1.4; +} + +.dish-tooltip .tooltip-nutri { + display: flex; + gap: 12px; + color: #ffd98a; + font-weight: 600; +} + +.hint.warn { + color: #c0392b; + margin-top: 12px; + padding-bottom: 0; +} + +.dish-cell + .dish-cell { + border-top: 1px dashed #b9c4d4; + margin-top: 4px; + padding-top: 6px; +} + +.dish-name { + font-weight: 600; + font-size: 13.5px; +} + +.dish-ing { + font-size: 11.5px; + color: #666; + margin-top: 2px; +} + +.menu-row { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 24px; + align-items: center; +} + +@media (max-width: 900px) { + .menu-row { + grid-template-columns: 1fr; + } + .slot-row { + grid-template-columns: 1fr; + } +} + +.nutri-section { + margin-top: 24px; + border-top: 2px solid #eef2f7; + padding-top: 18px; +} + +.nutri-section h3 { + font-size: 16px; + color: #1f3a5f; + margin-bottom: 12px; +} + +table.excel-like.nutri-table { + max-width: 640px; +} + +table.excel-like.nutri-table th, +table.excel-like.nutri-table td { + padding: 8px 12px; +} + +table.excel-like.nutri-table td { + vertical-align: middle; +} + +.dish-empty { + color: #999; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..54b39dd --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..2f55436 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': 'http://127.0.0.1:8000', + }, + }, +}) diff --git a/meals/__init__.py b/meals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/meals/admin.py b/meals/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/meals/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/meals/apps.py b/meals/apps.py new file mode 100644 index 0000000..832619c --- /dev/null +++ b/meals/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class MealsConfig(AppConfig): + name = 'meals' diff --git a/meals/migrations/__init__.py b/meals/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/meals/models.py b/meals/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/meals/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/meals/tests.py b/meals/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/meals/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/meals/views.py b/meals/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/meals/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..35cd80a --- /dev/null +++ b/start.bat @@ -0,0 +1,35 @@ +@echo off +chcp 65001 >nul +echo ========================================== +echo 学校订餐菜单生成器 - 一键启动 +echo ========================================== +cd /d %~dp0 + +if not exist .venv ( + echo [1/3] 首次运行,创建 Python 虚拟环境... + python -m venv .venv + .venv\Scripts\pip install -r backend\requirements.txt + .venv\Scripts\python backend\manage.py migrate + .venv\Scripts\python backend\manage.py seed_dishes + echo 管理员账号: admin 密码: admin123 +) + +echo [1/3] 启动 Django 后端 (http://127.0.0.1:8000)... +start "django-backend" cmd /k ".venv\Scripts\python backend\manage.py runserver 0.0.0.0:8000" + +echo [2/3] 启动 React 前端 (http://localhost:5173)... +if not exist frontend\node_modules ( + echo 首次运行,安装前端依赖... + cd frontend + call npm install + cd .. +) +start "react-frontend" cmd /k "cd frontend && npm run dev" + +echo [3/3] 启动完成! +echo. +echo - 前端页面: http://localhost:5173 +echo - 后台管理: http://127.0.0.1:8000/admin (账号 admin / 密码 admin123) +echo - 关闭方式: 关闭弹出的两个命令行窗口 +echo. +pause diff --git a/xxcdn二维码.png b/xxcdn二维码.png new file mode 100644 index 0000000..e6bac7c Binary files /dev/null and b/xxcdn二维码.png differ