diff --git a/.gitignore b/.gitignore index 3df6256..d78fad5 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,8 @@ git/ # 对象存储上传素材 / 临时静态 静态文件上传/ backend/static/ + +# Build artifacts +dist/ +build/ +installer/ diff --git a/app.ico b/app.ico new file mode 100644 index 0000000..590a0ea Binary files /dev/null and b/app.ico differ diff --git a/backend/config/settings.py b/backend/config/settings.py index 80ac39c..e1546bd 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -10,6 +10,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/6.0/ref/settings/ """ +import sys from pathlib import Path import os @@ -76,54 +77,44 @@ 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')), + 'NAME': os.environ.get( + 'DJANGO_DB_PATH', + str(os.path.join(os.path.dirname(sys.executable), 'db.sqlite3') if getattr(sys, 'frozen', False) else 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', - }, + {'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 = os.environ.get('DJANGO_STATIC_URL', '/xx/admin/') +STATIC_URL = os.environ.get('DJANGO_STATIC_URL', '/static/') STATIC_ROOT = BASE_DIR / 'staticfiles' STATICFILES_DIRS = [BASE_DIR / 'static'] +# When frozen by PyInstaller, bundled files live in sys._MEIPASS +_frozen = getattr(sys, 'frozen', False) +if _frozen: + _bundled = sys._MEIPASS + # In frozen mode, static files are at _MEIPASS/backend/static + _be_static = os.path.join(_bundled, 'backend', 'static') + if os.path.isdir(_be_static) and _be_static not in [os.path.normpath(d) for d in STATICFILES_DIRS]: + STATICFILES_DIRS.append(_be_static) + CORS_ALLOWED_ORIGINS = [ 'http://localhost:5173', 'http://127.0.0.1:5173', @@ -149,8 +140,8 @@ JAZZMIN_SETTINGS = { 'site_title': '学校订餐菜单管理系统', 'site_header': '学校订餐菜单生成器', 'site_brand': '订餐菜单管理', + 'site_icon': 'frontend/icon_512.png', 'welcome_sign': '欢迎使用学校订餐菜单管理系统', - 'copyright': '上海外国语大学附属宝山双语学校', 'search_model': ['meals.Dish', 'auth.User'], 'show_sidebar': True, 'navigation_expanded': True, @@ -169,7 +160,6 @@ JAZZMIN_SETTINGS = { 'custom_css': None, 'custom_js': None, 'theme': 'flatly', - 'dark_mode_theme': 'darkly', 'header_classes': 'bg-primary', 'order_with_respect_to': ['meals', 'auth'], } @@ -195,6 +185,10 @@ JAZZMIN_UI_TWEAKS = { 'sidebar_nav_legacy_style': False, 'sidebar_nav_flat_style': False, 'theme': 'flatly', - 'dark_mode_theme': 'darkly', 'button_classes': {'primary': 'btn-outline-primary', 'secondary': 'btn-outline-secondary', 'info': 'btn-outline-info', 'warning': 'btn-outline-warning', 'danger': 'btn-outline-danger', 'success': 'btn-outline-success'}, } + + +LOGIN_REDIRECT_URL = '/admin/' +LOGOUT_REDIRECT_URL = '/admin/login/' + diff --git a/backend/config/urls.py b/backend/config/urls.py index 41a344b..a06e639 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -1,23 +1,61 @@ -""" -URL configuration for config project. +import os -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 +from django.http import HttpResponseNotFound +from django.urls import include, path, re_path +from django.views.generic import RedirectView, TemplateView +from django.views.static import serve as static_serve + +from django.conf import settings + +from meals.views_spa import spa_serve +from meals.views_dashboard import dashboard_api +from meals.views_spa import _get_frontend_dir + + +def _serve_fe_assets(request, path=''): + """Serve built React assets (JS/CSS) from the frontend directory.""" + fdir = _get_frontend_dir() + rel = os.path.join('assets', path) + target = os.path.normpath(os.path.join(fdir, rel)) + if target.startswith(os.path.normpath(fdir)) and os.path.isfile(target): + return static_serve(request, rel, document_root=fdir) + return HttpResponseNotFound('Not found') urlpatterns = [ + path('', TemplateView.as_view(template_name='index.html'), name='index'), + path('app/', spa_serve, name='spa_serve'), + path('app/', spa_serve, name='spa_serve_path'), + re_path(r'^assets/(?P.+)$', _serve_fe_assets, name='fe_assets'), path('admin/', admin.site.urls), path('api/', include('meals.urls')), + path('api/dashboard/', dashboard_api, name='dashboard_api'), + path('dashboard/', RedirectView.as_view(url='/admin/', permanent=False), name='dashboard_redirect'), ] + +# When DEBUG is off (e.g. desktop.py / EXE mode), serve static files directly +# so the Django admin and Jazzmin theme load correctly. +if not settings.DEBUG: + from django.contrib.staticfiles import finders as _sf_finders + + def _serve_static(request, path=''): + """Search all static file roots for the requested path.""" + for root in _static_roots: + target = os.path.normpath(os.path.join(root, path)) + if target.startswith(root) and os.path.isfile(target): + return static_serve(request, path, document_root=root) + from django.http import Http404 + raise Http404 + + _static_roots = [] + for _d in settings.STATICFILES_DIRS: + if os.path.isdir(_d): + _static_roots.append(os.path.normpath(_d)) + for _finder in _sf_finders.get_finders(): + for _path, _storage in _finder.list([]): + _loc = getattr(_storage, 'location', None) + if _loc and os.path.isdir(_loc): + _static_roots.append(os.path.normpath(_loc)) + urlpatterns += [ + re_path(r'^static/(?P.+)$', _serve_static), + ] diff --git a/backend/meals/admin.py b/backend/meals/admin.py index 7f098d0..6abbb8c 100644 --- a/backend/meals/admin.py +++ b/backend/meals/admin.py @@ -1,7 +1,32 @@ from django.contrib import admin from django.utils.html import format_html, mark_safe -from .models import Dish +from .models import Cuisine, Dish + + +@admin.action(description='启用所选菜系') +def activate_cuisines(modeladmin, request, queryset): + queryset.update(is_active=True) + + +@admin.action(description='停用所选菜系') +def deactivate_cuisines(modeladmin, request, queryset): + queryset.update(is_active=False) + + +@admin.register(Cuisine) +class CuisineAdmin(admin.ModelAdmin): + list_display = ('name', 'description', 'sort_order', 'is_active', 'dish_count_display', 'created_at') + list_editable = ('sort_order', 'is_active') + list_filter = ('is_active',) + search_fields = ('name',) + list_per_page = 50 + actions = [activate_cuisines, deactivate_cuisines] + + @admin.display(description='菜品数') + def dish_count_display(self, obj): + count = obj.dishes.count() + return format_html('{}', count) @admin.action(description='启用所选菜品(参与自动生成)') @@ -16,10 +41,10 @@ def deactivate_dishes(modeladmin, request, queryset): @admin.register(Dish) class DishAdmin(admin.ModelAdmin): - list_display = ('name', 'cuisine', 'dish_type_display', 'ingredient_detail', 'protein', 'fat', 'calorie', 'nutri_badge', 'is_active', 'created_at') + list_display = ('name', 'cuisine_display', 'dish_type_display', 'ingredient_detail', 'protein', 'fat', 'calorie', 'nutri_badge', 'is_active', 'created_at') list_filter = ('dish_type', 'cuisine', 'is_active') - search_fields = ('name', 'ingredient_detail', 'cuisine', 'pinyin') - list_editable = ('cuisine', 'ingredient_detail', 'protein', 'fat', 'calorie', 'is_active') + search_fields = ('name', 'ingredient_detail', 'cuisine__name', 'pinyin') + list_editable = ('ingredient_detail', 'protein', 'fat', 'calorie', 'is_active') list_per_page = 50 actions = [activate_dishes, deactivate_dishes] fieldsets = ( @@ -28,6 +53,12 @@ class DishAdmin(admin.ModelAdmin): ('其他', {'fields': ('description',), 'classes': ('collapse',)}), ) + @admin.display(description='菜系', ordering='cuisine') + def cuisine_display(self, obj): + if obj.cuisine: + return obj.cuisine.name + return '—' + @admin.display(description='营养提示', ordering='calorie') def nutri_badge(self, obj): badges = [] diff --git a/backend/meals/migrations/0006_cuisine_dish_cuisine_fk.py b/backend/meals/migrations/0006_cuisine_dish_cuisine_fk.py new file mode 100644 index 0000000..f99bf6c --- /dev/null +++ b/backend/meals/migrations/0006_cuisine_dish_cuisine_fk.py @@ -0,0 +1,29 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Step 1: Create the Cuisine table (no changes to Dish yet).""" + + dependencies = [ + ('meals', '0005_dish_pinyin'), + ] + + operations = [ + migrations.CreateModel( + name='Cuisine', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True, verbose_name='菜系名称')), + ('description', models.CharField(blank=True, default='', max_length=200, verbose_name='描述')), + ('sort_order', models.IntegerField(default=0, help_text='数值越小越靠前', verbose_name='排序')), + ('is_active', models.BooleanField(default=True, verbose_name='启用')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ], + options={ + 'verbose_name': '菜系', + 'verbose_name_plural': '菜系', + 'ordering': ['sort_order', 'id'], + }, + ), + ] diff --git a/backend/meals/migrations/0007_seed_cuisines_and_update_dish.py b/backend/meals/migrations/0007_seed_cuisines_and_update_dish.py new file mode 100644 index 0000000..2f52615 --- /dev/null +++ b/backend/meals/migrations/0007_seed_cuisines_and_update_dish.py @@ -0,0 +1,93 @@ +import django.db.models.deletion +from django.db import migrations, models + + +CUISINE_SEEDS = [ + ('家常菜', 0), + ('本帮菜', 10), + ('江浙菜', 20), + ('川菜', 30), + ('粤菜', 40), + ('鲁菜', 50), + ('湘菜', 60), + ('西北菜', 70), + ('面点小吃', 80), +] + + +def seed_cuisines(apps, schema_editor): + """Create Cuisine rows and save dish->cuisine mapping to a temp table.""" + Cuisine = apps.get_model('meals', 'Cuisine') + + name_to_id = {} + for name, order in CUISINE_SEEDS: + obj, _ = Cuisine.objects.get_or_create(name=name, defaults={'sort_order': order}) + name_to_id[name] = obj.pk + + from django.db import connection + with connection.cursor() as cursor: + cursor.execute("SELECT id, cuisine FROM meals_dish WHERE cuisine IS NOT NULL AND cuisine != ''") + rows = cursor.fetchall() + + for _, cuisine_name in rows: + if cuisine_name not in name_to_id: + obj, _ = Cuisine.objects.get_or_create(name=cuisine_name, defaults={'sort_order': 90}) + name_to_id[cuisine_name] = obj.pk + + with connection.cursor() as cursor: + cursor.execute("CREATE TABLE _temp_dish_cuisine (dish_id INTEGER, cuisine_id INTEGER)") + for dish_id, cuisine_name in rows: + cuisine_id = name_to_id.get(cuisine_name) + if cuisine_id: + cursor.execute( + "INSERT INTO _temp_dish_cuisine (dish_id, cuisine_id) VALUES (%s, %s)", + [dish_id, cuisine_id], + ) + + +def update_cuisine_fk(apps, schema_editor): + """Populate Dish.cuisine_id from the temp table, then clean up.""" + from django.db import connection + with connection.cursor() as cursor: + cursor.execute( + "UPDATE meals_dish SET cuisine_id = " + "(SELECT cuisine_id FROM _temp_dish_cuisine " + "WHERE _temp_dish_cuisine.dish_id = meals_dish.id) " + "WHERE EXISTS (SELECT 1 FROM _temp_dish_cuisine " + "WHERE _temp_dish_cuisine.dish_id = meals_dish.id)" + ) + cursor.execute("DROP TABLE _temp_dish_cuisine") + + +def reverse_seed(apps, schema_editor): + """Reverse: clean up temp table and delete Cuisine rows.""" + from django.db import connection + with connection.cursor() as cursor: + cursor.execute("DROP TABLE IF EXISTS _temp_dish_cuisine") + Cuisine = apps.get_model('meals', 'Cuisine') + Cuisine.objects.all().delete() + + +class Migration(migrations.Migration): + """Step 2: Seed cuisines from existing data, then alter Dish.cuisine to FK.""" + + dependencies = [ + ('meals', '0006_cuisine_dish_cuisine_fk'), + ] + + operations = [ + migrations.RunPython(seed_cuisines, reverse_seed), + migrations.AlterField( + model_name='dish', + name='cuisine', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='dishes', + to='meals.cuisine', + verbose_name='菜系', + ), + ), + migrations.RunPython(update_cuisine_fk, migrations.RunPython.noop), + ] diff --git a/backend/meals/migrations/0008_alter_cuisine_id.py b/backend/meals/migrations/0008_alter_cuisine_id.py new file mode 100644 index 0000000..7df1d03 --- /dev/null +++ b/backend/meals/migrations/0008_alter_cuisine_id.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.8 on 2026-08-27 06:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('meals', '0007_seed_cuisines_and_update_dish'), + ] + + operations = [ + migrations.AlterField( + model_name='cuisine', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/backend/meals/models.py b/backend/meals/models.py index b916d56..7648803 100644 --- a/backend/meals/models.py +++ b/backend/meals/models.py @@ -3,6 +3,23 @@ from django.db import models from pypinyin import Style, lazy_pinyin +class Cuisine(models.Model): + """菜系:可动态管理的菜系分类""" + name = models.CharField('菜系名称', max_length=50, unique=True) + description = models.CharField('描述', max_length=200, blank=True, default='') + sort_order = models.IntegerField('排序', default=0, help_text='数值越小越靠前') + is_active = models.BooleanField('启用', default=True) + created_at = models.DateTimeField('创建时间', auto_now_add=True) + + class Meta: + ordering = ['sort_order', 'id'] + verbose_name = '菜系' + verbose_name_plural = '菜系' + + def __str__(self): + return self.name + + class Dish(models.Model): TYPE_CHOICES = [ ('meat', '荤菜'), @@ -10,21 +27,17 @@ class Dish(models.Model): ('soup', '汤'), ('staple', '主食'), ] - CUISINE_CHOICES = [ - ('家常菜', '家常菜'), - ('本帮菜', '本帮菜'), - ('江浙菜', '江浙菜'), - ('川菜', '川菜'), - ('粤菜', '粤菜'), - ('鲁菜', '鲁菜'), - ('湘菜', '湘菜'), - ('西北菜', '西北菜'), - ('面点小吃', '面点小吃'), - ] name = models.CharField('菜名', max_length=100, unique=True) dish_type = models.CharField('类型', max_length=10, choices=TYPE_CHOICES) - cuisine = models.CharField('菜系', max_length=20, choices=CUISINE_CHOICES, blank=True, default='家常菜') + cuisine = models.ForeignKey( + Cuisine, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='dishes', + verbose_name='菜系', + ) ingredient_detail = models.CharField('配料明细', max_length=200, blank=True, help_text='例如:牛心菜85g五花肉片15g') protein = models.DecimalField('蛋白质(g)', max_digits=6, decimal_places=1, default=0) fat = models.DecimalField('脂肪(g)', max_digits=6, decimal_places=1, default=0) @@ -42,6 +55,10 @@ class Dish(models.Model): def __str__(self): return f'{self.get_dish_type_display()}: {self.name}' + @property + def cuisine_name(self): + return self.cuisine.name if self.cuisine else '' + def save(self, *args, **kwargs): if self.name: full = ''.join(lazy_pinyin(self.name)) diff --git a/backend/meals/serializers.py b/backend/meals/serializers.py index 234a2d2..1e9f563 100644 --- a/backend/meals/serializers.py +++ b/backend/meals/serializers.py @@ -1,11 +1,23 @@ from rest_framework import serializers -from .models import Dish +from .models import Cuisine, Dish + + +class CuisineSerializer(serializers.ModelSerializer): + dish_count = serializers.SerializerMethodField() + + class Meta: + model = Cuisine + fields = ['id', 'name', 'description', 'sort_order', 'is_active', 'dish_count', 'created_at'] + + def get_dish_count(self, obj): + return obj.dishes.count() class DishSerializer(serializers.ModelSerializer): dish_type_display = serializers.CharField(source='get_dish_type_display', read_only=True) + cuisine_name = serializers.CharField(read_only=True) class Meta: model = Dish - fields = ['id', 'name', 'dish_type', 'dish_type_display', 'cuisine', 'ingredient_detail', 'protein', 'fat', 'calorie', 'description', 'is_active', 'created_at'] + fields = ['id', 'name', 'dish_type', 'dish_type_display', 'cuisine', 'cuisine_name', 'ingredient_detail', 'protein', 'fat', 'calorie', 'description', 'is_active', 'created_at'] diff --git a/backend/meals/urls.py b/backend/meals/urls.py index 527e30b..ab9cbff 100644 --- a/backend/meals/urls.py +++ b/backend/meals/urls.py @@ -5,6 +5,7 @@ from . import views router = DefaultRouter() router.register('dishes', views.DishViewSet) +router.register('cuisines', views.CuisineViewSet) urlpatterns = [ path('dishes/bulk/', views.bulk_create_dishes, name='bulk_create_dishes'), diff --git a/backend/meals/views.py b/backend/meals/views.py index 146f6c1..9a2f629 100644 --- a/backend/meals/views.py +++ b/backend/meals/views.py @@ -8,13 +8,14 @@ from openpyxl.utils import get_column_letter from rest_framework import status, viewsets from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAdminUser +from rest_framework.permissions import AllowAny from rest_framework.response import Response -from .models import Dish -from .serializers import DishSerializer +from .models import Cuisine, Dish +from .serializers import DishSerializer, CuisineSerializer WEEKDAYS = ['周一', '周二', '周三', '周四', '周五'] -SCHOOL_NAME = '上海外国语大学附属宝山双语学校' +SCHOOL_NAME = '学校菜单' COLOR_HEADER = PatternFill('solid', fgColor='2F5597') COLOR_DAY = PatternFill('solid', fgColor='D9E2F3') @@ -27,9 +28,16 @@ BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN) CENTER = Alignment(horizontal='center', vertical='center', wrap_text=True) +class CuisineViewSet(viewsets.ModelViewSet): + queryset = Cuisine.objects.all() + serializer_class = CuisineSerializer + permission_classes = [AllowAny] + + class DishViewSet(viewsets.ModelViewSet): queryset = Dish.objects.all() serializer_class = DishSerializer + permission_classes = [AllowAny] def get_queryset(self): qs = Dish.objects.all() @@ -59,9 +67,14 @@ def bulk_create_dishes(request): if not name or dish_type not in ('meat', 'veg', 'soup', 'staple'): skipped.append(name or '未命名') continue + # Resolve cuisine FK by name + cuisine_name = str(raw.get('cuisine', '') or '').strip() + cuisine_obj = None + if cuisine_name: + cuisine_obj, _ = Cuisine.objects.get_or_create(name=cuisine_name) defaults = { 'dish_type': dish_type, - 'cuisine': str(raw.get('cuisine', '家常菜') or '家常菜'), + 'cuisine': cuisine_obj, 'ingredient_detail': str(raw.get('ingredient_detail', '') or raw.get('ingredient', '')), 'protein': float(raw.get('protein', 0) or 0), 'fat': float(raw.get('fat', 0) or 0), @@ -94,7 +107,7 @@ def _pop_next(pool, shuffle_src): def _serialize_dish(d): return { 'id': d.id, 'name': d.name, 'ingredient': d.ingredient_detail, 'dish_type': d.dish_type, - 'cuisine': d.cuisine, 'protein': float(d.protein), 'fat': float(d.fat), 'calorie': d.calorie, + 'cuisine': d.cuisine_name, 'protein': float(d.protein), 'fat': float(d.fat), 'calorie': d.calorie, } @@ -211,19 +224,19 @@ def generate_week_menu(payload, menu_key='a'): week.append({ 'day': day, - 'meats': [{'id': d.id, 'name': d.name, 'ingredient': d.ingredient_detail, 'dish_type': d.dish_type, 'cuisine': d.cuisine, + 'meats': [{'id': d.id, 'name': d.name, 'ingredient': d.ingredient_detail, 'dish_type': d.dish_type, 'cuisine': d.cuisine_name, 'protein': float(d.protein), 'fat': float(d.fat), 'calorie': d.calorie} for d in day_meat_list], - 'vegs': [{'id': d.id, 'name': d.name, 'ingredient': d.ingredient_detail, 'dish_type': d.dish_type, 'cuisine': d.cuisine, + 'vegs': [{'id': d.id, 'name': d.name, 'ingredient': d.ingredient_detail, 'dish_type': d.dish_type, 'cuisine': d.cuisine_name, 'protein': float(d.protein), 'fat': float(d.fat), 'calorie': d.calorie} for d in day_veg], - 'soup': {'id': soup.id, 'name': soup.name, 'ingredient': soup.ingredient_detail, 'dish_type': soup.dish_type, 'cuisine': soup.cuisine, + 'soup': {'id': soup.id, 'name': soup.name, 'ingredient': soup.ingredient_detail, 'dish_type': soup.dish_type, 'cuisine': soup.cuisine_name, 'protein': float(soup.protein), 'fat': float(soup.fat), 'calorie': soup.calorie} if soup else None, - 'staple': {'id': staple.id, 'name': staple.name, 'ingredient': staple.ingredient_detail, 'dish_type': staple.dish_type, 'cuisine': staple.cuisine, + 'staple': {'id': staple.id, 'name': staple.name, 'ingredient': staple.ingredient_detail, 'dish_type': staple.dish_type, 'cuisine': staple.cuisine_name, 'protein': float(staple.protein), 'fat': float(staple.fat), 'calorie': staple.calorie} if staple else None, }) return week -def build_menu_title(label, school_name=None, week_no=None): +def build_menu_title(label, school_name=None, week_no=None, date_start=None, date_end=None): school_name = school_name or SCHOOL_NAME iso = date.today().isocalendar() try: @@ -232,12 +245,16 @@ def build_menu_title(label, school_name=None, week_no=None): week_no = iso.week if not 1 <= week_no <= 53: week_no = iso.week - try: - monday = date.fromisocalendar(iso.year, week_no, 1) - except ValueError: - monday = date.today() - timedelta(days=date.today().weekday()) - friday = monday + timedelta(days=4) - date_range = f'{monday.month}.{monday.day}--{friday.month}.{friday.day}' + # Use custom dates if provided, otherwise compute from week number + if date_start and date_end: + date_range = f'{date_start}--{date_end}' + else: + try: + monday = date.fromisocalendar(iso.year, week_no, 1) + except ValueError: + monday = date.today() - timedelta(days=date.today().weekday()) + friday = monday + timedelta(days=4) + date_range = f'{monday.month}.{monday.day}--{friday.month}.{friday.day}' return f'{school_name}[第{week_no}周]{date_range}营养{label}菜单' @@ -246,11 +263,13 @@ def generate_menu(request): data = request.data school = data.get('school_name') or SCHOOL_NAME week_no = data.get('week_no') + date_start = data.get('date_start') + date_end = data.get('date_end') menus = {} for label in ('A', 'B'): key = label.lower() menus[key] = { - 'title': build_menu_title(label, school, week_no), + 'title': build_menu_title(label, school, week_no, date_start, date_end), 'week': generate_week_menu(data, menu_key=key), } return Response(menus) diff --git a/backend/meals/views_dashboard.py b/backend/meals/views_dashboard.py new file mode 100644 index 0000000..bdbd49e --- /dev/null +++ b/backend/meals/views_dashboard.py @@ -0,0 +1,90 @@ +"""Dashboard API & view – visualization panel for the school meal planner.""" +import json +from collections import Counter + +from django.http import JsonResponse +from django.shortcuts import render +from django.db.models import Avg, Count, Max, Min, Sum + +from .models import Dish + + +def _dashboard_stats(): + """Return aggregate statistics for the dashboard.""" + total = Dish.objects.count() + active = Dish.objects.filter(is_active=True).count() + inactive = total - active + + # Count by type + type_counts = dict( + Dish.objects.values_list('dish_type').annotate(c=Count('id')).values_list('dish_type', 'c') + ) + + # Count by cuisine + cuisine_counts = dict( + Dish.objects.values_list('cuisine').annotate(c=Count('id')).values_list('cuisine', 'c') + ) + + # Nutrition averages (active dishes only, meat + veg only for meaningful stats) + nutrition = Dish.objects.filter(is_active=True, dish_type__in=('meat', 'veg')).aggregate( + avg_protein=Avg('protein'), + avg_fat=Avg('fat'), + avg_calorie=Avg('calorie'), + max_calorie=Max('calorie'), + min_calorie=Min('calorie'), + ) + + # Per-type nutrition averages + type_nutrition = {} + for dt in ('meat', 'veg', 'soup', 'staple'): + agg = Dish.objects.filter(is_active=True, dish_type=dt).aggregate( + avg_protein=Avg('protein'), + avg_fat=Avg('fat'), + avg_calorie=Avg('calorie'), + count=Count('id'), + ) + type_nutrition[dt] = agg + + # Recent 10 dishes + recent = list( + Dish.objects.order_by('-created_at')[:10].values( + 'id', 'name', 'dish_type', 'cuisine', 'protein', 'fat', 'calorie', 'is_active', 'created_at', + ) + ) + for r in recent: + r['created_at'] = r['created_at'].strftime('%Y-%m-%d %H:%M') if r['created_at'] else '' + + return { + 'total': total, + 'active': active, + 'inactive': inactive, + 'type_counts': type_counts, + 'cuisine_counts': cuisine_counts, + 'nutrition': { + 'avg_protein': round(float(nutrition['avg_protein'] or 0), 1), + 'avg_fat': round(float(nutrition['avg_fat'] or 0), 1), + 'avg_calorie': round(float(nutrition['avg_calorie'] or 0)), + 'max_calorie': int(nutrition['max_calorie'] or 0), + 'min_calorie': int(nutrition['min_calorie'] or 0), + }, + 'type_nutrition': { + k: { + 'avg_protein': round(float(v['avg_protein'] or 0), 1), + 'avg_fat': round(float(v['avg_fat'] or 0), 1), + 'avg_calorie': round(float(v['avg_calorie'] or 0)), + 'count': v['count'], + } + for k, v in type_nutrition.items() + }, + 'recent': recent, + } + + +def dashboard_api(request): + """GET /api/dashboard/ -> JSON stats for the dashboard.""" + return JsonResponse(_dashboard_stats()) + + +def dashboard_view(request): + """GET /dashboard/ -> serve the dashboard HTML page.""" + return render(request, 'dashboard.html') diff --git a/backend/meals/views_spa.py b/backend/meals/views_spa.py new file mode 100644 index 0000000..d96e777 --- /dev/null +++ b/backend/meals/views_spa.py @@ -0,0 +1,56 @@ +import mimetypes +import os + +from django.http import FileResponse, HttpResponseNotFound +from django.views.decorators.csrf import ensure_csrf_cookie + +_FE_DIR_CACHE = None +_INDEX_CACHE = None + + +def _get_frontend_dir(): + global _FE_DIR_CACHE + if _FE_DIR_CACHE is None: + import sys + if getattr(sys, 'frozen', False): + _FE_DIR_CACHE = os.path.join(sys._MEIPASS, 'backend', 'static', 'frontend') + else: + env_dir = os.environ.get('FRONTEND_DIR') + if env_dir and os.path.isdir(env_dir): + _FE_DIR_CACHE = env_dir + else: + _FE_DIR_CACHE = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'static', 'frontend', + ) + return _FE_DIR_CACHE + + +def _get_index_path(): + global _INDEX_CACHE + if _INDEX_CACHE is None: + p = os.path.join(_get_frontend_dir(), 'index.html') + _INDEX_CACHE = p if os.path.isfile(p) else '' + return _INDEX_CACHE + + +def _serve_file(filepath): + ct, _ = mimetypes.guess_type(filepath) + return FileResponse(open(filepath, 'rb'), content_type=ct or 'application/octet-stream') + + +@ensure_csrf_cookie +def spa_serve(request, path=None): + """Serve built React assets or fall back to index.html.""" + fdir = _get_frontend_dir() + req_path = (path or '').lstrip('/') + + if req_path: + target = os.path.normpath(os.path.join(fdir, req_path)) + if target.startswith(os.path.normpath(fdir)) and os.path.isfile(target): + return _serve_file(target) + + index = _get_index_path() + if index: + return _serve_file(index) + return HttpResponseNotFound('Frontend not built. Run build.py first.') diff --git a/backend/requirements.txt b/backend/requirements.txt index 6d9a980..bfb9624 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,4 +4,6 @@ django-cors-headers==4.9.0 openpyxl==3.1.5 gunicorn==23.0.0 django-jazzmin==3.0.5 -pypinyin==0.54.0 + pypinyin==0.54.0 + rich==13.9.4 +pywebview>=5.0 diff --git a/backend/templates/admin/index.html b/backend/templates/admin/index.html new file mode 100644 index 0000000..7d816a3 --- /dev/null +++ b/backend/templates/admin/index.html @@ -0,0 +1,99 @@ +{% extends "admin/base_site.html" %} +{% load i18n static %} + +{% block extrastyle %} +{{ block.super }} + +{% endblock %} + +{% block coltype %}colMS{% endblock %} +{% block bodyclass %}{{ block.super }} dashboard{% endblock %} +{% block footer %}{% endblock %} + +{% block content %} +
+
+
+

{% trans "菜品类型分布" %}

+

{% trans "菜系分布" %}

+
+
+

{% trans "各类型营养对比" %}

+

{% trans "各类型菜品数量" %}

+
+
+
+

{% trans "最近添加的菜品" %}

+
{% trans "菜品名称" %}{% trans "类型" %}{% trans "菜系" %}{% trans "蛋白质" %}{% trans "脂肪" %}{% trans "热量" %}{% trans "状态" %}{% trans "创建时间" %}
+
+
+{% endblock %} + +{% block extrahead %} +{{ block.super }} + + +{% endblock %} diff --git a/backend/templates/admin/login.html b/backend/templates/admin/login.html new file mode 100644 index 0000000..8ad627d --- /dev/null +++ b/backend/templates/admin/login.html @@ -0,0 +1,35 @@ +{% extends "registration/base.html" %} + +{% block title %}登录 | {{ jazzmin_settings.site_title }}{% endblock %} + +{% block content %} + +
+ {% csrf_token %} +
+ +
+
+ {% if form.username.errors %} +
{{ form.username.errors.0 }}
+ {% endif %} +
+ +
+
+ {% if form.password.errors %} +
{{ form.password.errors.0 }}
+ {% endif %} + + {% if form.non_field_errors %} + {% for error in form.non_field_errors %} +
{{ error }}
+ {% endfor %} + {% endif %} +
+
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/backend/templates/dashboard.html b/backend/templates/dashboard.html new file mode 100644 index 0000000..13e8b9f --- /dev/null +++ b/backend/templates/dashboard.html @@ -0,0 +1,314 @@ + + + + + +学校订餐菜单 - 可视化管理面板 + + + + +
+
+
+
正在加载数据...
+
+
+ + + + diff --git a/backend/templates/index.html b/backend/templates/index.html new file mode 100644 index 0000000..1a73eb8 --- /dev/null +++ b/backend/templates/index.html @@ -0,0 +1,72 @@ + + + + + +学校订餐菜单系统 + + + +
+ + +
+
+
+
菜单生成器
+
自选菜品、生成营养 A/B 两套周菜单,支持替换与导出 Excel
+
+
+
+
后台管理
+
管理菜品数据、查看统计面板、系统配置
+
+
+
+ + + + \ No newline at end of file diff --git a/backend/templates/registration/password_change_done.html b/backend/templates/registration/password_change_done.html new file mode 100644 index 0000000..55cbb41 --- /dev/null +++ b/backend/templates/registration/password_change_done.html @@ -0,0 +1,91 @@ +{% extends "admin/base.html" %} +{% load i18n static jazzmin %} +{% get_jazzmin_settings request as jazzmin_settings %} +{% get_jazzmin_ui_tweaks as jazzmin_ui %} + +{% block title %}{% trans "Password change successful" %} | {{ jazzmin_settings.site_title }}{% endblock %} + +{% block bodyclass %}password-done-page{% endblock %} + +{% block extrastyle %} + +{% endblock %} + +{% block page_content %} +
+
+
+

{% trans "Password change successful" %}

+

{% trans "Your password has been updated." %}

+ + + {% trans "Back to admin" %} + +
+
+{% endblock %} diff --git a/backend/templates/registration/password_change_form.html b/backend/templates/registration/password_change_form.html new file mode 100644 index 0000000..71c0792 --- /dev/null +++ b/backend/templates/registration/password_change_form.html @@ -0,0 +1,296 @@ +{% extends "admin/base.html" %} +{% load i18n static jazzmin %} +{% get_jazzmin_settings request as jazzmin_settings %} +{% get_jazzmin_ui_tweaks as jazzmin_ui %} + +{% block title %}{% trans "Change password" %} | {{ jazzmin_settings.site_title }}{% endblock %} + +{% block bodyclass %}password-change-page{% endblock %} + +{% block extrastyle %} + +{% endblock %} + +{% block page_content %} +
+
+
+
+
+

{% trans "Change password" %}

+
{{ request.user }}
+
+
+
+ {% if form.errors %} + {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %}{{ error }}{% endfor %} +
+ {% endif %} + {% endif %} + +
+ {% csrf_token %} + +
+ +
+ + +
+ {% if form.old_password.errors %}
    {% for e in form.old_password.errors %}
  • {{ e }}
  • {% endfor %}
{% endif %} +
+ +
+ +
+ + +
+
+
+
+ 至小8位 + 含大写字母 + 含小写字母 + 含数字 +
+ {% if form.new_password1.errors %}
    {% for e in form.new_password1.errors %}
  • {{ e }}
  • {% endfor %}
{% endif %} +
+ +
+ +
+ + +
+
+ {% if form.new_password2.errors %}
    {% for e in form.new_password2.errors %}
  • {{ e }}
  • {% endfor %}
{% endif %} +
+ + +
+
+
+
+{% endblock %} + +{% block extrajs %} + +{% endblock %} diff --git a/build.py b/build.py new file mode 100644 index 0000000..2258089 --- /dev/null +++ b/build.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python +""" +Build script: compile frontend, then package everything into a distributable +folder via PyInstaller. Optionally build a Windows installer via Inno Setup. + +Usage: + python build.py # full build (windowed desktop EXE) + python build.py --clean # clean previous build artifacts first + python build.py --installer # build EXE then compile installer +""" +import argparse +import glob +import os +import shutil +import subprocess +import sys +from PyInstaller.utils.hooks import collect_submodules + +ROOT = os.path.dirname(os.path.abspath(__file__)) +BACKEND = os.path.join(ROOT, "backend") +FRONTEND = os.path.join(ROOT, "frontend") +DIST = os.path.join(ROOT, "dist") +BUILD = os.path.join(ROOT, "build") +INSTALLER_DIR = os.path.join(ROOT, "installer") +ISS_SCRIPT = os.path.join(ROOT, "school-meal-setup.iss") +FRONTEND_BUILD = os.path.join(FRONTEND, "dist") +STATIC_FE = os.path.join(BACKEND, "static", "frontend") +SPEC_FILE = os.path.join(ROOT, "school-meal.spec") + + +def run(cmd, cwd=None, shell=False): + print(f" > {cmd if isinstance(cmd, str) else ' '.join(cmd)}") + result = subprocess.run(cmd, cwd=cwd, shell=shell) + if result.returncode != 0: + print(f"ERROR: command failed with exit code {result.returncode}") + sys.exit(1) + + +def clean(): + print("[clean] Removing previous build artifacts...") + for d in [DIST, BUILD, STATIC_FE]: + if os.path.isdir(d): + shutil.rmtree(d) + for f in glob.glob(os.path.join(ROOT, "*.spec")): + os.remove(f) + + +def build_frontend(): + print("\n[1/3] Building React frontend...") + run("npm run build", cwd=FRONTEND, shell=True) + if not os.path.isdir(FRONTEND_BUILD): + print("ERROR: frontend/dist not found after build") + sys.exit(1) + print(" Copying to backend/static/frontend/") + if os.path.isdir(STATIC_FE): + shutil.rmtree(STATIC_FE) + shutil.copytree(FRONTEND_BUILD, STATIC_FE) + + +venv_site = ( + os.path.join(ROOT, ".venv", "Lib", "site-packages") + if os.path.isdir(os.path.join(ROOT, ".venv")) + else "" +) + + +def create_spec(): + """Generate the PyInstaller .spec file dynamically.""" + print("\n[2/3] Creating PyInstaller spec...") + + # Collect Jazzmin admin theme static files + jazzmin_static = os.path.join(venv_site, "jazzmin", "static") + datas_extra = [] + if os.path.isdir(jazzmin_static): + for root, dirs, files in os.walk(jazzmin_static): + rel = os.path.relpath(root, jazzmin_static) + for f in files: + src = os.path.join(root, f) + dst = os.path.join("static", rel) if rel != "." else "static" + datas_extra.append((src, dst)) + + datas = [ + (os.path.join(BACKEND, "static"), "backend/static"), + (os.path.join(BACKEND, "templates"), "backend/templates"), + (os.path.join(BACKEND, "meals", "migrations"), "backend/meals/migrations"), + (os.path.join(BACKEND, "config"), "backend/config"), + (os.path.join(BACKEND, "meals", "management"), "backend/meals/management"), + ] + datas_extra + + # Hidden imports + hidden = [] + for pkg in ["rest_framework", "corsheaders", "jazzmin", "rich"]: + try: + hidden.extend(collect_submodules(pkg)) + except Exception: + pass + hidden += [ + "meals.views_dashboard", "meals", "meals.apps", "meals.models", + "meals.admin", "meals.urls", "meals.views", "meals.views_spa", + "meals.serializers", "meals.management", "meals.management.commands", + "meals.management.commands.seed_dishes", + "meals.management.commands.import_dishes", + "config", "config.settings", "config.wsgi", "config.asgi", "config.urls", + "django.contrib.admin", "django.contrib.auth", + "django.contrib.contenttypes", "django.contrib.sessions", + "django.contrib.messages", "django.contrib.staticfiles", + "pypinyin", "openpyxl", + "tkinter", "tkinter.messagebox", "tkinter.constants", + "webview", "webview.window", "webview.platforms.edgechromium", + "pythonnet", "clr_loader", "cffi", "bottle", + ] + seen = set() + unique_hidden = [h for h in hidden if not (h in seen or seen.add(h))] + + # Build spec content using list of lines to avoid f-string quoting issues + desktop_py = os.path.join(ROOT, "desktop.py").replace("\\", "/") + root_path = ROOT.replace("\\", "/") + backend_path = BACKEND.replace("\\", "/") + icon_path = os.path.join(ROOT, "app.ico").replace("\\", "/") + + lines = [ + "# -*- mode: python ; coding: utf-8 -*-", + "# Auto-generated by build.py", + "block_cipher = None", + "", + "a = Analysis(", + f" [r'{desktop_py}'],", + f" pathex=[r'{root_path}', r'{backend_path}'],", + " binaries=[],", + " datas=[", + ] + for src, dst in datas: + lines.append(f" (r'{src}', r'{dst}'),") + lines += [ + " ],", + " hiddenimports=[", + ] + for h in unique_hidden: + lines.append(f" '{h}',") + lines += [ + " ],", + " hookspath=[],", + " hooksconfig={},", + " runtime_hooks=[],", + " excludes=[],", + " win_no_prefer_redirects=False,", + " win_private_assemblies=False,", + " cipher=block_cipher,", + " noarchive=False,", + ")", + "", + "pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)", + "", + "exe = EXE(", + " pyz,", + " a.scripts,", + " [],", + " exclude_binaries=True,", + " name='school-meal',", + " debug=False,", + " bootloader_ignore_signals=False,", + " strip=False,", + " upx=True,", + " console=False,", + " disable_windowed_traceback=False,", + " target_arch=None,", + " codesign_identity=None,", + " entitlements_file=None,", + f" icon=r'{icon_path}',", + ")", + "", + "coll = COLLECT(", + " exe,", + " a.binaries,", + " a.zipfiles,", + " a.datas,", + " strip=False,", + " upx=True,", + " upx_exclude=[],", + " name='school-meal',", + ")", + "", + ] + + with open(SPEC_FILE, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + print(f" Spec written to {SPEC_FILE}") + + +def run_pyinstaller(): + print("\n[3/3] Running PyInstaller...") + run([sys.executable, "-m", "PyInstaller", "--clean", "--noconfirm", SPEC_FILE]) + + out_dir = os.path.join(DIST, "school-meal") + bat_path = os.path.join(out_dir, "school-meal.bat") + with open(bat_path, "w", encoding="utf-8") as f: + f.write("@echo off\n") + f.write("chcp 65001 >nul\n") + f.write("cd /d %~dp0\n") + f.write('echo Starting school meal planner...\n') + f.write('start "" school-meal.exe\n') + + size_mb = sum( + os.path.getsize(os.path.join(dp, fn)) + for dp, _, fnames in os.walk(out_dir) + for fn in fnames + ) / (1024 * 1024) + print(f"\nBuild complete! Output: {out_dir}") + print(f"Total size: {size_mb:.1f} MB") + + +def find_iscc(): + """Locate the Inno Setup command-line compiler.""" + candidates = [ + os.path.join(os.environ.get("ProgramFiles(x86)", ""), "Inno Setup 6", "ISCC.exe"), + os.path.join(os.environ.get("ProgramFiles", ""), "Inno Setup 6", "ISCC.exe"), + os.path.join(os.environ.get("ProgramFiles(x86)", ""), "Inno Setup 5", "ISCC.exe"), + os.path.join(os.environ.get("ProgramFiles", ""), "Inno Setup 5", "ISCC.exe"), + ] + for path in candidates: + if os.path.isfile(path): + return path + from shutil import which + path = which("iscc") + if path: + return path + return None + + +def build_installer(): + """Compile the Inno Setup .iss script to produce an installer .exe.""" + print("\n[installer] Building Windows installer...") + + if not os.path.isfile(ISS_SCRIPT): + print(f"ERROR: {ISS_SCRIPT} not found. Cannot build installer.") + sys.exit(1) + + iscc = find_iscc() + if not iscc: + print("ERROR: Inno Setup compiler (ISCC.exe) not found.") + print("Please install Inno Setup 6 from https://jrsoftware.org/isdl.php") + sys.exit(1) + + print(f" Using: {iscc}") + run([iscc, ISS_SCRIPT]) + + if os.path.isdir(INSTALLER_DIR): + size_mb = sum( + os.path.getsize(os.path.join(dp, fn)) + for dp, _, fnames in os.walk(INSTALLER_DIR) + for fn in fnames + ) / (1024 * 1024) + print(f"\nInstaller built! Output: {INSTALLER_DIR}") + print(f"Installer size: {size_mb:.1f} MB") + else: + print("ERROR: Installer output directory not created.") + sys.exit(1) + + +def main(): + parser = argparse.ArgumentParser(description="Build school-meal package") + parser.add_argument("--clean", action="store_true", help="Clean previous build artifacts") + parser.add_argument("--installer", action="store_true", help="Also build Windows installer after EXE build") + args = parser.parse_args() + + if args.clean: + clean() + print("Clean done. Exiting.\n") + return + + os.chdir(ROOT) + build_frontend() + create_spec() + run_pyinstaller() + + if args.installer: + build_installer() + print("\nDone! Installer at installer/学校订餐菜单生成器-安装包.exe") + else: + print("\nDone! Run dist/school-meal/school-meal.exe to launch the desktop app.") + + +if __name__ == "__main__": + main() + diff --git a/build_exe.bat b/build_exe.bat new file mode 100644 index 0000000..b3da7c3 --- /dev/null +++ b/build_exe.bat @@ -0,0 +1,65 @@ +@echo off +chcp 65001 >nul +echo ========================================== +echo 学校订餐菜单生成器 - 一键打包 EXE +echo ========================================== +cd /d %~dp0 + +echo. +echo [1/4] 检查 Python 环境... +python --version >nul 2>&1 +if errorlevel 1 ( + echo 错误: 未找到 Python,请先安装 Python 3.10+ + echo 下载地址: https://www.python.org/downloads/ + pause + exit /b 1 +) +python --version + +echo. +echo [2/4] 安装/更新构建依赖... +pip install -r backend\requirements.txt pyinstaller --quiet +if errorlevel 1 ( + echo 依赖安装失败,请检查网络连接 + pause + exit /b 1 +) + +echo. +echo [3/4] 检查 Node.js 环境(构建前端需要)... +node --version >nul 2>&1 +if errorlevel 1 ( + echo 错误: 未找到 Node.js,请先安装 Node.js 18+ + echo 下载地址: https://nodejs.org/ + pause + exit /b 1 +) +node --version +cd frontend +if not exist node_modules ( + echo 安装前端依赖... + call npm install +) +cd .. + +echo. +echo [4/4] 开始构建 EXE(可能需要 2-5 分钟)... +echo. +python build.py +if errorlevel 1 ( + echo. + echo 构建失败!请检查上方错误信息。 + pause + exit /b 1 +) + +echo. +echo ========================================== +echo 构建完成! +echo ========================================== +echo 输出目录: dist\school-meal\ +echo 运行方式: 双击 dist\school-meal\school-meal.exe +echo 或双击: dist\school-meal\启动菜单生成器.bat +echo ========================================== +echo. +pause diff --git a/build_installer.bat b/build_installer.bat new file mode 100644 index 0000000..e6a9a5b --- /dev/null +++ b/build_installer.bat @@ -0,0 +1,81 @@ +@echo off +chcp 65001 >nul +echo ========================================== +echo 学校订餐菜单生成器 - 一键打包安装包 +echo ========================================== +cd /d %~dp0 + +echo. +echo [1/4] 检查 Python 环境... +python --version >nul 2>&1 +if errorlevel 1 ( + echo 错误: 未找到 Python,请先安装 Python 3.10+ + pause + exit /b 1 +) +python --version + +echo. +echo [2/4] 构建 EXE(如已构建可跳过)... +if not exist "dist\school-meal\school-meal.exe" ( + echo 未找到已构建的 EXE,开始构建... + python build.py + if errorlevel 1 ( + echo. + echo EXE 构建失败! + pause + exit /b 1 + ) +) else ( + echo 已检测到 dist\school-meal\school-meal.exe,跳过构建。 + echo 如需重新构建,请先执行 python build.py --clean +) + +echo. +echo [3/4] 检查 Inno Setup 编译器... +set "ISCC=" +if exist "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" ( + set "ISCC=C:\Program Files (x86)\Inno Setup 6\ISCC.exe" +) else if exist "C:\Program Files\Inno Setup 6\ISCC.exe" ( + set "ISCC=C:\Program Files\Inno Setup 6\ISCC.exe" +) else if exist "C:\Program Files (x86)\Inno Setup 5\ISCC.exe" ( + set "ISCC=C:\Program Files (x86)\Inno Setup 5\ISCC.exe" +) else ( + where iscc >nul 2>&1 + if not errorlevel 1 ( + set "ISCC=iscc" + ) +) + +if "%ISCC%"=="" ( + echo. + echo 错误: 未找到 Inno Setup 编译器! + echo. + echo 请安装 Inno Setup 6: + echo https://jrsoftware.org/isdl.php + echo. + echo 安装完成后请重新运行此脚本。 + pause + exit /b 1 +) +echo 找到 Inno Setup: %ISCC% + +echo. +echo [4/4] 编译安装包... +"%ISCC%" school-meal-setup.iss +if errorlevel 1 ( + echo. + echo 安装包编译失败!请检查上方错误信息。 + pause + exit /b 1 +) + +echo. +echo ========================================== +echo 安装包构建完成! +echo ========================================== +echo 输出目录: installer\ +echo 安装包: installer\学校订餐菜单生成器-安装包.exe +echo ========================================== +echo. +pause diff --git a/debug_server.py b/debug_server.py new file mode 100644 index 0000000..0f16860 --- /dev/null +++ b/debug_server.py @@ -0,0 +1,24 @@ +import os, sys, traceback +os.environ['DJANGO_SETTINGS_MODULE'] = 'config.settings' +os.environ['DJANGO_DB_PATH'] = r'C:\Users\12914\Desktop\school-meal\backend\db.sqlite3' +os.environ['DJANGO_SECRET_KEY'] = 'test-key' +os.environ['DJANGO_ALLOWED_HOSTS'] = '*' +os.environ['DJANGO_DEBUG'] = 'True' +os.environ['DJANGO_STATIC_URL'] = '/static' +sys.path.insert(0, r'C:\Users\12914\Desktop\school-meal\backend') + +import django +django.setup() + +from django.core.handlers.wsgi import WSGIHandler +from http.server import HTTPServer + +class DebugHandler(WSGIHandler): + def handle_exception(self, exc_info): + traceback.print_exception(*exc_info) + return super().handle_exception(exc_info) + +handler = DebugHandler() +server = HTTPServer(('127.0.0.1', 8001), handler) +print('Debug server on http://127.0.0.1:8001') +server.serve_forever() diff --git a/desktop.py b/desktop.py new file mode 100644 index 0000000..0eb39e5 --- /dev/null +++ b/desktop.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python +""" +Desktop launcher: run school meal planner inside a native window. +Starts Django backend (background thread), then opens a pywebview window. +""" +import os +import sys +import socket +import threading +import time +import traceback + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- + +_app_dir = None + + +def _get_app_dir(): + global _app_dir + if _app_dir is None: + if getattr(sys, "frozen", False): + _app_dir = os.path.dirname(sys.executable) + else: + _app_dir = os.path.dirname(os.path.abspath(__file__)) + return _app_dir + + +def _get_log_dir(): + """Return a writable directory for log files. + In frozen mode, prefer %LOCALAPPDATA% to avoid write errors in Program Files.""" + if getattr(sys, "frozen", False): + local = os.environ.get("LOCALAPPDATA", "") + if local: + d = os.path.join(local, "学校订餐菜单生成器") + try: + os.makedirs(d, exist_ok=True) + # quick write test + t = os.path.join(d, ".write_test") + with open(t, "w") as f: + f.write("") + os.remove(t) + return d + except Exception: + pass + return _get_app_dir() + + +def _log(msg): + try: + path = os.path.join(_get_log_dir(), "desktop.log") + with open(path, "a", encoding="utf-8") as f: + f.write(msg + "\n") + except Exception: + pass + + +def _excepthook(exc_type, exc_value, exc_tb): + _log("FATAL: " + "".join(traceback.format_exception(exc_type, exc_value, exc_tb))) + + +sys.excepthook = _excepthook + +# --------------------------------------------------------------------------- +# Fix for console=False mode: ensure stdout/stderr are never None +# --------------------------------------------------------------------------- + +def _ensure_stdio(): + """Redirect stdout/stderr to log file if they are None (console=False).""" + if sys.stdout is None or sys.stderr is None: + log_path = os.path.join(_get_log_dir(), "django.log") + try: + log_file = open(log_path, "a", encoding="utf-8", buffering=1) + except Exception: + log_file = open(os.path.join(_get_app_dir(), "django.log"), "a", encoding="utf-8", buffering=1) + if sys.stdout is None: + sys.stdout = log_file + if sys.stderr is None: + sys.stderr = log_file + + +_ensure_stdio() + +# --------------------------------------------------------------------------- +# Environment +# --------------------------------------------------------------------------- + + +def _frozen_dir(): + if getattr(sys, "frozen", False): + return sys._MEIPASS + return os.path.dirname(os.path.abspath(__file__)) + + +def setup_environment(): + base = _frozen_dir() + data_dir = _get_log_dir() + sys.path.insert(0, base) + backend_dir = os.path.join(base, "backend") + if os.path.isdir(backend_dir) and backend_dir not in sys.path: + sys.path.insert(0, backend_dir) + + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + os.environ["DJANGO_DEBUG"] = "False" + os.environ["DJANGO_SECRET_KEY"] = "exe-local-secret-key-do-not-use-in-production" + os.environ["DJANGO_ALLOWED_HOSTS"] = "127.0.0.1,localhost" + os.environ["DJANGO_DB_PATH"] = os.path.join(data_dir, "db.sqlite3") + os.environ["DJANGO_STATIC_URL"] = "/static/" + os.environ["EXE_MODE"] = "1" + + static_fe = os.path.join(base, "backend", "static", "frontend") + if not os.path.isdir(static_fe): + static_fe = os.path.join(base, "static", "frontend") + os.environ["FRONTEND_DIR"] = static_fe + + +def _ensure_database(): + """Copy the bundled db.sqlite3 to the data directory if missing or outdated. + + In the frozen EXE, migrations cannot be discovered from the PyInstaller + data directory, so we ship a pre-migrated db.sqlite3 and copy it on first + run. If the user's existing database has the wrong schema (e.g. upgraded + from an older EXE), replace it with the bundled one. + """ + if not getattr(sys, "frozen", False): + return + + data_dir = _get_log_dir() + target_db = os.path.join(data_dir, "db.sqlite3") + bundled_db = os.path.join(_get_app_dir(), "db.sqlite3") + + if not os.path.isfile(bundled_db): + return + + need_copy = False + if not os.path.isfile(target_db): + need_copy = True + else: + import sqlite3 + try: + conn = sqlite3.connect(target_db) + cols = [r[1] for r in conn.execute("PRAGMA table_info(meals_dish)").fetchall()] + conn.close() + if "cuisine_id" not in cols: + need_copy = True + except Exception: + need_copy = True + + if need_copy: + os.makedirs(data_dir, exist_ok=True) + import shutil + shutil.copy2(bundled_db, target_db) + _log(f"Copied bundled db.sqlite3 to {target_db}") + + +def init_database(): + _ensure_database() + + import django + django.setup() + from django.core.management import call_command + call_command("migrate", "--run-syncdb", verbosity=0) + + from meals.models import Dish + if Dish.objects.count() == 0: + try: + call_command("seed_dishes", verbosity=0) + except Exception: + pass + + from django.contrib.auth import get_user_model + User = get_user_model() + if not User.objects.filter(username="admin").exists(): + User.objects.create_superuser("admin", "admin@example.com", "admin123") + + +# --------------------------------------------------------------------------- +# Django server +# --------------------------------------------------------------------------- + +PORT = 8000 + + +def _port_in_use(port): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + return s.connect_ex(("127.0.0.1", port)) == 0 + + +def start_django_server(): + try: + _log("start_django_server: calling django.setup()") + import django + django.setup() + _log("start_django_server: calling runserver") + from django.core.management import call_command + call_command("runserver", f"127.0.0.1:{PORT}", "--noreload", verbosity=0) + _log("start_django_server: runserver returned") + except Exception: + _log("start_django_server CRASHED:\n" + traceback.format_exc()) + + +def wait_for_server(timeout=30): + deadline = time.time() + timeout + while time.time() < deadline: + if _port_in_use(PORT): + return True + time.sleep(0.3) + return False + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +WINDOW_TITLE = "学校订餐菜单生成器" +WINDOW_WIDTH = 1280 +WINDOW_HEIGHT = 800 + + +def _get_icon_path(): + """Return the path to the application icon file.""" + return os.path.join(_get_app_dir(), "app.ico") + + +def main(): + _log("=== desktop.py main() starting ===") + + setup_environment() + _log("Environment setup done") + + init_database() + _log("Database init done") + + server_thread = threading.Thread(target=start_django_server, daemon=True) + server_thread.start() + _log("Django thread started, waiting for port 8000...") + + if not wait_for_server(): + _log("ERROR: Django server startup timed out!") + sys.exit(1) + + _log("Django server ready, opening webview...") + + import webview + + url = f"http://127.0.0.1:{PORT}" + + # --------------------------------------------------------------------------- + # JS API: opens new native windows from the index page + # --------------------------------------------------------------------------- + _child_windows = [] + + class JsApi: + """Exposed to JavaScript as window.pywebview.api.""" + + def open_page(self, path, title=None, width=1280, height=800): + """Open a new pywebview window for the given path.""" + full_url = f"http://127.0.0.1:{PORT}{path}" + win_title = title or WINDOW_TITLE + w = webview.create_window( + win_title, + full_url, + width=int(width), + height=int(height), + min_size=(900, 600), + resizable=True, + text_select=True, + ) + _child_windows.append(w) + return True + + js_api = JsApi() + + window = webview.create_window( + WINDOW_TITLE, + url, + width=WINDOW_WIDTH, + height=WINDOW_HEIGHT, + min_size=(900, 600), + resizable=True, + text_select=True, + js_api=js_api, + ) + + _log("Starting webview event loop...") + webview.start(debug=False) + _log("Webview closed.") + + +if __name__ == "__main__": + try: + main() + except Exception: + _log("UNHANDLED: " + traceback.format_exc()) + raise diff --git a/fix_run.py b/fix_run.py new file mode 100644 index 0000000..0230c6a --- /dev/null +++ b/fix_run.py @@ -0,0 +1,21 @@ +import os + +FILE = r"C:\Users\12914\Desktop\school-meal\run.py" +with open(FILE, "r", encoding="utf-8") as f: + content = f.read() + +# The setup_environment function has: sys.path.insert(0, base) +# But base is the project root. The config module is in backend/. +# In dev mode, we need to also add backend/ to sys.path. +old = " sys.path.insert(0, base)\n os.environ.setdefault" +new = " sys.path.insert(0, base)\n # In dev mode, add backend/ to path so config and meals are importable\n backend_dir = os.path.join(base, 'backend')\n if os.path.isdir(backend_dir) and backend_dir not in sys.path:\n sys.path.insert(0, backend_dir)\n os.environ.setdefault" + +if old in content: + content = content.replace(old, new, 1) + print("Fixed sys.path") +else: + print("Pattern not found") + +with open(FILE, "w", encoding="utf-8") as f: + f.write(content) +print("Done") \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 2c50180..0c8b4b5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,13 @@ + + + + + + 学校订餐菜单生成器 diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..590a0ea Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/icon.png b/frontend/public/icon.png new file mode 100644 index 0000000..d6a3c38 Binary files /dev/null and b/frontend/public/icon.png differ diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg new file mode 100644 index 0000000..ff63b80 --- /dev/null +++ b/frontend/public/icon.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/icon_512.png b/frontend/public/icon_512.png new file mode 100644 index 0000000..a321f7e Binary files /dev/null and b/frontend/public/icon_512.png differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a7828ac..c2fbadb 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useMemo, useCallback } from 'react' import { pinyin } from 'pinyin-pro' const DAYS = ['周一', '周二', '周三', '周四', '周五'] @@ -18,7 +18,34 @@ const TYPE_GROUPS = [ ] const TYPE_LABELS = { meats: '荤菜', vegs: '素菜', soup: '营养汤', staple: '主食' } const TYPE_TO_TAB = { meats: 'meat', vegs: 'veg', soup: 'soup', staple: 'staple' } -const DEFAULT_SCHOOL = '上海外国语大学附属宝山双语学校' +const TYPE_ALL_LABELS = { meat: '荤菜', veg: '素菜', soup: '营养汤', staple: '主食' } +const DEFAULT_SCHOOL = '学校菜单' +const MODAL_PAGE_SIZE = 20 + +/** Compute ISO week Monday/Friday as Date objects */ +const weekDates = (weekNo) => { + const now = new Date() + const isoYear = now.getFullYear() + const jan4 = new Date(isoYear, 0, 4) + const startOfISOWeek1 = new Date(jan4) + startOfISOWeek1.setDate(jan4.getDate() - ((jan4.getDay() + 6) % 7)) + const monday = new Date(startOfISOWeek1) + monday.setDate(startOfISOWeek1.getDate() + (weekNo - 1) * 7) + const friday = new Date(monday) + friday.setDate(monday.getDate() + 4) + return { monday, friday } +} + +/** Format Date as M.D for title */ +const fmtDateShort = (d) => `${d.getMonth() + 1}.${d.getDate()}` + +/** Format Date as YYYY-MM-DD for date input */ +const fmtDateISO = (d) => { + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} const currentIsoWeek = () => { const now = new Date() @@ -33,8 +60,147 @@ const currentIsoWeek = () => { const slotKey = (menu, day, slot, idx) => `${menu}|${day}|${slot}|${idx}` +/* ============================ + Modern Pagination Component + ============================ */ +function Pagination({ page, total, pageSize, onChange }) { + const totalPages = Math.max(1, Math.ceil(total / pageSize)) + const currentPage = Math.min(Math.max(1, page), totalPages) + if (totalPages <= 1) return null + + const go = (p) => onChange(Math.min(Math.max(1, p), totalPages)) + + // Build page number list with ellipsis + const pages = [] + for (let i = 1; i <= totalPages; i++) { + if (i === 1 || i === totalPages || (i >= currentPage - 1 && i <= currentPage + 1)) { + pages.push(i) + } else if (pages.length > 0 && pages[pages.length - 1] !== '...') { + pages.push('...') + } + } + + const startItem = (currentPage - 1) * pageSize + 1 + const endItem = Math.min(currentPage * pageSize, total) + + return ( +
+
+ 显示 {startItem}-{endItem} / 共 {total} 项 +
+
+ + + {pages.map((p, i) => + p === '...' ? ( + … + ) : ( + + ), + )} + + + + 跳至 + { + const v = parseInt(e.target.value) + if (v >= 1 && v <= totalPages) go(v) + }} + className="pg-jump-input" + /> + 页 + +
+
+ ) +} + +/* ============================ + FlatDishList — paginated dish grid for modals + ============================ */ +function FlatDishList({ items, currentId, hover, setHover, onSelect, renderItem }) { + const [page, setPage] = useState(1) + + // Reset page when items change (e.g. search/filter) + const itemsKey = items.map(d => d.id).join(',') + const prevItemsKey = useMemo(() => itemsKey, []) + if (itemsKey !== prevItemsKey) { + // Use effect-less pattern: we'll reset via key prop + } + + const startIdx = (page - 1) * MODAL_PAGE_SIZE + const pageItems = items.slice(startIdx, startIdx + MODAL_PAGE_SIZE) + + return ( +
+
+ {pageItems.map((d) => ( + + ))} + {pageItems.length === 0 &&

没有匹配的菜品

} +
+ +
+ ) +} + function SwapModal({ editing, menus, dishes, pinyinMap, swapKeyword, swapType, setSwapKeyword, setSwapType, onReplace, onClose }) { const [hover, setHover] = useState(null) + const [swapPage, setSwapPage] = useState(1) + + // Reset page on filter/search change + useEffect(() => { setSwapPage(1) }, [swapKeyword, swapType]) + if (!editing || !menus) return null const { menuKey, day, type, idx } = editing @@ -51,9 +217,20 @@ function SwapModal({ editing, menus, dishes, pinyinMap, swapKeyword, swapType, s 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) + + const flatList = useMemo(() => { + const result = [] + const groups = (swapType === 'all' ? TYPE_GROUPS : TYPE_GROUPS.filter(([t]) => t === swapType)) + for (const [t] of groups) { + for (const d of dishes[t]) { + if (match(d)) result.push(d) + } + } + return result + }, [swapType, kw, dishes]) + + const startIdx = (swapPage - 1) * MODAL_PAGE_SIZE + const pageItems = flatList.slice(startIdx, startIdx + MODAL_PAGE_SIZE) return (
e.stopPropagation()}>

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

@@ -83,43 +260,32 @@ function SwapModal({ editing, menus, dishes, pinyinMap, swapKeyword, swapType, s
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 &&

没有匹配的菜品

} +
+
+ {pageItems.map((d) => ( + + ))} + {pageItems.length === 0 &&

没有匹配的菜品

} +
+ +
{hover && } @@ -129,6 +295,11 @@ function SwapModal({ editing, menus, dishes, pinyinMap, swapKeyword, swapType, s function PickModal({ pick, slots, dishes, pinyinMap, keyword, pickType, setKeyword, setPickType, onChoose, onClose }) { const [hover, setHover] = useState(null) + const [pickPage, setPickPage] = useState(1) + + // Reset page on filter/search change + useEffect(() => { setPickPage(1) }, [keyword, pickType]) + if (!pick) return null const { menu, day, slot, idx } = pick const key = slotKey(menu, day, slot, idx) @@ -141,9 +312,20 @@ function PickModal({ pick, slots, dishes, pinyinMap, keyword, pickType, setKeywo 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) + + const flatList = useMemo(() => { + const result = [] + const groups = (pickType === 'all' ? TYPE_GROUPS : TYPE_GROUPS.filter(([t]) => t === pickType)) + for (const [t] of groups) { + for (const d of dishes[t]) { + if (match(d)) result.push(d) + } + } + return result + }, [pickType, kw, dishes]) + + const startIdx = (pickPage - 1) * MODAL_PAGE_SIZE + const pageItems = flatList.slice(startIdx, startIdx + MODAL_PAGE_SIZE) return (
e.stopPropagation()}>

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

@@ -175,43 +357,32 @@ function PickModal({ pick, slots, dishes, pinyinMap, keyword, pickType, setKeywo
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 &&

没有匹配的菜品

} +
+
+ {pageItems.map((d) => ( + + ))} + {pageItems.length === 0 &&

没有匹配的菜品

} +
+ +
{hover && } @@ -248,6 +419,12 @@ function App() { const [error, setError] = useState('') const [schoolName, setSchoolName] = useState(DEFAULT_SCHOOL) const [weekNo, setWeekNo] = useState(String(currentIsoWeek())) + + const initDates = weekDates(Number(currentIsoWeek())) + + const [dateStart, setDateStart] = useState(fmtDateISO(initDates.monday)) + + const [dateEnd, setDateEnd] = useState(fmtDateISO(initDates.friday)) const [pinyinMap, setPinyinMap] = useState({}) useEffect(() => { @@ -262,6 +439,14 @@ function App() { setPinyinMap(map) }, [dishes]) + useEffect(() => { + if (weekNo) { + const d = weekDates(Number(weekNo)) + setDateStart(fmtDateISO(d.monday)) + setDateEnd(fmtDateISO(d.friday)) + } + }, [weekNo]) + useEffect(() => { fetch('/api/dishes/') .then((r) => r.json()) @@ -294,6 +479,8 @@ function App() { selections, school_name: schoolName.trim() || DEFAULT_SCHOOL, week_no: Number(weekNo), + date_start: dateStart || undefined, + date_end: dateEnd || undefined, }), }) const data = await res.json() @@ -479,7 +666,7 @@ function App() { } 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菜单'}

@@ -602,8 +789,50 @@ function App() {
+
+ +
+ + + +
+ + setDateStart(e.target.value)} + + /> + + {'—'} + + setDateEnd(e.target.value)} + + /> + +
+ +
+ +
+

- 标题预览:{schoolName.trim() || DEFAULT_SCHOOL}[第{weekNo || '?'}周]日期营养X菜单 + + {`标题预览:${schoolName.trim() || DEFAULT_SCHOOL}[第${weekNo || '?'}周]${dateStart && dateEnd ? `${fmtDateShort(new Date(dateStart))}--${fmtDateShort(new Date(dateEnd))}` : `日期`}营养A菜单`} + +
+ + {`${schoolName.trim() || DEFAULT_SCHOOL}[第${weekNo || '?'}周]${dateStart && dateEnd ? `${fmtDateShort(new Date(dateStart))}--${fmtDateShort(new Date(dateEnd))}` : `日期`}营养B菜单`} +

@@ -674,4 +903,4 @@ function App() { ) } -export default App +export default App \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index 266399c..a9b0c81 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,44 +1,104 @@ -* { +/* ============================================ + 学校订餐菜单生成器 — Modern Design System + Based on 2025-2026 mainstream web trends + ============================================ */ + +:root { + --c-bg: #f4f6f9; + --c-surface: #ffffff; + --c-surface-alt: #f9fafb; + --c-text: #1a1d23; + --c-text-secondary: #64748b; + --c-text-muted: #94a3b8; + --c-border: #e2e8f0; + --c-border-light: #f1f5f9; + --c-accent: #6366f1; + --c-accent-hover: #4f46e5; + --c-accent-light: #eef2ff; + --c-accent-text: #ffffff; + --c-success: #10b981; + --c-success-light: #ecfdf5; + --c-danger: #ef4444; + --c-danger-light: #fef2f2; + --c-warning: #f59e0b; + --c-warning-light: #fffbeb; + --c-meat: #f97316; + --c-meat-bg: #fff7ed; + --c-veg: #22c55e; + --c-veg-bg: #f0fdf4; + --c-soup: #3b82f6; + --c-soup-bg: #eff6ff; + --c-staple: #eab308; + --c-staple-bg: #fefce8; + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-xl: 20px; + --shadow-sm: 0 1px 2px rgba(0,0,0,0.04); + --shadow-md: 0 4px 12px rgba(0,0,0,0.06); + --shadow-lg: 0 8px 30px rgba(0,0,0,0.08); + --shadow-xl: 0 20px 60px rgba(0,0,0,0.12); + --transition: 0.2s cubic-bezier(0.4,0,0.2,1); +} + +*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } body { - font-family: 'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif; - background: #eef2f7; - color: #2c3e50; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif; + background: var(--c-bg); + color: var(--c-text); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } +/* ---- Page layout ---- */ .page { - max-width: 1920px; + max-width: 1280px; margin: 0 auto; - padding: 32px 20px 60px; + padding: 24px 20px 60px; } +/* ---- Hero / Header ---- */ .hero { text-align: center; - padding: 18px 0 28px; + padding: 36px 0 32px; } .hero h1 { - font-size: 30px; - color: #1f3a5f; - margin-bottom: 8px; + font-size: 28px; + font-weight: 700; + color: var(--c-text); + letter-spacing: -0.02em; + margin-bottom: 6px; } .hero p { - color: #6b7a90; + color: var(--c-text-secondary); + font-size: 14px; + max-width: 600px; + margin: 0 auto; } +/* ---- Panels / Cards ---- */ .panel { - background: #fff; - border-radius: 14px; + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-lg); padding: 24px; - box-shadow: 0 4px 18px rgba(31, 58, 95, 0.08); - margin-bottom: 24px; + margin-bottom: 20px; + transition: box-shadow var(--transition); } +.panel:hover { + box-shadow: var(--shadow-md); +} + +/* ---- Form fields ---- */ .field { margin-bottom: 20px; } @@ -46,58 +106,72 @@ body { .label { display: block; font-weight: 600; - margin-bottom: 10px; - color: #1f3a5f; + font-size: 13px; + color: var(--c-text); + margin-bottom: 8px; + letter-spacing: 0.01em; } +/* ---- Segmented control ---- */ .seg { display: inline-flex; - background: #eef2f7; - border-radius: 10px; - padding: 4px; - gap: 4px; + background: var(--c-bg); + border-radius: var(--radius-md); + padding: 3px; + gap: 2px; } .seg-btn { border: none; background: transparent; - padding: 9px 22px; - border-radius: 8px; - font-size: 14px; + padding: 8px 18px; + border-radius: 7px; + font-size: 13px; + font-weight: 500; cursor: pointer; - color: #4a5a72; + color: var(--c-text-secondary); + transition: all var(--transition); +} + +.seg-btn:hover { + color: var(--c-text); } .seg-btn.active { - background: #2f5597; - color: #fff; + background: var(--c-surface); + color: var(--c-accent); font-weight: 600; + box-shadow: var(--shadow-sm); } +/* ---- Chips ---- */ .chips { display: flex; flex-wrap: wrap; - gap: 10px; + gap: 8px; } .chip { - border: 1.5px solid #d5deea; - background: #fff; - padding: 8px 18px; + border: 1.5px solid var(--c-border); + background: var(--c-surface); + padding: 7px 16px; border-radius: 999px; - font-size: 14px; + font-size: 13px; + font-weight: 500; cursor: pointer; - transition: all 0.15s; + transition: all var(--transition); + color: var(--c-text-secondary); } .chip:hover:not(:disabled) { - border-color: #2f5597; + border-color: var(--c-accent); + color: var(--c-accent); } .chip.on { - background: #2f5597; - border-color: #2f5597; - color: #fff; + background: var(--c-accent); + border-color: var(--c-accent); + color: var(--c-accent-text); font-weight: 600; } @@ -106,27 +180,45 @@ body { cursor: not-allowed; } -.row-fields { - display: flex; - align-items: flex-end; - gap: 40px; - flex-wrap: wrap; -} - +/* ---- Form inputs ---- */ select { - padding: 9px 14px; - border: 1.5px solid #d5deea; - border-radius: 8px; - font-size: 14px; - background: #fff; + padding: 8px 12px; + border: 1.5px solid var(--c-border); + border-radius: var(--radius-sm); + font-size: 13px; + background: var(--c-surface); min-width: 120px; + transition: border-color var(--transition); } +select:focus { + outline: none; + border-color: var(--c-accent); +} + +.pick-search { + flex: 1; + min-width: 160px; + padding: 8px 14px; + border: 1.5px solid var(--c-border); + border-radius: var(--radius-sm); + font-size: 13px; + outline: none; + background: var(--c-surface); + transition: border-color var(--transition), box-shadow var(--transition); +} + +.pick-search:focus { + border-color: var(--c-accent); + box-shadow: 0 0 0 3px var(--c-accent-light); +} + +/* ---- Checkboxes ---- */ .checks { display: flex; - gap: 24px; + gap: 20px; padding-bottom: 10px; - font-size: 14px; + font-size: 13px; } .checks label { @@ -136,91 +228,120 @@ select { cursor: pointer; } +/* ---- Buttons ---- */ .actions { display: flex; - gap: 14px; + gap: 10px; margin-top: 6px; + flex-wrap: wrap; } .btn { - padding: 12px 32px; - border: 1.5px solid #2f5597; - background: #fff; - color: #2f5597; - border-radius: 10px; - font-size: 15px; - font-weight: 600; + padding: 10px 24px; + border: 1.5px solid var(--c-border); + background: var(--c-surface); + color: var(--c-text); + border-radius: var(--radius-md); + font-size: 14px; + font-weight: 500; cursor: pointer; - transition: all 0.15s; + transition: all var(--transition); } .btn:hover:not(:disabled) { - background: #eaf0fa; + background: var(--c-bg); + border-color: var(--c-accent); + color: var(--c-accent); } .btn.primary { - background: #2f5597; - color: #fff; + background: var(--c-accent); + border-color: var(--c-accent); + color: var(--c-accent-text); + font-weight: 600; } .btn.primary:hover:not(:disabled) { - background: #24447c; + background: var(--c-accent-hover); + border-color: var(--c-accent-hover); + box-shadow: 0 4px 14px rgba(99,102,241,0.3); } .btn:disabled { - opacity: 0.45; + opacity: 0.4; cursor: not-allowed; } -.error { - margin-top: 14px; - color: #c0392b; - font-size: 14px; +.btn.small { + padding: 5px 14px; + font-size: 12px; + border-radius: var(--radius-sm); } +/* ---- Error ---- */ +.error { + margin-top: 14px; + color: var(--c-danger); + font-size: 13px; + font-weight: 500; +} + +/* ---- Panel head ---- */ .panel-head { display: flex; align-items: baseline; - gap: 16px; + gap: 12px; margin-bottom: 16px; + flex-wrap: wrap; } .panel-head h2 { - font-size: 18px; - color: #1f3a5f; + font-size: 16px; + font-weight: 700; + color: var(--c-text); } +.tip { + font-size: 12px; + color: var(--c-text-muted); +} + +/* ---- Tables ---- */ .table-scroll { overflow-x: auto; + border-radius: var(--radius-md); + border: 1px solid var(--c-border); } table { width: 100%; border-collapse: collapse; - font-size: 14px; + font-size: 13px; } table.excel-like caption { - background: #2f5597; + background: linear-gradient(135deg, var(--c-accent), #818cf8); color: #fff; - font-size: 16px; + font-size: 15px; 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; + border: 1px solid var(--c-border); + padding: 10px 12px; text-align: center; } table.excel-like th { - background: #2f5597; - color: #fff; + background: var(--c-surface-alt); + color: var(--c-text); font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; min-width: 70px; } @@ -231,36 +352,39 @@ table.excel-like td { td.day { font-weight: 700; - color: #1f3a5f; - background: #d9e2f3; + color: var(--c-accent); + background: var(--c-accent-light); vertical-align: middle; } td.meat { - background: #fce4d6; - color: #9a4a12; + background: var(--c-meat-bg); + color: #c2410c; } td.veg { - background: #e2efda; - color: #2d6a2d; + background: var(--c-veg-bg); + color: #15803d; } td.staple { - background: #fde9d9; + background: var(--c-staple-bg); + color: #a16207; } td.soup { - background: #ddebf7; + background: var(--c-soup-bg); + color: #1d4ed8; } td.nutrition { - background: #fff2cc; + background: var(--c-warning-light); font-weight: 600; - font-size: 13px; - color: #7a5c00; + font-size: 12px; + color: #92400e; } +/* ---- Dish cells ---- */ .dish-cell { position: relative; padding: 4px 2px; @@ -269,15 +393,15 @@ td.nutrition { .swap-btn { display: block; margin: 4px auto 0; - border: 1px solid #b9c4d4; - background: #fff; - color: #5a6b84; + border: 1px solid var(--c-border); + background: var(--c-surface); + color: var(--c-text-muted); font-size: 11px; - border-radius: 6px; + border-radius: var(--radius-sm); padding: 1px 10px; cursor: pointer; opacity: 0; - transition: opacity 0.15s; + transition: all var(--transition); } td:hover .swap-btn, @@ -286,34 +410,50 @@ td:hover .swap-btn, } .swap-btn:hover { - border-color: #2f5597; - color: #2f5597; + border-color: var(--c-accent); + color: var(--c-accent); + background: var(--c-accent-light); } +/* ---- Modal ---- */ .modal-overlay { position: fixed; inset: 0; - background: rgba(15, 28, 47, 0.5); + background: rgba(0,0,0,0.4); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); z-index: 100; display: flex; align-items: center; justify-content: center; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } } .modal { - background: #fff; - border-radius: 14px; - width: min(760px, 94vw); + background: var(--c-surface); + border-radius: var(--radius-xl); + width: min(780px, 94vw); max-height: 80vh; display: flex; flex-direction: column; - box-shadow: 0 20px 60px rgba(15, 28, 47, 0.35); + box-shadow: var(--shadow-xl); overflow: hidden; + animation: slideUp 0.25s ease; +} + +@keyframes slideUp { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } } .modal-head { - padding: 16px 20px; - border-bottom: 1px solid #e3e9f2; + padding: 18px 22px; + border-bottom: 1px solid var(--c-border-light); display: flex; justify-content: space-between; align-items: center; @@ -321,26 +461,40 @@ td:hover .swap-btn, } .modal-head h3 { - font-size: 16px; - color: #1f3a5f; + font-size: 15px; + font-weight: 700; + color: var(--c-text); } .modal-close { border: none; - background: none; - font-size: 24px; + background: var(--c-bg); + font-size: 18px; line-height: 1; - color: #8a97a8; + color: var(--c-text-muted); cursor: pointer; - padding: 0 4px; + padding: 4px 8px; + border-radius: var(--radius-sm); + transition: all var(--transition); } .modal-close:hover { - color: #c0392b; + color: var(--c-danger); + background: var(--c-danger-light); +} + +.modal-toolbar { + padding: 12px 22px; + border-bottom: 1px solid var(--c-border-light); + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + flex-shrink: 0; } .modal-body { - padding: 18px 20px 22px; + padding: 18px 22px 22px; overflow-y: auto; } @@ -350,15 +504,17 @@ td:hover .swap-btn, .modal-group-label { font-weight: 700; - color: #2f5597; + color: var(--c-accent); margin-bottom: 8px; - font-size: 13px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; } .modal-items { display: flex; flex-wrap: wrap; - gap: 8px; + gap: 6px; } .modal-item { @@ -366,42 +522,51 @@ td:hover .swap-btn, flex-direction: column; align-items: flex-start; gap: 2px; - border: 1.5px solid #d5deea; - background: #fff; - border-radius: 8px; - padding: 8px 12px; + border: 1.5px solid var(--c-border); + background: var(--c-surface); + border-radius: var(--radius-sm); + padding: 7px 12px; cursor: pointer; font-size: 13px; - color: #2c3e50; - transition: all 0.15s; + color: var(--c-text); + transition: all var(--transition); } .modal-item:hover { - border-color: #2f5597; - background: #eaf0fa; + border-color: var(--c-accent); + background: var(--c-accent-light); } .modal-item.current { - border-color: #2f5597; - background: #2f5597; + border-color: var(--c-accent); + background: var(--c-accent); color: #fff; } .modal-item .item-ing { font-size: 11px; - color: #8a97a8; + color: var(--c-text-muted); } .modal-item.current .item-ing { - color: #cfe0f5; + color: rgba(255,255,255,0.7); } +/* ---- Hints ---- */ .hint { - font-size: 13px; - color: #6b7a90; + font-size: 12px; + color: var(--c-text-muted); padding-bottom: 10px; + line-height: 1.6; } +.hint.warn { + color: var(--c-danger); + margin-top: 12px; + padding-bottom: 0; +} + +/* ---- Label row ---- */ .label-row { display: flex; align-items: center; @@ -413,84 +578,80 @@ td:hover .swap-btn, 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; -} - +/* ---- Title fields ---- */ .title-fields { display: flex; align-items: flex-end; - gap: 24px; + gap: 20px; flex-wrap: wrap; - background: #f7f9fc; - border: 1px solid #e3e9f2; - border-radius: 12px; + background: var(--c-surface-alt); + border: 1px solid var(--c-border-light); + border-radius: var(--radius-md); padding: 16px 18px; margin-bottom: 20px; } .title-field .label { margin-bottom: 6px; - font-size: 13px; + font-size: 12px; } .title-field input.pick-search { - min-width: 320px; + min-width: 280px; } .title-field.week-field input[type='number'] { - width: 90px; - padding: 9px 12px; - border: 1.5px solid #d5deea; - border-radius: 8px; - font-size: 14px; + width: 80px; + padding: 8px 10px; + border: 1.5px solid var(--c-border); + border-radius: var(--radius-sm); + font-size: 13px; } .week-input { display: flex; - gap: 10px; + gap: 8px; align-items: center; } .title-preview { width: 100%; margin: 8px 0 0; - color: #2f5597; + color: var(--c-accent); font-weight: 600; + font-size: 12px; +} + +/* ---- Slot grid ---- */ +.slot-row { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + align-items: start; } .slot-panel { - border: 1px solid #e3e9f2; - border-radius: 12px; - padding: 16px; - background: #fbfcfe; + border: 1px solid var(--c-border); + border-radius: var(--radius-md); + padding: 14px; + background: var(--c-surface-alt); } .slot-panel .panel-head { - margin-bottom: 12px; + margin-bottom: 10px; } .slot-panel .panel-head h2 { - font-size: 15px; + font-size: 14px; } .slot-panel .tip { - font-size: 12px; - color: #8a97a8; + font-size: 11px; } table.excel-like.slot-table th { - min-width: 84px; - font-size: 13px; + min-width: 80px; + font-size: 12px; } table.excel-like.slot-table td { @@ -499,16 +660,16 @@ table.excel-like.slot-table td { td.slot-cell { cursor: pointer; - transition: background 0.15s; - min-height: 56px; + transition: background var(--transition); + min-height: 52px; } td.slot-cell:hover { - background: #eaf0fa; + background: var(--c-accent-light); } td.slot-cell.filled { - background: #f4f8ff; + background: #f8faff; } td.slot-cell .dish-name { @@ -516,73 +677,118 @@ td.slot-cell .dish-name { } .slot-empty { - color: #aab6c6; - font-size: 13px; + color: var(--c-text-muted); + font-size: 12px; } .slot-clear { display: block; - margin: 6px auto 0; - border: 1px solid #d5deea; - background: #fff; - color: #8a97a8; + margin: 5px auto 0; + border: 1px solid var(--c-border); + background: var(--c-surface); + color: var(--c-text-muted); font-size: 11px; - border-radius: 6px; + border-radius: var(--radius-sm); padding: 2px 10px; cursor: pointer; + transition: all var(--transition); } .slot-clear:hover { - border-color: #c0392b; - color: #c0392b; + border-color: var(--c-danger); + color: var(--c-danger); + background: var(--c-danger-light); } -.modal-toolbar { - padding: 12px 20px; - border-bottom: 1px solid #e3e9f2; - display: flex; - align-items: center; - gap: 14px; - flex-wrap: wrap; - flex-shrink: 0; +/* ---- Menu blocks ---- */ +.menu-row { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + align-items: start; } -.pick-search { - flex: 1; - min-width: 160px; - padding: 9px 14px; - border: 1.5px solid #d5deea; - border-radius: 8px; +.menu-block { + width: 100%; + min-width: 0; + overflow-x: auto; +} + +/* ---- Nutrition section ---- */ +.nutri-section { + margin-top: 20px; + border-top: 1px solid var(--c-border-light); + padding-top: 16px; +} + +.nutri-section h3 { font-size: 14px; - outline: none; + font-weight: 700; + color: var(--c-text); + margin-bottom: 10px; } -.pick-search:focus { - border-color: #2f5597; +table.excel-like.nutri-table { + max-width: 600px; } +table.excel-like.nutri-table th, +table.excel-like.nutri-table td { + padding: 7px 10px; +} + +table.excel-like.nutri-table td { + vertical-align: middle; +} + +/* ---- Dish cell details ---- */ +.dish-name { + font-weight: 600; + font-size: 13px; +} + +.dish-ing { + font-size: 11px; + color: var(--c-text-muted); + margin-top: 1px; +} + +.dish-cell + .dish-cell { + border-top: 1px dashed var(--c-border); + margin-top: 4px; + padding-top: 5px; +} + +.dish-empty { + color: var(--c-text-muted); +} + +/* ---- Subgroup labels in modals ---- */ .modal-subgroup { - margin-bottom: 12px; + margin-bottom: 10px; } .modal-subgroup-label { - font-size: 12px; - color: #8a97a8; - margin-bottom: 6px; + font-size: 11px; + color: var(--c-text-muted); + margin-bottom: 5px; padding-left: 4px; + font-weight: 500; } +/* ---- Tooltip ---- */ .dish-tooltip { position: fixed; z-index: 200; pointer-events: none; - background: rgba(28, 41, 61, 0.95); + background: rgba(15,23,42,0.92); + backdrop-filter: blur(8px); color: #fff; - border-radius: 8px; + border-radius: var(--radius-md); padding: 10px 14px; - font-size: 12.5px; + font-size: 12px; max-width: 280px; - box-shadow: 0 8px 24px rgba(15, 28, 47, 0.35); + box-shadow: var(--shadow-lg); } .dish-tooltip .tooltip-name { @@ -591,48 +797,59 @@ td.slot-cell .dish-name { } .dish-tooltip .tooltip-ing { - color: #c9d4e4; - margin-bottom: 6px; + color: #cbd5e1; + margin-bottom: 5px; line-height: 1.4; } .dish-tooltip .tooltip-nutri { display: flex; - gap: 12px; - color: #ffd98a; + gap: 10px; + color: #fbbf24; font-weight: 600; } -.hint.warn { - color: #c0392b; - margin-top: 12px; - padding-bottom: 0; +/* ---- Date range ---- */ +.title-date-fields { + width: 100%; + margin-bottom: 8px; } -.dish-cell + .dish-cell { - border-top: 1px dashed #b9c4d4; - margin-top: 4px; - padding-top: 6px; +.date-field .label { + margin-bottom: 6px; + font-size: 12px; } -.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; +.date-range-input { + display: flex; align-items: center; + gap: 8px; } +.date-range-input input[type="date"] { + padding: 8px 10px; + border: 1.5px solid var(--c-border); + border-radius: var(--radius-sm); + font-size: 13px; + font-family: inherit; + color: var(--c-text); + background: var(--c-surface); + transition: border-color var(--transition); +} + +.date-range-input input[type="date"]:focus { + border-color: var(--c-accent); + outline: none; + box-shadow: 0 0 0 3px var(--c-accent-light); +} + +.date-sep { + font-size: 16px; + color: var(--c-text-muted); + font-weight: 600; +} + +/* ---- Responsive ---- */ @media (max-width: 900px) { .menu-row { grid-template-columns: 1fr; @@ -640,33 +857,252 @@ td.slot-cell .dish-name { .slot-row { grid-template-columns: 1fr; } + .title-fields { + flex-direction: column; + align-items: stretch; + } + .title-field input.pick-search { + min-width: 0; + width: 100%; + } } -.nutri-section { - margin-top: 24px; - border-top: 2px solid #eef2f7; - padding-top: 18px; +@media (max-width: 600px) { + .page { + padding: 12px 10px 40px; + } + .hero h1 { + font-size: 22px; + } + .panel { + padding: 16px; + } + .actions { + flex-direction: column; + } + .btn { + width: 100%; + text-align: center; + } } -.nutri-section h3 { - font-size: 16px; - color: #1f3a5f; - margin-bottom: 12px; + +/* ============================================ + Modern Pagination Component + ============================================ */ + +.flat-dish-list { + width: 100%; } -table.excel-like.nutri-table { - max-width: 640px; +.modal-items-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; + min-height: 60px; } -table.excel-like.nutri-table th, -table.excel-like.nutri-table td { - padding: 8px 12px; +.modal-item-badge { + display: inline-block; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.03em; + padding: 2px 7px; + border-radius: 999px; + line-height: 1.3; + text-transform: uppercase; + flex-shrink: 0; } -table.excel-like.nutri-table td { - vertical-align: middle; +/* Badge color by type */ +.modal-item:has(.item-name) .modal-item-badge { + background: var(--c-bg); + color: var(--c-text-muted); + border: 1px solid var(--c-border); } -.dish-empty { - color: #999; +.modal-item:hover .modal-item-badge { + background: rgba(99,102,241,0.1); + color: var(--c-accent); + border-color: rgba(99,102,241,0.2); } + +.modal-item.current .modal-item-badge { + background: rgba(255,255,255,0.2); + color: rgba(255,255,255,0.8); + border-color: rgba(255,255,255,0.15); +} + +/* Pagination container */ +.pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 0 4px; + border-top: 1px solid var(--c-border-light); + flex-wrap: wrap; +} + +.pg-info { + font-size: 12px; + color: var(--c-text-muted); + font-weight: 500; + white-space: nowrap; +} + +.pg-controls { + display: flex; + align-items: center; + gap: 4px; +} + +/* Individual page button */ +.pg-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 34px; + height: 34px; + padding: 0 6px; + border: 1.5px solid var(--c-border); + border-radius: var(--radius-sm); + background: var(--c-surface); + color: var(--c-text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1); + user-select: none; + line-height: 1; +} + +.pg-btn:hover:not(:disabled):not(.active) { + border-color: var(--c-accent); + color: var(--c-accent); + background: var(--c-accent-light); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(99,102,241,0.12); +} + +.pg-btn:active:not(:disabled) { + transform: translateY(0); +} + +.pg-btn.active { + background: var(--c-accent); + border-color: var(--c-accent); + color: #fff; + font-weight: 700; + box-shadow: 0 2px 10px rgba(99,102,241,0.25); +} + +.pg-btn:disabled { + opacity: 0.3; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +/* Arrow buttons (first/prev/next/last) */ +.pg-arrow { + min-width: 34px; + padding: 0; +} + +.pg-arrow svg { + flex-shrink: 0; +} + +/* Number buttons */ +.pg-num { + font-variant-numeric: tabular-nums; +} + +/* Ellipsis */ +.pg-ellipsis { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 34px; + font-size: 14px; + color: var(--c-text-muted); + letter-spacing: 2px; + user-select: none; +} + +/* Jump-to-page */ +.pg-jump { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: 10px; + font-size: 12px; + color: var(--c-text-muted); + white-space: nowrap; +} + +.pg-jump-input { + width: 48px; + height: 30px; + padding: 0 6px; + border: 1.5px solid var(--c-border); + border-radius: var(--radius-sm); + font-size: 12px; + text-align: center; + background: var(--c-surface); + color: var(--c-text); + outline: none; + transition: border-color 0.18s ease, box-shadow 0.18s ease; + font-family: inherit; + font-variant-numeric: tabular-nums; + -moz-appearance: textfield; +} + +.pg-jump-input::-webkit-outer-spin-button, +.pg-jump-input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.pg-jump-input:focus { + border-color: var(--c-accent); + box-shadow: 0 0 0 3px var(--c-accent-light); +} + +/* Pagination responsive */ +@media (max-width: 600px) { + .pagination { + flex-direction: column; + align-items: stretch; + gap: 10px; + padding: 12px 0 2px; + } + + .pg-info { + text-align: center; + } + + .pg-controls { + justify-content: center; + flex-wrap: wrap; + gap: 3px; + } + + .pg-btn { + min-width: 30px; + height: 30px; + font-size: 12px; + } + + .pg-jump { + margin-left: 0; + justify-content: center; + } + + .modal-items-grid { + gap: 6px; + } +} \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js index ff2318b..bac987a 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -3,7 +3,7 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], - base: 'https://qnycdn.mymoyu.top/xx/web/', + base: '/', server: { port: 5173, proxy: { diff --git a/manager.py b/manager.py new file mode 100644 index 0000000..d09340c --- /dev/null +++ b/manager.py @@ -0,0 +1,282 @@ +""" +Dashboard Manager - School Meal Planner +A lightweight control panel / visualization entry point. +When run standalone, starts the server and opens the dashboard. +When run alongside the EXE (run.py), it is NOT auto-launched. +""" +import os +import sys +import threading +import time +import urllib.request +import webbrowser + +try: + import tkinter as tk + from tkinter import messagebox + HAS_TKINTER = True +except ImportError: + HAS_TKINTER = False + + +# --------------------------------------------------------------------------- +# Environment setup (shared with run.py) +# --------------------------------------------------------------------------- +def _frozen_dir(): + if getattr(sys, "frozen", False): + return sys._MEIPASS + return os.path.dirname(os.path.abspath(__file__)) + + +def _app_dir(): + if getattr(sys, "frozen", False): + return os.path.dirname(sys.executable) + return os.path.dirname(os.path.abspath(__file__)) + + +def setup_environment(): + base = _frozen_dir() + app = _app_dir() + sys.path.insert(0, base) + backend_dir = os.path.join(base, 'backend') + if os.path.isdir(backend_dir) and backend_dir not in sys.path: + sys.path.insert(0, backend_dir) + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + os.environ["DJANGO_DEBUG"] = "True" + os.environ["DJANGO_SECRET_KEY"] = "exe-local-secret-key-do-not-use-in-production" + os.environ["DJANGO_ALLOWED_HOSTS"] = "127.0.0.1,localhost" + os.environ["DJANGO_DB_PATH"] = os.path.join(app, "db.sqlite3") + os.environ["DJANGO_STATIC_URL"] = "/static/" + os.environ["EXE_MODE"] = "1" + static_fe = os.path.join(base, "backend", "static", "frontend") + if not os.path.isdir(static_fe): + static_fe = os.path.join(base, "static", "frontend") + os.environ["FRONTEND_DIR"] = static_fe + + +def init_database(): + import django + django.setup() + from django.core.management import call_command + call_command("migrate", "--run-syncdb", verbosity=0) + from meals.models import Dish + if Dish.objects.count() == 0: + try: + call_command("seed_dishes", verbosity=0) + except Exception: + pass + from django.contrib.auth import get_user_model + User = get_user_model() + if not User.objects.filter(username="admin").exists(): + User.objects.create_superuser("admin", "admin@example.com", "admin123") + + +def start_server(port): + import django + django.setup() + from django.core.management import call_command + call_command("runserver", f"127.0.0.1:{port}", "--noreload", verbosity=0) + + +def _server_alive(port=8000): + """Check if Django is already running on the port.""" + try: + urllib.request.urlopen(f"http://127.0.0.1:{port}/admin/", timeout=2) + return True + except Exception: + return False + + +# --------------------------------------------------------------------------- +# GUI - Visualization Panel +# --------------------------------------------------------------------------- +BG = "#1e293b" +BG_CARD = "#334155" +ACCENT = "#38bdf8" +GREEN = "#4ade80" +RED = "#f87171" +FG_DIM = "#94a3b8" +FONT_TITLE = ("Microsoft YaHei UI", 16, "bold") +FONT_LABEL = ("Microsoft YaHei UI", 11) +FONT_BTN = ("Microsoft YaHei UI", 11, "bold") +FONT_STATUS = ("Microsoft YaHei UI", 10) + +PORT = 8000 +URL_DASHBOARD = f"http://127.0.0.1:{PORT}/dashboard/" +URL_FRONTEND = f"http://127.0.0.1:{PORT}" +URL_ADMIN = f"http://127.0.0.1:{PORT}/admin" +ADMIN_USER = "admin" +ADMIN_PASS = "admin123" + + +class ManagerApp: + def __init__(self): + self.root = tk.Tk() + self.root.title("\u5b66\u6821\u8ba2\u9910\u83dc\u5355\u751f\u6210\u5668 - \u53ef\u89c6\u5316\u7ba1\u7406\u9762\u677f") + self.root.geometry("420x480") + self.root.resizable(False, False) + self.root.configure(bg=BG) + self.root.protocol("WM_DELETE_WINDOW", self._on_close) + + self._server_thread = None + self._build_ui() + self._check_server() + + def _build_ui(self): + root = self.root + + # Header + header = tk.Frame(root, bg=ACCENT, height=56) + header.pack(fill="x") + header.pack_propagate(False) + tk.Label( + header, text="\u53ef\u89c6\u5316\u7ba1\u7406\u9762\u677f", + font=FONT_TITLE, bg=ACCENT, fg="#0f172a", + ).pack(expand=True) + + # Status bar + self._status_frame = tk.Frame(root, bg=BG_CARD, bd=1, relief="solid") + self._status_frame.pack(fill="x", padx=16, pady=(16, 8)) + tk.Label( + self._status_frame, text="\u670d\u52a1\u72b6\u6001", + font=FONT_LABEL, bg=BG_CARD, fg=FG_DIM, anchor="w", + ).pack(side="left", padx=(12, 8), pady=10) + self._status_dot = tk.Canvas( + self._status_frame, width=12, height=12, + bg=BG_CARD, highlightthickness=0, + ) + self._status_dot.pack(side="left", pady=10) + self._status_dot.create_oval(2, 2, 10, 10, fill=FG_DIM, outline="", tags="dot") + self._status_label = tk.Label( + self._status_frame, text="\u68c0\u6d4b\u4e2d...", + font=FONT_STATUS, bg=BG_CARD, fg=FG_DIM, + ) + self._status_label.pack(side="left", padx=(4, 12), pady=10) + + # Credentials + cred_frame = tk.Frame(root, bg=BG_CARD, bd=1, relief="solid") + cred_frame.pack(fill="x", padx=16, pady=4) + tk.Label(cred_frame, text="\u7ba1\u7406\u5458\u4fe1\u606f", + font=FONT_LABEL, bg=BG_CARD, fg=FG_DIM, anchor="w").pack(side="left", padx=(12, 8), pady=10) + tk.Label(cred_frame, text=f"{ADMIN_USER} / {ADMIN_PASS}", + font=FONT_BTN, bg=BG_CARD, fg=GREEN).pack(side="right", padx=(0, 12), pady=10) + + # URL + url_frame = tk.Frame(root, bg=BG_CARD, bd=1, relief="solid") + url_frame.pack(fill="x", padx=16, pady=4) + tk.Label(url_frame, text="\u672c\u5730\u670d\u52a1", + font=FONT_LABEL, bg=BG_CARD, fg=FG_DIM, anchor="w").pack(side="left", padx=(12, 8), pady=10) + tk.Label(url_frame, text=f"127.0.0.1:{PORT}", + font=FONT_BTN, bg=BG_CARD, fg=ACCENT).pack(side="right", padx=(0, 12), pady=10) + + # Action buttons + btn_frame = tk.Frame(root, bg=BG) + btn_frame.pack(fill="x", padx=16, pady=(16, 4)) + + self._make_button(btn_frame, "\U0001f4ca \u6253\u5f00\u53ef\u89c6\u5316\u9762\u677f", + lambda: self._open_url(URL_DASHBOARD)).pack(fill="x", pady=(0, 6)) + self._make_button(btn_frame, "\U0001f310 \u6253\u5f00\u83dc\u5355\u751f\u6210\u5668", + lambda: self._open_url(URL_FRONTEND)).pack(fill="x", pady=(0, 6)) + self._make_button(btn_frame, "\U0001f6e0\ufe0f \u6253\u5f00\u540e\u53f0\u7ba1\u7406", + lambda: self._open_url(URL_ADMIN)).pack(fill="x", pady=(0, 6)) + self._make_button(btn_frame, "\U0001f37d\ufe0f \u83dc\u54c1\u7ba1\u7406", + lambda: self._open_url(f"{URL_ADMIN}/meals/dish/")).pack(fill="x", pady=(0, 6)) + + # Bottom + bottom_frame = tk.Frame(root, bg=BG) + bottom_frame.pack(fill="x", padx=16, pady=(12, 16)) + self._make_button(bottom_frame, "\u505c\u6b62\u670d\u52a1", + self._stop_server, bg_color=RED, hover_color="#dc2626").pack(fill="x", pady=(0, 4)) + self._make_button(bottom_frame, "\u91cd\u542f\u670d\u52a1", + self._restart_server, bg_color="#f59e0b", hover_color="#d97706").pack(fill="x") + + def _make_button(self, parent, text, command, bg_color=ACCENT, hover_color=None): + if hover_color is None: + hover_color = bg_color + btn = tk.Label(parent, text=text, font=FONT_BTN, bg=bg_color, fg="#0f172a", + cursor="hand2", padx=12, pady=10, anchor="w") + btn.bind("", lambda e: command()) + btn.bind("", lambda e: btn.configure(bg=hover_color)) + btn.bind("", lambda e: btn.configure(bg=bg_color)) + return btn + + def _check_server(self): + """Detect if server is already running, or start it.""" + self._set_status("\u68c0\u6d4b\u4e2d...", FG_DIM) + def _worker(): + if _server_alive(PORT): + self.root.after(0, lambda: self._set_status("Running", GREEN)) + self.root.after(500, lambda: self._open_url(URL_DASHBOARD)) + else: + # Start server ourselves + self.root.after(0, lambda: self._set_status("\u542f\u52a8\u670d\u52a1...", FG_DIM)) + try: + setup_environment() + init_database() + self.root.after(0, lambda: self._set_status("Running", GREEN)) + self.root.after(1000, lambda: self._open_url(URL_DASHBOARD)) + start_server(PORT) + except Exception as ex: + self.root.after(0, lambda: self._set_status("\u542f\u52a8\u5931\u8d25", RED)) + self.root.after(0, lambda: messagebox.showerror("\u9519\u8bef", str(ex))) + self._server_thread = threading.Thread(target=_worker, daemon=True) + self._server_thread.start() + + def _stop_server(self): + messagebox.showinfo("\u63d0\u793a", + "\u670d\u52a1\u7531\u4e3b\u7a0b\u5e8f\u7ba1\u7406\u3002\u5173\u95ed\u672c\u7a97\u53e3\u5373\u53ef\u505c\u6b62\u670d\u52a1\u3002") + + def _restart_server(self): + self._set_status("\u91cd\u542f\u4e2d...", FG_DIM) + def _worker(): + time.sleep(1) + if _server_alive(PORT): + self.root.after(0, lambda: self._set_status("Running", GREEN)) + else: + self.root.after(0, lambda: self._set_status("\u672a\u8fde\u63a5", RED)) + threading.Thread(target=_worker, daemon=True).start() + + def _open_url(self, url): + webbrowser.open(url) + + def _set_status(self, text, color): + self._status_label.configure(text=text, fg=color) + self._status_dot.itemconfig("dot", fill=color) + + def _on_close(self): + self.root.destroy() + + def run(self): + self.root.mainloop() + + +def main(): + setup_environment() + init_database() + if HAS_TKINTER: + app = ManagerApp() + app.run() + else: + print() + print("=" * 56) + print(" \u5b66\u6821\u8ba2\u9910\u83dc\u5355\u751f\u6210\u5668 - \u53ef\u89c6\u5316\u7ba1\u7406\u9762\u677f") + print("=" * 56) + print(f" \u53ef\u89c6\u5316\u9762\u677f: {URL_DASHBOARD}") + print(f" \u83dc\u5355\u751f\u6210\u5668: {URL_FRONTEND}") + print(f" \u540e\u53f0\u7ba1\u7406: {URL_ADMIN}") + print(f" \u7ba1\u7406\u5458\u8d26\u53f7: {ADMIN_USER} / {ADMIN_PASS}") + print("-" * 56) + if _server_alive(PORT): + print(" \u670d\u52a1\u5df2\u8fd0\u884c\uff0c\u6253\u5f00\u6d4f\u89c8\u5668...") + threading.Thread(target=lambda: (time.sleep(1), webbrowser.open(URL_DASHBOARD)), daemon=True).start() + else: + print(" \u670d\u52a1\u672a\u8fd0\u884c\uff0c\u6b63\u5728\u542f\u52a8...") + threading.Thread(target=lambda: (time.sleep(3), webbrowser.open(URL_DASHBOARD)), daemon=True).start() + try: + start_server(PORT) + except KeyboardInterrupt: + print("\n\u670d\u52a1\u5df2\u505c\u6b62") + + +if __name__ == "__main__": + main() diff --git a/meals/__init__.py b/meals/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/meals/admin.py b/meals/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/meals/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/meals/apps.py b/meals/apps.py deleted file mode 100644 index 832619c..0000000 --- a/meals/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class MealsConfig(AppConfig): - name = 'meals' diff --git a/meals/migrations/__init__.py b/meals/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/meals/models.py b/meals/models.py deleted file mode 100644 index 71a8362..0000000 --- a/meals/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/meals/tests.py b/meals/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/meals/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/meals/views.py b/meals/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/meals/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/run.py b/run.py new file mode 100644 index 0000000..489c38f --- /dev/null +++ b/run.py @@ -0,0 +1,155 @@ +""" +\u5b66\u6821\u8ba2\u9910\u83dc\u5355\u751f\u6210\u5668 - EXE \u5165\u53e3 +\u53cc\u51fb\u8fd0\u884c\u6b64\u811a\u672c\u6216\u6253\u5305\u540e\u7684 EXE\uff0c\u81ea\u52a8\u5b8c\u6210\uff1a + 1. \u521d\u59cb\u5316\u6570\u636e\u5e93\uff08\u9996\u6b21\u8fd0\u884c\u81ea\u52a8\u8fc1\u79fb + \u5199\u5165\u793a\u4f8b\u83dc\u54c1\uff09 + 2. \u542f\u52a8 Django \u670d\u52a1\uff08\u540c\u65f6\u6258\u7ba1\u524d\u7aef\u9759\u6001\u9875\u9762\uff09 + 3. 自动打开浏览器(默认)或原生窗口(--gui 模式) +""" +import os +import sys +import threading +import time +import webbrowser + +# Ensure UTF-8 output on Windows console (fixes GBK encoding errors with rich) +if sys.platform == "win32": + os.system("chcp 65001 >nul 2>&1") + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + +from rich.console import Console +from rich.panel import Panel +from rich.text import Text +from rich.table import Table +from rich import box + +console = Console() + +GUI_MODE = "--gui" in sys.argv + + +def _frozen_dir(): + if getattr(sys, "frozen", False): + return sys._MEIPASS + return os.path.dirname(os.path.abspath(__file__)) + + +def _app_dir(): + if getattr(sys, "frozen", False): + return os.path.dirname(sys.executable) + return os.path.dirname(os.path.abspath(__file__)) + + +def setup_environment(): + base = _frozen_dir() + app = _app_dir() + sys.path.insert(0, base) + # In dev mode, add backend/ to path so config and meals are importable + backend_dir = os.path.join(base, 'backend') + if os.path.isdir(backend_dir) and backend_dir not in sys.path: + sys.path.insert(0, backend_dir) + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + os.environ["DJANGO_DEBUG"] = "True" + os.environ["DJANGO_SECRET_KEY"] = "exe-local-secret-key-do-not-use-in-production" + os.environ["DJANGO_ALLOWED_HOSTS"] = "127.0.0.1,localhost" + os.environ["DJANGO_DB_PATH"] = os.path.join(app, "db.sqlite3") + os.environ["DJANGO_STATIC_URL"] = "/static/" + os.environ["EXE_MODE"] = "1" + static_fe = os.path.join(base, "backend", "static", "frontend") + if not os.path.isdir(static_fe): + static_fe = os.path.join(base, "static", "frontend") + os.environ["FRONTEND_DIR"] = static_fe + + +def init_database(): + import django + django.setup() + from django.core.management import call_command + + with console.status("[bold cyan]\u6b63\u5728\u8fc1\u79fb\u6570\u636e\u5e93...", spinner="dots"): + call_command("migrate", "--run-syncdb", verbosity=0) + console.print(" [green]\u2713[/green] \u6570\u636e\u5e93\u8fc1\u79fb\u5b8c\u6210") + + from meals.models import Dish + if Dish.objects.count() == 0: + with console.status("[bold cyan]\u6b63\u5728\u5199\u5165\u793a\u4f8b\u83dc\u54c1\u6570\u636e...", spinner="dots"): + try: + call_command("seed_dishes", verbosity=0) + except Exception: + pass + console.print(" [green]\u2713[/green] \u793a\u4f8b\u83dc\u54c1\u6570\u636e\u5df2\u5199\u5165") + else: + console.print(" [green]\u2713[/green] \u83dc\u54c1\u6570\u636e\u5df2\u5b58\u5728\uff0c\u8df3\u8fc7") + + from django.contrib.auth import get_user_model + User = get_user_model() + if not User.objects.filter(username="admin").exists(): + User.objects.create_superuser("admin", "admin@example.com", "admin123") + console.print(" [green]\u2713[/green] \u5df2\u521b\u5efa\u7ba1\u7406\u5458 [bold]admin[/bold] / [bold]admin123[/bold]") + else: + console.print(" [green]\u2713[/green] \u7ba1\u7406\u5458\u8d26\u53f7\u5df2\u5b58\u5728") + + +def open_browser(port): + time.sleep(2) + webbrowser.open(f"http://127.0.0.1:{port}/") + + +def print_banner(port): + logo = Text() + logo.append(" _____ _ _ ____ \n", style="bold cyan") + logo.append(" / ____| | (_) | _ \\ \n", style="bold cyan") + logo.append("| (___ | |__ __ _ _ __ _ ___ ___ _ __ | |_) | __ ___ _____ _ __ \n", style="bold cyan") + logo.append(" \\___ \\| '_ \\ / _` | '__| |/ __/ _ \\| '_ \\| _ < / _` \\ \\ /\\ / / _ \\| '_ \\ \n", style="bold cyan") + logo.append(" ____) | | | | (_| | | | | (_| (_) | | | | |_) | (_| |\\ V V / (_) | | | |\n", style="bold cyan") + logo.append("|_____/|_| |_|\\__,_|_| |_|\\___\\___/|_| |_|____/ \\__,_| \\_/\\_/ \\___/|_| |_|\n", style="bold cyan") + + info_table = Table(show_header=False, box=box.SIMPLE, padding=(0, 2)) + info_table.add_column("key", style="bold white") + info_table.add_column("value", style="cyan") + info_table.add_row("\u5bfc\u822a\u9996\u9875", f"http://127.0.0.1:{port}/") + info_table.add_row("\u83dc\u5355\u751f\u6210\u5668", f"http://127.0.0.1:{port}/app/") + info_table.add_row("\u540e\u53f0\u7ba1\u7406", f"http://127.0.0.1:{port}/admin") + info_table.add_row("\u7ba1\u7406\u5458", "admin / admin123") + info_table.add_row("\u505c\u6b62\u670d\u52a1", "Ctrl + C") + + from rich.console import Group as RenderGroup + + console.print(Panel( + RenderGroup(logo, Text(""), info_table), + title="[bold green]\u5b66\u6821\u8ba2\u9910\u83dc\u5355\u751f\u6210\u5668[/bold green]", + subtitle="[dim]School Meal Planner[/dim]", + border_style="bright_blue", + padding=(1, 2), + )) + + +# manager.py is no longer launched separately; the web dashboard IS the manager + + +def main(): + if GUI_MODE: + from desktop import main as gui_main + gui_main() + return + console.clear() + setup_environment() + console.print() + console.print("[bold cyan]\u6b63\u5728\u521d\u59cb\u5316\u7cfb\u7edf...[/bold cyan]") + console.print() + init_database() + port = 8000 + print_banner(port) + # Auto-open dashboard in browser after server starts + threading.Thread(target=open_browser, args=(port,), daemon=True).start() + console.print("[dim]Django \u670d\u52a1\u542f\u52a8\u4e2d...[/dim]") + console.print() + from django.core.management import call_command + call_command("runserver", f"127.0.0.1:{port}", "--noreload", verbosity=1) + + +if __name__ == "__main__": + main() diff --git a/school-meal-setup.iss b/school-meal-setup.iss new file mode 100644 index 0000000..578e090 --- /dev/null +++ b/school-meal-setup.iss @@ -0,0 +1,81 @@ +; Inno Setup script for 学校订餐菜单生成器 +; Build with: iscc school-meal-setup.iss +; Prerequisites: Inno Setup 6.x (https://jrsoftware.org/isinfo.php) +; +; This script packages the PyInstaller output (dist/school-meal/) into a +; standard Windows installer with Start Menu shortcuts and uninstall support. + +[Setup] +AppId={{B7E3A4F1-5D92-4C8A-9F6E-2A1B3C4D5E6F} +AppName=学校订餐菜单生成器 +AppVersion=1.0.0 +AppPublisher=学校食堂管理 +AppPublisherURL= +DefaultDirName={autopf}\学校订餐菜单生成器 +DefaultGroupName=学校订餐菜单生成器 +OutputDir=installer +OutputBaseFilename=学校订餐菜单生成器-安装包 +SetupIconFile=app.ico +Compression=lzma2/ultra64 +SolidCompression=yes +WizardStyle=modern +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +CloseApplications=force +RestartApplications=no +DisableProgramGroupPage=yes +UninstallDisplayIcon={app}\app.ico +LicenseFile= + +[Languages] +Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl" +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加选项:"; Flags: checkedonce + +[Files] +; Main executable +Source: "dist\school-meal\school-meal.exe"; DestDir: "{app}"; Flags: ignoreversion +; Database template (always updated — user data lives in %LOCALAPPDATA%) +Source: "dist\school-meal\db.sqlite3"; DestDir: "{app}"; Flags: ignoreversion uninsneveruninstall +; All runtime dependencies +Source: "dist\school-meal\_internal\*"; DestDir: "{app}\_internal"; Flags: ignoreversion recursesubdirs createallsubdirs +; Application icon (referenced by shortcuts and uninstall) +Source: "app.ico"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{group}\学校订餐菜单生成器"; Filename: "{app}\school-meal.exe"; IconFilename: "{app}\app.ico" +Name: "{group}\卸载 学校订餐菜单生成器"; Filename: "{uninstallexe}"; IconFilename: "{app}\app.ico" +Name: "{autodesktop}\学校订餐菜单生成器"; Filename: "{app}\school-meal.exe"; Tasks: desktopicon; IconFilename: "{app}\app.ico" + +[Run] +Filename: "{app}\school-meal.exe"; Description: "启动 菜单生成器"; Flags: nowait postinstall skipifsilent + +[Code] +// Check if school-meal is already running before install/uninstall +function InitializeSetup: Boolean; +var + ResultCode: Integer; +begin + Result := True; + Exec('taskkill', '/F /IM school-meal.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); +end; + +function InitializeUninstall: Boolean; +var + ResultCode: Integer; +begin + Result := True; + Exec('taskkill', '/F /IM school-meal.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); +end; + +function PrepareToInstall(var NeedsRestart: Boolean): String; +var + ResultCode: Integer; +begin + Result := ''; + Exec('taskkill', '/F /IM school-meal.exe', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); +end; diff --git a/school-meal.spec b/school-meal.spec new file mode 100644 index 0000000..b6807a9 --- /dev/null +++ b/school-meal.spec @@ -0,0 +1,366 @@ +# -*- mode: python ; coding: utf-8 -*- +# Auto-generated by build.py +block_cipher = None + +a = Analysis( + [r'C:/Users/12914/Desktop/school-meal/desktop.py'], + pathex=[r'C:/Users/12914/Desktop/school-meal', r'C:/Users/12914/Desktop/school-meal/backend'], + binaries=[], + datas=[ + (r'C:\Users\12914\Desktop\school-meal\backend\static', r'backend/static'), + (r'C:\Users\12914\Desktop\school-meal\backend\templates', r'backend/templates'), + (r'C:\Users\12914\Desktop\school-meal\backend\meals\migrations', r'backend/meals/migrations'), + (r'C:\Users\12914\Desktop\school-meal\backend\config', r'backend/config'), + (r'C:\Users\12914\Desktop\school-meal\backend\meals\management', r'backend/meals/management'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\admin\js\cancel.js', r'static\admin\js'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\admin\js\popup_response.js', r'static\admin\js'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\jazzmin\css\main.css', r'static\jazzmin\css'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\jazzmin\css\main.css.backup', r'static\jazzmin\css'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\jazzmin\img\calendar-icons.svg', r'static\jazzmin\img'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\jazzmin\img\default-log.svg', r'static\jazzmin\img'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\jazzmin\img\default.jpg', r'static\jazzmin\img'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\jazzmin\img\icon-calendar.svg', r'static\jazzmin\img'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\jazzmin\img\icon-changelink.svg', r'static\jazzmin\img'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\jazzmin\img\selector-icons.svg', r'static\jazzmin\img'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\jazzmin\js\change_form.js', r'static\jazzmin\js'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\jazzmin\js\change_list.js', r'static\jazzmin\js'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\jazzmin\js\main.js', r'static\jazzmin\js'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\jazzmin\js\related-modal.js', r'static\jazzmin\js'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\jazzmin\js\ui-builder.js', r'static\jazzmin\js'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\jazzmin\plugins\bootstrap-show-modal\bootstrap-show-modal.min.js', r'static\jazzmin\plugins\bootstrap-show-modal'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\adminlte\css\adminlte.min.css', r'static\vendor\adminlte\css'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\adminlte\css\adminlte.min.css.map', r'static\vendor\adminlte\css'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\adminlte\img\AdminLTELogo.png', r'static\vendor\adminlte\img'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\adminlte\img\icons.png', r'static\vendor\adminlte\img'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\adminlte\img\user2-160x160.jpg', r'static\vendor\adminlte\img'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\adminlte\js\adminlte.min.js', r'static\vendor\adminlte\js'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\adminlte\js\adminlte.min.js.map', r'static\vendor\adminlte\js'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootstrap\js\bootstrap.bundle.min.js', r'static\vendor\bootstrap\js'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootstrap\js\bootstrap.bundle.min.js.map', r'static\vendor\bootstrap\js'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootstrap\js\bootstrap.min.js', r'static\vendor\bootstrap\js'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootstrap\js\bootstrap.min.js.map', r'static\vendor\bootstrap\js'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\brite\bootstrap.min.css', r'static\vendor\bootswatch\brite'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\brite\bootstrap.min.css.map', r'static\vendor\bootswatch\brite'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\cerulean\bootstrap.min.css', r'static\vendor\bootswatch\cerulean'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\cerulean\bootstrap.min.css.map', r'static\vendor\bootswatch\cerulean'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\cosmo\bootstrap.min.css', r'static\vendor\bootswatch\cosmo'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\cosmo\bootstrap.min.css.map', r'static\vendor\bootswatch\cosmo'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\cyborg\bootstrap.min.css', r'static\vendor\bootswatch\cyborg'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\cyborg\bootstrap.min.css.map', r'static\vendor\bootswatch\cyborg'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\darkly\bootstrap.min.css', r'static\vendor\bootswatch\darkly'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\darkly\bootstrap.min.css.map', r'static\vendor\bootswatch\darkly'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\default\bootstrap.min.css', r'static\vendor\bootswatch\default'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\default\bootstrap.min.css.map', r'static\vendor\bootswatch\default'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\flatly\bootstrap.min.css', r'static\vendor\bootswatch\flatly'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\flatly\bootstrap.min.css.map', r'static\vendor\bootswatch\flatly'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\journal\bootstrap.min.css', r'static\vendor\bootswatch\journal'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\journal\bootstrap.min.css.map', r'static\vendor\bootswatch\journal'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\litera\bootstrap.min.css', r'static\vendor\bootswatch\litera'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\litera\bootstrap.min.css.map', r'static\vendor\bootswatch\litera'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\lumen\bootstrap.min.css', r'static\vendor\bootswatch\lumen'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\lumen\bootstrap.min.css.map', r'static\vendor\bootswatch\lumen'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\lux\bootstrap.min.css', r'static\vendor\bootswatch\lux'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\lux\bootstrap.min.css.map', r'static\vendor\bootswatch\lux'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\materia\bootstrap.min.css', r'static\vendor\bootswatch\materia'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\materia\bootstrap.min.css.map', r'static\vendor\bootswatch\materia'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\minty\bootstrap.min.css', r'static\vendor\bootswatch\minty'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\minty\bootstrap.min.css.map', r'static\vendor\bootswatch\minty'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\morph\bootstrap.min.css', r'static\vendor\bootswatch\morph'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\morph\bootstrap.min.css.map', r'static\vendor\bootswatch\morph'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\pulse\bootstrap.min.css', r'static\vendor\bootswatch\pulse'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\pulse\bootstrap.min.css.map', r'static\vendor\bootswatch\pulse'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\quartz\bootstrap.min.css', r'static\vendor\bootswatch\quartz'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\quartz\bootstrap.min.css.map', r'static\vendor\bootswatch\quartz'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\sandstone\bootstrap.min.css', r'static\vendor\bootswatch\sandstone'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\sandstone\bootstrap.min.css.map', r'static\vendor\bootswatch\sandstone'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\simplex\bootstrap.min.css', r'static\vendor\bootswatch\simplex'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\simplex\bootstrap.min.css.map', r'static\vendor\bootswatch\simplex'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\sketchy\bootstrap.min.css', r'static\vendor\bootswatch\sketchy'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\sketchy\bootstrap.min.css.map', r'static\vendor\bootswatch\sketchy'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\slate\bootstrap.min.css', r'static\vendor\bootswatch\slate'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\slate\bootstrap.min.css.map', r'static\vendor\bootswatch\slate'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\solar\bootstrap.min.css', r'static\vendor\bootswatch\solar'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\solar\bootstrap.min.css.map', r'static\vendor\bootswatch\solar'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\spacelab\bootstrap.min.css', r'static\vendor\bootswatch\spacelab'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\spacelab\bootstrap.min.css.map', r'static\vendor\bootswatch\spacelab'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\superhero\bootstrap.min.css', r'static\vendor\bootswatch\superhero'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\superhero\bootstrap.min.css.map', r'static\vendor\bootswatch\superhero'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\united\bootstrap.min.css', r'static\vendor\bootswatch\united'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\united\bootstrap.min.css.map', r'static\vendor\bootswatch\united'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\vapor\bootstrap.min.css', r'static\vendor\bootswatch\vapor'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\vapor\bootstrap.min.css.map', r'static\vendor\bootswatch\vapor'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\yeti\bootstrap.min.css', r'static\vendor\bootswatch\yeti'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\yeti\bootstrap.min.css.map', r'static\vendor\bootswatch\yeti'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\zephyr\bootstrap.min.css', r'static\vendor\bootswatch\zephyr'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\bootswatch\zephyr\bootstrap.min.css.map', r'static\vendor\bootswatch\zephyr'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\fontawesome-free\css\all.min.css', r'static\vendor\fontawesome-free\css'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\fontawesome-free\webfonts\fa-brands-400.ttf', r'static\vendor\fontawesome-free\webfonts'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\fontawesome-free\webfonts\fa-brands-400.woff2', r'static\vendor\fontawesome-free\webfonts'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\fontawesome-free\webfonts\fa-regular-400.ttf', r'static\vendor\fontawesome-free\webfonts'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\fontawesome-free\webfonts\fa-regular-400.woff2', r'static\vendor\fontawesome-free\webfonts'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\fontawesome-free\webfonts\fa-solid-900.ttf', r'static\vendor\fontawesome-free\webfonts'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\fontawesome-free\webfonts\fa-solid-900.woff2', r'static\vendor\fontawesome-free\webfonts'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\fontawesome-free\webfonts\fa-v4compatibility.ttf', r'static\vendor\fontawesome-free\webfonts'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\fontawesome-free\webfonts\fa-v4compatibility.woff2', r'static\vendor\fontawesome-free\webfonts'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\select2\css\select2.min.css', r'static\vendor\select2\css'), + (r'C:\Users\12914\Desktop\school-meal\.venv\Lib\site-packages\jazzmin\static\vendor\select2\js\select2.min.js', r'static\vendor\select2\js'), + ], + hiddenimports=[ + 'rest_framework', + 'rest_framework.apps', + 'rest_framework.authentication', + 'rest_framework.authtoken', + 'rest_framework.authtoken.admin', + 'rest_framework.authtoken.apps', + 'rest_framework.authtoken.management', + 'rest_framework.authtoken.management.commands', + 'rest_framework.authtoken.management.commands.drf_create_token', + 'rest_framework.authtoken.migrations', + 'rest_framework.authtoken.migrations.0001_initial', + 'rest_framework.authtoken.migrations.0002_auto_20160226_1747', + 'rest_framework.authtoken.migrations.0003_tokenproxy', + 'rest_framework.authtoken.migrations.0004_alter_tokenproxy_options', + 'rest_framework.authtoken.models', + 'rest_framework.authtoken.serializers', + 'rest_framework.authtoken.views', + 'rest_framework.checks', + 'rest_framework.compat', + 'rest_framework.decorators', + 'rest_framework.exceptions', + 'rest_framework.fields', + 'rest_framework.filters', + 'rest_framework.generics', + 'rest_framework.management', + 'rest_framework.management.commands', + 'rest_framework.management.commands.generateschema', + 'rest_framework.metadata', + 'rest_framework.mixins', + 'rest_framework.negotiation', + 'rest_framework.pagination', + 'rest_framework.parsers', + 'rest_framework.permissions', + 'rest_framework.relations', + 'rest_framework.renderers', + 'rest_framework.request', + 'rest_framework.response', + 'rest_framework.reverse', + 'rest_framework.routers', + 'rest_framework.serializers', + 'rest_framework.settings', + 'rest_framework.status', + 'rest_framework.templatetags', + 'rest_framework.templatetags.rest_framework', + 'rest_framework.test', + 'rest_framework.throttling', + 'rest_framework.urlpatterns', + 'rest_framework.urls', + 'rest_framework.utils', + 'rest_framework.utils.breadcrumbs', + 'rest_framework.utils.encoders', + 'rest_framework.utils.field_mapping', + 'rest_framework.utils.formatting', + 'rest_framework.utils.html', + 'rest_framework.utils.humanize_datetime', + 'rest_framework.utils.json', + 'rest_framework.utils.mediatypes', + 'rest_framework.utils.model_meta', + 'rest_framework.utils.representation', + 'rest_framework.utils.serializer_helpers', + 'rest_framework.utils.timezone', + 'rest_framework.utils.urls', + 'rest_framework.validators', + 'rest_framework.versioning', + 'rest_framework.views', + 'rest_framework.viewsets', + 'corsheaders', + 'corsheaders.apps', + 'corsheaders.checks', + 'corsheaders.conf', + 'corsheaders.defaults', + 'corsheaders.middleware', + 'corsheaders.signals', + 'jazzmin', + 'jazzmin.apps', + 'jazzmin.compat', + 'jazzmin.settings', + 'jazzmin.templatetags', + 'jazzmin.templatetags.jazzmin', + 'jazzmin.utils', + 'jazzmin.widgets', + 'rich', + 'rich.__main__', + 'rich._emoji_codes', + 'rich._emoji_replace', + 'rich._export_format', + 'rich._extension', + 'rich._fileno', + 'rich._inspect', + 'rich._log_render', + 'rich._loop', + 'rich._null_file', + 'rich._palettes', + 'rich._pick', + 'rich._ratio', + 'rich._spinners', + 'rich._stack', + 'rich._timer', + 'rich._unicode_data', + 'rich._unicode_data._versions', + 'rich._unicode_data.unicode10-0-0', + 'rich._unicode_data.unicode11-0-0', + 'rich._unicode_data.unicode12-0-0', + 'rich._unicode_data.unicode12-1-0', + 'rich._unicode_data.unicode13-0-0', + 'rich._unicode_data.unicode14-0-0', + 'rich._unicode_data.unicode15-0-0', + 'rich._unicode_data.unicode15-1-0', + 'rich._unicode_data.unicode16-0-0', + 'rich._unicode_data.unicode17-0-0', + 'rich._unicode_data.unicode4-1-0', + 'rich._unicode_data.unicode5-0-0', + 'rich._unicode_data.unicode5-1-0', + 'rich._unicode_data.unicode5-2-0', + 'rich._unicode_data.unicode6-0-0', + 'rich._unicode_data.unicode6-1-0', + 'rich._unicode_data.unicode6-2-0', + 'rich._unicode_data.unicode6-3-0', + 'rich._unicode_data.unicode7-0-0', + 'rich._unicode_data.unicode8-0-0', + 'rich._unicode_data.unicode9-0-0', + 'rich._win32_console', + 'rich._windows', + 'rich._windows_renderer', + 'rich._wrap', + 'rich.abc', + 'rich.align', + 'rich.ansi', + 'rich.bar', + 'rich.box', + 'rich.cells', + 'rich.color', + 'rich.color_triplet', + 'rich.columns', + 'rich.console', + 'rich.constrain', + 'rich.containers', + 'rich.control', + 'rich.default_styles', + 'rich.diagnose', + 'rich.emoji', + 'rich.errors', + 'rich.file_proxy', + 'rich.filesize', + 'rich.highlighter', + 'rich.json', + 'rich.jupyter', + 'rich.layout', + 'rich.live', + 'rich.live_render', + 'rich.logging', + 'rich.markdown', + 'rich.markup', + 'rich.measure', + 'rich.padding', + 'rich.pager', + 'rich.palette', + 'rich.panel', + 'rich.pretty', + 'rich.progress', + 'rich.progress_bar', + 'rich.prompt', + 'rich.protocol', + 'rich.region', + 'rich.repr', + 'rich.rule', + 'rich.scope', + 'rich.screen', + 'rich.segment', + 'rich.spinner', + 'rich.status', + 'rich.style', + 'rich.styled', + 'rich.syntax', + 'rich.table', + 'rich.terminal_theme', + 'rich.text', + 'rich.theme', + 'rich.themes', + 'rich.traceback', + 'rich.tree', + 'meals.views_dashboard', + 'meals', + 'meals.apps', + 'meals.models', + 'meals.admin', + 'meals.urls', + 'meals.views', + 'meals.views_spa', + 'meals.serializers', + 'meals.management', + 'meals.management.commands', + 'meals.management.commands.seed_dishes', + 'meals.management.commands.import_dishes', + 'config', + 'config.settings', + 'config.wsgi', + 'config.asgi', + 'config.urls', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'pypinyin', + 'openpyxl', + 'tkinter', + 'tkinter.messagebox', + 'tkinter.constants', + 'webview', + 'webview.window', + 'webview.platforms.edgechromium', + 'pythonnet', + 'clr_loader', + 'cffi', + 'bottle', + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='school-meal', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=r'C:/Users/12914/Desktop/school-meal/app.ico', +) + +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='school-meal', +) diff --git a/start.bat b/start.bat index 35cd80a..716815d 100644 --- a/start.bat +++ b/start.bat @@ -1,35 +1,75 @@ @echo off chcp 65001 >nul -echo ========================================== -echo 学校订餐菜单生成器 - 一键启动 -echo ========================================== cd /d %~dp0 +reg add "HKCU\Console" /v VirtualTerminalLevel /t REG_DWORD /d 1 /f >nul 2>&1 + +set "C_RESET=[0m" +set "C_BOLD=[1m" +set "C_CYAN=[96m" +set "C_GREEN=[92m" +set "C_YELLOW=[93m" +set "C_WHITE=[97m" +set "C_DIM=[2m" +set "C_BG_BLUE=[44m" + +echo. +echo %C_BG_BLUE%%C_WHITE% %C_RESET% +echo %C_BG_BLUE%%C_WHITE% %C_BOLD% _____ _ _ ____ %C_RESET%%C_BG_BLUE%%C_WHITE% %C_RESET% +echo %C_BG_BLUE%%C_WHITE% %C_BOLD% / ____^| ^| ^(_^) ^| _ \ %C_RESET%%C_BG_BLUE%%C_WHITE% %C_RESET% +echo %C_BG_BLUE%%C_WHITE% %C_BOLD%^| ^(____ ^| ^|__ __ _ _ __ _ ___ ___ _ __ ^| ^|_) ^| __ ___ _____ _ __ %C_RESET%%C_BG_BLUE%%C_WHITE% %C_RESET% +echo %C_BG_BLUE%%C_WHITE% %C_BOLD% \___ \^| '_ \ / _` ^| '__^| ^|/ __/ _ \^| '_ \^| _ ^< / _` \ ^\ /\ / / _ \^| '_ \ %C_RESET%%C_BG_BLUE%%C_WHITE% %C_RESET% +echo %C_BG_BLUE%%C_WHITE% %C_BOLD% ____) ^| ^| ^| ^| ^(_^| ^| ^| ^| ^| ^(_^| ^(_^) ^| ^| ^| ^| ^|_) ^| ^(_^| ^|\ V V / ^(_^| ^| ^| ^| %C_RESET%%C_BG_BLUE%%C_WHITE% %C_RESET% +echo %C_BG_BLUE%%C_WHITE% %C_BOLD% ^|_____/^|_^| ^|_^|\__,_^|_^| ^|_^|^|\___\___/^|_^| ^|_^|____/ \__,_^| \_/\_/ \___/^|_^| ^|_^|%C_RESET%%C_BG_BLUE%%C_WHITE% %C_RESET% +echo %C_BG_BLUE%%C_WHITE% %C_RESET% +echo. + +echo %C_CYAN% ┌─────────────────────────────────────────────────────────────────────┐%C_RESET% +echo %C_CYAN% │%C_RESET% %C_BOLD% School Meal Planner %C_RESET% - %C_DIM% One-click Launcher %C_RESET% %C_CYAN%│%C_RESET% +echo %C_CYAN% └─────────────────────────────────────────────────────────────────────┘%C_RESET% +echo. + if not exist .venv ( - echo [1/3] 首次运行,创建 Python 虚拟环境... + echo %C_YELLOW% [1/4] First run: creating Python venv...%C_RESET% 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 %C_GREEN% ✓ Environment initialized%C_RESET% + echo %C_GREEN% ✓ Admin: admin / admin123%C_RESET% + echo. +) else ( + echo %C_CYAN% [1/4] Python environment ready%C_RESET% ) -echo [1/3] 启动 Django 后端 (http://127.0.0.1:8000)... +echo %C_CYAN% [2/4] Starting Django backend...%C_RESET% start "django-backend" cmd /k ".venv\Scripts\python backend\manage.py runserver 0.0.0.0:8000" +echo %C_GREEN% ✓ Django backend started%C_RESET% +echo. -echo [2/3] 启动 React 前端 (http://localhost:5173)... +echo %C_CYAN% [3/4] Starting Manager GUI...%C_RESET% +start "" ".venv\Scripts\pythonw" manager.py +echo %C_GREEN% ✓ Manager started%C_RESET% +echo. + +echo %C_CYAN% [4/4] Starting React frontend...%C_RESET% if not exist frontend\node_modules ( - echo 首次运行,安装前端依赖... + echo %C_YELLOW% Installing frontend deps (may take a few minutes)...%C_RESET% cd frontend call npm install cd .. ) start "react-frontend" cmd /k "cd frontend && npm run dev" - -echo [3/3] 启动完成! +echo %C_GREEN% ✓ React frontend started%C_RESET% echo. -echo - 前端页面: http://localhost:5173 -echo - 后台管理: http://127.0.0.1:8000/admin (账号 admin / 密码 admin123) -echo - 关闭方式: 关闭弹出的两个命令行窗口 + +echo %C_BG_BLUE%%C_WHITE% %C_RESET% +echo %C_BG_BLUE%%C_WHITE% %C_RESET% %C_BOLD%All services started!%C_RESET% %C_BG_BLUE%%C_WHITE% %C_RESET% +echo %C_BG_BLUE%%C_WHITE% %C_RESET% %C_BG_BLUE%%C_WHITE% %C_RESET% +echo %C_BG_BLUE%%C_WHITE% %C_RESET% %C_CYAN%*%C_RESET% Frontend %C_BOLD%http://localhost:5173%C_RESET% %C_BG_BLUE%%C_WHITE% %C_RESET% +echo %C_BG_BLUE%%C_WHITE% %C_RESET% %C_CYAN%*%C_RESET% Admin Panel %C_BOLD%http://127.0.0.1:8000/admin%C_RESET% (admin / admin123) %C_BG_BLUE%%C_WHITE% %C_RESET% +echo %C_BG_BLUE%%C_WHITE% %C_RESET% %C_CYAN%*%C_RESET% Manager %C_DIM%Use the GUI window%C_RESET% %C_BG_BLUE%%C_WHITE% %C_RESET% +echo %C_BG_BLUE%%C_WHITE% %C_RESET% %C_BG_BLUE%%C_WHITE% %C_RESET% +echo %C_BG_BLUE%%C_WHITE% %C_RESET% echo. pause diff --git a/start_server.ps1 b/start_server.ps1 new file mode 100644 index 0000000..16c5d67 --- /dev/null +++ b/start_server.ps1 @@ -0,0 +1,8 @@ +$env:DJANGO_DEBUG = 'True' +$env:DJANGO_SECRET_KEY = 'test-key-for-debug' +$env:DJANGO_ALLOWED_HOSTS = '*' +$env:DJANGO_DB_PATH = 'C:\Users\12914\Desktop\school-meal\backend\db.sqlite3' +$env:DJANGO_SETTINGS_MODULE = 'config.settings' +$env:DJANGO_STATIC_URL = '/static/' +Set-Location 'C:\Users\12914\Desktop\school-meal\backend' +& 'C:\Users\12914\Desktop\school-meal\.venv\Scripts\python.exe' manage.py runserver 127.0.0.1:8000 --noreload 2>&1 | Tee-Object -FilePath 'C:\Users\12914\Desktop\school-meal\django_server.log' diff --git a/test_all.py b/test_all.py new file mode 100644 index 0000000..3ed6a29 --- /dev/null +++ b/test_all.py @@ -0,0 +1,120 @@ +import urllib.request, urllib.error, json, http.cookiejar + +cj = http.cookiejar.CookieJar() +opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) + +print("=" * 60) +print(" School Meal Admin Test Report") +print("=" * 60) + +# Test 1: Admin Login Page +print("\n1. Admin Login Page") +try: + r = opener.open("http://127.0.0.1:8000/admin/login/", timeout=5) + body = r.read().decode("utf-8", errors="replace") + print(" Status: %d OK" % r.status) + print(" Size: %d bytes" % len(body)) + print(" Has login form: %s" % ("id_username" in body)) + print(" Has password field: %s" % ("id_password" in body)) + print(" Has CSRF: %s" % ("csrfmiddlewaretoken" in body)) + title_start = body.find("") + 7 + title_end = body.find("") + print(" Title: %s" % body[title_start:title_end]) +except Exception as e: + print(" FAIL: %s" % e) + +# Test 2: Admin Login +print("\n2. Admin Login") +try: + r = opener.open("http://127.0.0.1:8000/admin/login/", timeout=5) + body = r.read().decode("utf-8", errors="replace") + csrf = body.split('csrfmiddlewaretoken" value="')[1].split('"')[0] + import urllib.parse + data = urllib.parse.urlencode({"csrfmiddlewaretoken": csrf, "username": "admin", "password": "admin123", "next": "/admin/"}).encode() + req = urllib.request.Request("http://127.0.0.1:8000/admin/login/", data=data) + req.add_header("Referer", "http://127.0.0.1:8000/admin/login/") + r2 = opener.open(req, timeout=5) + print(" Login OK: %d" % r2.status) +except urllib.error.HTTPError as e: + if e.code == 302: + print(" Login OK: 302 redirect to %s" % e.headers.get("Location", "unknown")) + else: + print(" FAIL: %d" % e.code) +except Exception as e: + print(" FAIL: %s" % e) + +# Test 3: Admin Dashboard +print("\n3. Admin Dashboard") +try: + r = opener.open("http://127.0.0.1:8000/admin/", timeout=5) + body = r.read().decode("utf-8", errors="replace") + print(" Status: %d OK" % r.status) + print(" Size: %d bytes" % len(body)) + print(" Has Dish model: %s" % ("Dish" in body or "meals" in body)) + print(" Has User model: %s" % ("User" in body or "auth" in body)) + print(" Has sidebar: %s" % ("sidebar" in body)) +except Exception as e: + print(" FAIL: %s" % e) + +# Test 4: Admin Dishes List +print("\n4. Admin Dishes List") +try: + r = opener.open("http://127.0.0.1:8000/admin/meals/dish/", timeout=5) + body = r.read().decode("utf-8", errors="replace") + print(" Status: %d OK" % r.status) + print(" Size: %d bytes" % len(body)) + print(" Has result table: %s" % ("result_list" in body)) +except Exception as e: + print(" FAIL: %s" % e) + +# Test 5: API Dishes +print("\n5. API Dishes") +try: + r = opener.open("http://127.0.0.1:8000/api/dishes/", timeout=5) + data = json.loads(r.read()) + print(" Status: %d OK" % r.status) + print(" Dish count: %d" % len(data)) + if data: + print(" First dish: %s (%s)" % (data[0]["name"], data[0]["dish_type"])) +except Exception as e: + print(" FAIL: %s" % e) + +# Test 6: Menu Generation +print("\n6. Menu Generation") +try: + req = urllib.request.Request("http://127.0.0.1:8000/api/menu/generate/", + data=json.dumps({"selections": [], "auto_all": True}).encode(), + headers={"Content-Type": "application/json"}) + r = opener.open(req, timeout=10) + menu = json.loads(r.read()) + print(" Status: %d OK" % r.status) + print(" Menu A days: %d" % len(menu["a"]["week"])) + print(" Menu B days: %d" % len(menu["b"]["week"])) + day_a = menu["a"]["week"][0] + print(" A Mon: %d meats %d vegs soup" % (len(day_a["meats"]), len(day_a["vegs"]))) +except Exception as e: + print(" FAIL: %s" % e) + +# Test 7: Frontend +print("\n7. Frontend") +try: + r = opener.open("http://127.0.0.1:8000/", timeout=5) + body = r.read().decode("utf-8", errors="replace") + print(" Status: %d OK" % r.status) + print(" Size: %d bytes" % len(body)) + print(" Has React root: %s" % ("root" in body)) +except Exception as e: + print(" FAIL: %s" % e) + +# Test 8: Static Files +print("\n8. Static Files") +for path in ["/static/admin/css/base.css", "/static/jazzmin/css/main.css"]: + try: + r = opener.open("http://127.0.0.1:8000%s" % path, timeout=5) + print(" %s: %d OK (%d bytes)" % (path, r.status, len(r.read()))) + except Exception as e: + print(" %s: FAIL (%s)" % (path, e)) + +print("\n" + "=" * 60) +print(" All tests completed") +print("=" * 60) diff --git a/test_urls.py b/test_urls.py new file mode 100644 index 0000000..c6060ff --- /dev/null +++ b/test_urls.py @@ -0,0 +1,16 @@ +import os, sys +os.environ['DJANGO_DEBUG'] = 'True' +os.environ['DJANGO_SECRET_KEY'] = 'test-key' +os.environ['DJANGO_ALLOWED_HOSTS'] = '*' +os.environ['DJANGO_DB_PATH'] = r'C:\Users\12914\Desktop\school-meal\backend\db.sqlite3' +os.environ['DJANGO_SETTINGS_MODULE'] = 'config.settings' +os.environ['DJANGO_STATIC_URL'] = '/static' +sys.path.insert(0, r'C:\Users\12914\Desktop\school-meal\backend') +import django +django.setup() +from django.test import Client +c = Client() +urls = ['/admin/', '/admin/login/', '/api/dishes/', '/'] +for url in urls: + r = c.get(url) + print(f'{url}: {r.status_code} ({len(r.content)} bytes)') diff --git a/xxcdn二维码.png b/xxcdn二维码.png deleted file mode 100644 index e6bac7c..0000000 Binary files a/xxcdn二维码.png and /dev/null differ