Files
Chunyu d7553520ce feat: 现代化翻页组件 + 完整项目更新
- 新增 Pagination 通用组件:首页/上一页/页码/下一页/末页导航
- PickModal 和 SwapModal 集成分页功能,每页20项
- 搜索/筛选切换自动回到第1页
- 现代化CSS样式:hover浮起动效、主色高亮、移动端适配
- 新增 dashboard、admin 管理模板
- 菜系(cuisine)模型和迁移
- 构建脚本 build.py 支持 PyInstaller 打包
- 前端资源重新构建
2026-08-28 13:44:21 +08:00

454 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import random
from datetime import date, timedelta
from django.http import HttpResponse
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from rest_framework import status, viewsets
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAdminUser
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from .models import Cuisine, Dish
from .serializers import DishSerializer, CuisineSerializer
WEEKDAYS = ['周一', '周二', '周三', '周四', '周五']
SCHOOL_NAME = '学校菜单'
COLOR_HEADER = PatternFill('solid', fgColor='2F5597')
COLOR_DAY = PatternFill('solid', fgColor='D9E2F3')
COLOR_MEAT = PatternFill('solid', fgColor='FCE4D6')
COLOR_VEG = PatternFill('solid', fgColor='E2EFDA')
COLOR_STAPLE = PatternFill('solid', fgColor='FDE9D9')
COLOR_SOUP = PatternFill('solid', fgColor='DDEBF7')
THIN = Side(style='thin', color='808080')
BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
CENTER = Alignment(horizontal='center', vertical='center', wrap_text=True)
class 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()
dish_type = self.request.query_params.get('type')
if dish_type:
qs = qs.filter(dish_type=dish_type)
return qs
TYPE_LABEL_MAP = {'荤菜': 'meat', '素菜': 'veg', '汤': 'soup', '主食': 'staple'}
@api_view(['POST'])
@permission_classes([IsAdminUser])
def bulk_create_dishes(request):
items = request.data.get('dishes')
if not isinstance(items, list) or not items:
return Response({'detail': '请提供 dishes 列表'}, status=status.HTTP_400_BAD_REQUEST)
created, skipped = [], []
for raw in items:
if not isinstance(raw, dict):
continue
name = str(raw.get('name', '')).strip()
dish_type = str(raw.get('dish_type', '')).strip()
dish_type = TYPE_LABEL_MAP.get(dish_type, dish_type)
if not name or dish_type not in ('meat', 'veg', 'soup', 'staple'):
skipped.append(name or '未命名')
continue
# 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': 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),
'calorie': int(raw.get('calorie', 0) or 0),
'is_active': bool(raw.get('is_active', True)),
}
dish, was_created = Dish.objects.update_or_create(name=name, defaults=defaults)
(created if was_created else skipped).append(name)
return Response({
'detail': f'新增 {len(created)} 道,更新/跳过 {len(skipped)} 道',
'created': created,
'skipped': skipped,
})
def _pick_unique(pool, count):
picked = random.sample(pool, min(count, len(pool)))
remaining = [d for d in pool if d not in picked]
return picked, remaining
def _pop_next(pool, shuffle_src):
if not pool:
random.shuffle(shuffle_src)
pool = shuffle_src[:]
return pool.pop(0), pool
def _serialize_dish(d):
return {
'id': d.id, 'name': d.name, 'ingredient': d.ingredient_detail, 'dish_type': d.dish_type,
'cuisine': d.cuisine_name, 'protein': float(d.protein), 'fat': float(d.fat), 'calorie': d.calorie,
}
def _generate_partial_week(payload, meats, vegs, soups, staples, menu_key):
pools = {'meat': meats[:], 'veg': vegs[:], 'soup': soups[:], 'staple': staples[:]}
src_pools = {'meat': meats, 'veg': vegs, 'soup': soups, 'staple': staples}
for p in pools.values():
random.shuffle(p)
day_selected = {}
for s in payload.get('selections') or []:
if s.get('menu', 'a') != menu_key:
continue
day = s.get('day')
slot = s.get('slot')
if day not in WEEKDAYS or slot not in ('meats', 'vegs', 'soup', 'staple'):
continue
dish = Dish.objects.filter(pk=s.get('dish_id'), is_active=True).first() if s.get('dish_id') else None
if not dish:
continue
key = f'{slot}{s.get("idx", 0)}' if slot in ('meats', 'vegs') else slot
day_selected.setdefault(day, {})[key] = dish
def fill(dish_type, used_ids):
pool = pools[dish_type]
while pool:
d = pool.pop(0)
if d.id not in used_ids:
return d
random.shuffle(src_pools[dish_type])
pool = pools[dish_type] = src_pools[dish_type][:]
while pool:
d = pool.pop(0)
if d.id not in used_ids:
return d
return None
week = []
for day in WEEKDAYS:
sel = day_selected.get(day, {})
used_ids = {d.id for d in sel.values()}
m0 = sel.get('meats0') or fill('meat', used_ids)
m1 = sel.get('meats1') or fill('meat', used_ids)
v0 = sel.get('vegs0') or fill('veg', used_ids)
v1 = sel.get('vegs1') or fill('veg', used_ids)
soup = sel.get('soup') or fill('soup', used_ids)
staple = sel.get('staple') or fill('staple', used_ids)
week.append({
'day': day,
'meats': [_serialize_dish(d) for d in (m0, m1) if d],
'vegs': [_serialize_dish(d) for d in (v0, v1) if d],
'soup': _serialize_dish(soup) if soup else None,
'staple': _serialize_dish(staple) if staple else None,
})
return week
def generate_week_menu(payload, menu_key='a'):
auto_all = payload.get('auto_all', True)
veg_per_day = 2 # 每天固定 2 个素菜
with_soup = bool(payload.get('with_soup', True))
with_staple = bool(payload.get('with_staple', True))
selected_ids = payload.get('selected_meat_ids', [])
meats = list(Dish.objects.filter(dish_type='meat', is_active=True))
vegs = list(Dish.objects.filter(dish_type='veg', is_active=True))
soups = list(Dish.objects.filter(dish_type='soup', is_active=True))
staples = list(Dish.objects.filter(dish_type='staple', is_active=True))
if payload.get('partial') or payload.get('selections'):
return _generate_partial_week(payload, meats, vegs, soups, staples, menu_key)
random.shuffle(vegs)
veg_pool = vegs[:]
random.shuffle(soups)
soup_pool = soups[:]
random.shuffle(staples)
staple_pool = staples[:]
random.shuffle(meats)
meat_pool = meats[:]
if auto_all:
day_meats = None
else:
selected = Dish.objects.filter(id__in=selected_ids, dish_type='meat')
day_meats = list(selected) + [d for d in meats if d not in selected]
day_meats = day_meats[:2]
week = []
for day in WEEKDAYS:
if auto_all:
day_meat_list = []
for _ in range(2):
item, meat_pool = _pop_next(meat_pool, meats)
day_meat_list.append(item)
else:
day_meat_list = day_meats[:] if day_meats else []
if len(day_meat_list) < 2:
extra, _ = _pick_unique([d for d in meats if d not in day_meat_list], 2 - len(day_meat_list))
day_meat_list += extra
day_veg = []
for _ in range(veg_per_day):
item, veg_pool = _pop_next(veg_pool, vegs)
day_veg.append(item)
soup = None
if with_soup and soup_pool:
soup, soup_pool = _pop_next(soup_pool, soups)
staple = None
if with_staple and staple_pool:
staple, staple_pool = _pop_next(staple_pool, staples)
week.append({
'day': day,
'meats': [{'id': d.id, 'name': d.name, 'ingredient': d.ingredient_detail, 'dish_type': d.dish_type, 'cuisine': d.cuisine_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_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_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_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, date_start=None, date_end=None):
school_name = school_name or SCHOOL_NAME
iso = date.today().isocalendar()
try:
week_no = int(week_no) if week_no is not None else iso.week
except (TypeError, ValueError):
week_no = iso.week
if not 1 <= week_no <= 53:
week_no = iso.week
# 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}菜单'
@api_view(['POST'])
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, date_start, date_end),
'week': generate_week_menu(data, menu_key=key),
}
return Response(menus)
def compute_daily_nutrition(week):
proteins, fats, calories = [], [], []
for day_menu in week:
items = list(day_menu['meats']) + list(day_menu['vegs'])
if day_menu.get('soup'):
items.append(day_menu['soup'])
if day_menu.get('staple'):
items.append(day_menu['staple'])
proteins.append(round(sum(x.get('protein', 0) or 0 for x in items), 1))
fats.append(round(sum(x.get('fat', 0) or 0 for x in items), 1))
calories.append(sum(x.get('calorie', 0) or 0 for x in items))
return min(proteins), max(fats), min(calories)
def nutrition_text(week):
min_p, max_f, min_c = compute_daily_nutrition(week)
return f'营养分析:蛋白质≥{min_p}g 脂肪≤{max_f}g 热量≥{min_c}kcal'
def build_week_sheet(ws, week, title, start_col=1):
headers = ['菜式', '主食', '荤菜', '素菜', '营养汤']
n_cols = len(headers)
off = start_col - 1
nutrition = nutrition_text(week)
ws.merge_cells(start_row=1, start_column=start_col, end_row=1, end_column=start_col + n_cols - 1)
cell = ws.cell(row=1, column=start_col, value=title)
cell.font = Font(size=16, bold=True)
cell.alignment = CENTER
ws.row_dimensions[1].height = 36
for col, name in enumerate(headers, start=1):
c = ws.cell(row=2, column=col + off, value=name)
c.font = Font(bold=True, color='FFFFFF')
c.fill = COLOR_HEADER
c.alignment = CENTER
c.border = BORDER
ws.row_dimensions[2].height = 26
def dish_lines(items):
lines = []
for d in items:
line = d['name']
if d.get('ingredient'):
line += f'\n({d["ingredient"]})'
lines.append(line)
return '\n'.join(lines)
for r, day_menu in enumerate(week, start=3):
values = [day_menu['day']]
values.append(dish_lines([day_menu['staple']]) if day_menu.get('staple') else '')
values.append(dish_lines(day_menu['meats']))
values.append(dish_lines(day_menu['vegs']))
values.append(dish_lines([day_menu['soup']]) if day_menu.get('soup') else '')
for col, val in enumerate(values, start=1):
c = ws.cell(row=r, column=col + off, value=val)
c.border = BORDER
if col == 1:
c.alignment = CENTER
c.fill = COLOR_DAY
c.font = Font(bold=True)
elif col == 2:
c.fill = COLOR_STAPLE
elif col == 3:
c.fill = COLOR_MEAT
elif col == 4:
c.fill = COLOR_VEG
else:
c.fill = COLOR_SOUP
if col > 1:
c.alignment = Alignment(vertical='top', horizontal='center', wrap_text=True)
ws.row_dimensions[r].height = 84
nut_row = len(week) + 3
ws.merge_cells(start_row=nut_row, start_column=start_col, end_row=nut_row, end_column=start_col + n_cols - 1)
nc = ws.cell(row=nut_row, column=start_col, value=nutrition)
nc.font = Font(bold=True, size=11)
nc.alignment = CENTER
nc.fill = PatternFill('solid', fgColor='FFF2CC')
for col in range(off + 1, off + n_cols + 1):
ws.cell(row=nut_row, column=col).border = BORDER
ws.row_dimensions[nut_row].height = 26
seen = {}
for day_menu in week:
for d in day_menu['meats'] + day_menu['vegs']:
seen.setdefault(d['id'], d)
dishes_detail = list(seen.values())
if dishes_detail:
detail_start = nut_row + 2
ws.merge_cells(start_row=detail_start, start_column=start_col, end_row=detail_start, end_column=start_col + n_cols - 1)
dc = ws.cell(row=detail_start, column=start_col, value='本周菜品营养分析(每份)')
dc.font = Font(size=13, bold=True)
dc.alignment = CENTER
dc.fill = COLOR_HEADER
for col in range(off + 1, off + n_cols + 1):
ws.cell(row=detail_start, column=col).border = BORDER
ws.row_dimensions[detail_start].height = 28
detail_headers = ['菜品', '类型', '蛋白质(g)', '脂肪(g)', '热量(kcal)']
for col, name in enumerate(detail_headers, start=1):
c = ws.cell(row=detail_start + 1, column=col + off, value=name)
c.font = Font(bold=True)
c.alignment = CENTER
c.border = BORDER
c.fill = PatternFill('solid', fgColor='D9E2F3')
ws.row_dimensions[detail_start + 1].height = 22
type_map = {'meat': '荤菜', 'veg': '素菜'}
for i, d in enumerate(dishes_detail, start=detail_start + 2):
row_vals = [d['name'], type_map.get(d.get('dish_type', ''), d.get('dish_type', '')),
d['protein'], d['fat'], d['calorie']]
for col, val in enumerate(row_vals, start=1):
c = ws.cell(row=i, column=col + off, value=val)
c.alignment = CENTER
c.border = BORDER
if col == 3 or col == 4:
c.number_format = '0.0'
if d.get('dish_type') == 'meat':
for col in range(off + 1, off + n_cols + 1):
ws.cell(row=i, column=col).fill = COLOR_MEAT
else:
for col in range(off + 1, off + n_cols + 1):
ws.cell(row=i, column=col).fill = COLOR_VEG
ws.row_dimensions[i].height = 20
total_row = detail_start + 2 + len(dishes_detail)
ws.merge_cells(start_row=total_row, start_column=start_col, end_row=total_row, end_column=start_col + n_cols - 1)
tc = ws.cell(row=total_row, column=start_col, value=nutrition)
tc.font = Font(bold=True, size=11)
tc.alignment = CENTER
tc.fill = PatternFill('solid', fgColor='FFF2CC')
for col in range(off + 1, off + n_cols + 1):
ws.cell(row=total_row, column=col).border = BORDER
ws.row_dimensions[total_row].height = 24
widths = {1: 10, 2: 16, 3: 34, 4: 34, 5: 28}
for i in range(n_cols):
ws.column_dimensions[get_column_letter(start_col + i)].width = widths[i + 1]
return total_row if dishes_detail else nut_row
@api_view(['POST'])
def export_excel(request):
wb = Workbook()
ws = wb.active
ws.title = '周菜单'
menus = request.data.get('menus')
last_row = 0
if menus and isinstance(menus, list):
labels = {m.get('label', ''): m for m in menus}
a = labels.get('营养A菜单') or labels.get('A')
b = labels.get('营养B菜单') or labels.get('B')
if a and a.get('week'):
last_row = max(last_row, build_week_sheet(ws, a['week'], a.get('title', '营养A菜单'), start_col=1))
if b and b.get('week'):
last_row = max(last_row, build_week_sheet(ws, b['week'], b.get('title', '营养B菜单'), start_col=7))
else:
week = request.data.get('week')
if not week:
return Response({'detail': '缺少 week 数据'}, status=status.HTTP_400_BAD_REQUEST)
title = request.data.get('title') or build_menu_title('A')
last_row = build_week_sheet(ws, week, title, start_col=1)
ws.freeze_panes = 'B3'
ws.print_area = f'A1:K{last_row}'
response = HttpResponse(
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
week_no = date.today().isocalendar().week
response['Content-Disposition'] = f'attachment; filename="学校订餐周菜单_第{week_no}周.xlsx"'
wb.save(response)
return response