- 新增 Pagination 通用组件:首页/上一页/页码/下一页/末页导航 - PickModal 和 SwapModal 集成分页功能,每页20项 - 搜索/筛选切换自动回到第1页 - 现代化CSS样式:hover浮起动效、主色高亮、移动端适配 - 新增 dashboard、admin 管理模板 - 菜系(cuisine)模型和迁移 - 构建脚本 build.py 支持 PyInstaller 打包 - 前端资源重新构建
454 lines
18 KiB
Python
454 lines
18 KiB
Python
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
|