Initial commit - Zhangmenu Django project
A menu management application built with Django, featuring: - Dish management with cover images - Order system with party dates and participants - REST API with Django REST Framework - Bootstrap-styled frontend Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+53
@@ -0,0 +1,53 @@
|
|||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
|
.settings.local.json
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Django
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
/media
|
||||||
|
/staticfiles
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Node (if used)
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# Design System Master File
|
||||||
|
|
||||||
|
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
|
||||||
|
> If that file exists, its rules **override** this Master file.
|
||||||
|
> If not, strictly follow the rules below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Project:** ZhangMenu
|
||||||
|
**Generated:** 2026-02-22 01:19:43
|
||||||
|
**Category:** Restaurant/Food Service
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Global Rules
|
||||||
|
|
||||||
|
### Color Palette
|
||||||
|
|
||||||
|
| Role | Hex | CSS Variable |
|
||||||
|
|------|-----|--------------|
|
||||||
|
| Primary | `#7C3AED` | `--color-primary` |
|
||||||
|
| Secondary | `#A78BFA` | `--color-secondary` |
|
||||||
|
| CTA/Accent | `#F97316` | `--color-cta` |
|
||||||
|
| Background | `#FAF5FF` | `--color-background` |
|
||||||
|
| Text | `#4C1D95` | `--color-text` |
|
||||||
|
|
||||||
|
**Color Notes:** Excitement purple + action orange
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
- **Heading Font:** Playfair Display SC
|
||||||
|
- **Body Font:** Karla
|
||||||
|
- **Mood:** restaurant, menu, culinary, elegant, foodie, hospitality
|
||||||
|
- **Google Fonts:** [Playfair Display SC + Karla](https://fonts.google.com/share?selection.family=Karla:wght@300;400;500;600;700|Playfair+Display+SC:wght@400;700)
|
||||||
|
|
||||||
|
**CSS Import:**
|
||||||
|
```css
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Karla:wght@300;400;500;600;700&family=Playfair+Display+SC:wght@400;700&display=swap');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Spacing Variables
|
||||||
|
|
||||||
|
| Token | Value | Usage |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| `--space-xs` | `4px` / `0.25rem` | Tight gaps |
|
||||||
|
| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |
|
||||||
|
| `--space-md` | `16px` / `1rem` | Standard padding |
|
||||||
|
| `--space-lg` | `24px` / `1.5rem` | Section padding |
|
||||||
|
| `--space-xl` | `32px` / `2rem` | Large gaps |
|
||||||
|
| `--space-2xl` | `48px` / `3rem` | Section margins |
|
||||||
|
| `--space-3xl` | `64px` / `4rem` | Hero padding |
|
||||||
|
|
||||||
|
### Shadow Depths
|
||||||
|
|
||||||
|
| Level | Value | Usage |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
|
||||||
|
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
|
||||||
|
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
|
||||||
|
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Specs
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Primary Button */
|
||||||
|
.btn-primary {
|
||||||
|
background: #F97316;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 200ms ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Secondary Button */
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: #7C3AED;
|
||||||
|
border: 2px solid #7C3AED;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 200ms ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cards
|
||||||
|
|
||||||
|
```css
|
||||||
|
.card {
|
||||||
|
background: #FAF5FF;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transition: all 200ms ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inputs
|
||||||
|
|
||||||
|
```css
|
||||||
|
.input {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #E2E8F0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: border-color 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: #7C3AED;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px #7C3AED20;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modals
|
||||||
|
|
||||||
|
```css
|
||||||
|
.modal-overlay {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Style Guidelines
|
||||||
|
|
||||||
|
**Style:** Vibrant & Block-based
|
||||||
|
|
||||||
|
**Keywords:** Bold, energetic, playful, block layout, geometric shapes, high color contrast, duotone, modern, energetic
|
||||||
|
|
||||||
|
**Best For:** Startups, creative agencies, gaming, social media, youth-focused, entertainment, consumer
|
||||||
|
|
||||||
|
**Key Effects:** Large sections (48px+ gaps), animated patterns, bold hover (color shift), scroll-snap, large type (32px+), 200-300ms
|
||||||
|
|
||||||
|
### Page Pattern
|
||||||
|
|
||||||
|
**Pattern Name:** App Store Style Landing
|
||||||
|
|
||||||
|
- **Conversion Strategy:** Show real screenshots. Include ratings (4.5+ stars). QR code for mobile. Platform-specific CTAs.
|
||||||
|
- **CTA Placement:** Download buttons prominent (App Store + Play Store) throughout
|
||||||
|
- **Section Order:** 1. Hero with device mockup, 2. Screenshots carousel, 3. Features with icons, 4. Reviews/ratings, 5. Download CTAs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-Patterns (Do NOT Use)
|
||||||
|
|
||||||
|
- ❌ Low-quality imagery
|
||||||
|
- ❌ Outdated hours
|
||||||
|
|
||||||
|
### Additional Forbidden Patterns
|
||||||
|
|
||||||
|
- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
|
||||||
|
- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
|
||||||
|
- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
|
||||||
|
- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
|
||||||
|
- ❌ **Instant state changes** — Always use transitions (150-300ms)
|
||||||
|
- ❌ **Invisible focus states** — Focus states must be visible for a11y
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-Delivery Checklist
|
||||||
|
|
||||||
|
Before delivering any UI code, verify:
|
||||||
|
|
||||||
|
- [ ] No emojis used as icons (use SVG instead)
|
||||||
|
- [ ] All icons from consistent icon set (Heroicons/Lucide)
|
||||||
|
- [ ] `cursor-pointer` on all clickable elements
|
||||||
|
- [ ] Hover states with smooth transitions (150-300ms)
|
||||||
|
- [ ] Light mode: text contrast 4.5:1 minimum
|
||||||
|
- [ ] Focus states visible for keyboard navigation
|
||||||
|
- [ ] `prefers-reduced-motion` respected
|
||||||
|
- [ ] Responsive: 375px, 768px, 1024px, 1440px
|
||||||
|
- [ ] No content hidden behind fixed navbars
|
||||||
|
- [ ] No horizontal scroll on mobile
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run administrative tasks."""
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'zhangmenu.settings')
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class MenuConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'menu'
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-02-21 13:39
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DishCategory',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=50, unique=True, verbose_name='分类名称')),
|
||||||
|
('icon', models.CharField(blank=True, max_length=50, verbose_name='图标类名')),
|
||||||
|
('order', models.IntegerField(default=0, verbose_name='排序')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '菜品分类',
|
||||||
|
'verbose_name_plural': '菜品分类',
|
||||||
|
'ordering': ['order', 'id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Dish',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100, verbose_name='菜品名称')),
|
||||||
|
('cover_image', models.ImageField(blank=True, null=True, upload_to='dishes/', verbose_name='封面图')),
|
||||||
|
('description', models.TextField(blank=True, verbose_name='做法描述(Markdown格式)')),
|
||||||
|
('ingredients', models.JSONField(default=list, verbose_name='材料清单')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||||
|
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dishes', to='menu.dishcategory', verbose_name='分类')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '菜品',
|
||||||
|
'verbose_name_plural': '菜品',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Order',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100, verbose_name='订单名称')),
|
||||||
|
('status', models.CharField(choices=[('in_progress', '进行中'), ('completed', '已完成')], default='in_progress', max_length=20, verbose_name='状态')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='完成时间')),
|
||||||
|
('note', models.TextField(blank=True, verbose_name='备注')),
|
||||||
|
('dishes', models.ManyToManyField(related_name='orders', to='menu.dish', verbose_name='菜品')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '订单',
|
||||||
|
'verbose_name_plural': '订单',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-02-21 15:03
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('menu', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='dish',
|
||||||
|
name='cover_image',
|
||||||
|
field=models.URLField(blank=True, null=True, verbose_name='封面图URL'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-02-21 15:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('menu', '0002_alter_dish_cover_image'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='dish',
|
||||||
|
name='cover_image',
|
||||||
|
field=models.CharField(blank=True, max_length=500, null=True, verbose_name='封面图URL'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-02-22 03:36
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('menu', '0003_alter_dish_cover_image'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='dish',
|
||||||
|
name='view_count',
|
||||||
|
field=models.IntegerField(default=0, verbose_name='浏览次数'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-02-22 14:39
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('menu', '0004_add_view_count'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='participants',
|
||||||
|
field=models.JSONField(default=list, verbose_name='参与人员'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='party_date',
|
||||||
|
field=models.DateTimeField(blank=True, null=True, verbose_name='聚会时间'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-03-05 14:46
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('menu', '0005_order_participants_order_party_date'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='images',
|
||||||
|
field=models.JSONField(default=list, verbose_name='聚会照片'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class DishCategory(models.Model):
|
||||||
|
"""菜品分类"""
|
||||||
|
name = models.CharField(max_length=50, unique=True, verbose_name="分类名称")
|
||||||
|
icon = models.CharField(max_length=50, blank=True, verbose_name="图标类名")
|
||||||
|
order = models.IntegerField(default=0, verbose_name="排序")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "菜品分类"
|
||||||
|
verbose_name_plural = "菜品分类"
|
||||||
|
ordering = ['order', 'id']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Dish(models.Model):
|
||||||
|
"""菜品"""
|
||||||
|
name = models.CharField(max_length=100, verbose_name="菜品名称")
|
||||||
|
category = models.ForeignKey(DishCategory, on_delete=models.SET_NULL, null=True, blank=True, related_name='dishes', verbose_name="分类")
|
||||||
|
cover_image = models.CharField(max_length=500, blank=True, null=True, verbose_name="封面图URL")
|
||||||
|
description = models.TextField(blank=True, verbose_name="做法描述(Markdown格式)")
|
||||||
|
ingredients = models.JSONField(default=list, verbose_name="材料清单")
|
||||||
|
view_count = models.IntegerField(default=0, verbose_name="浏览次数")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||||
|
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "菜品"
|
||||||
|
verbose_name_plural = "菜品"
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Order(models.Model):
|
||||||
|
"""订单"""
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('in_progress', '进行中'),
|
||||||
|
('completed', '已完成'),
|
||||||
|
]
|
||||||
|
|
||||||
|
name = models.CharField(max_length=100, verbose_name="订单名称")
|
||||||
|
dishes = models.ManyToManyField(Dish, related_name='orders', verbose_name="菜品")
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='in_progress', verbose_name="状态")
|
||||||
|
party_date = models.DateTimeField(null=True, blank=True, verbose_name="聚会时间")
|
||||||
|
participants = models.JSONField(default=list, verbose_name="参与人员")
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||||
|
completed_at = models.DateTimeField(null=True, blank=True, verbose_name="完成时间")
|
||||||
|
note = models.TextField(blank=True, verbose_name="备注")
|
||||||
|
images = models.JSONField(default=list, verbose_name="聚会照片")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "订单"
|
||||||
|
verbose_name_plural = "订单"
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def get_ingredients_summary(self):
|
||||||
|
"""汇总所有菜品需要的材料"""
|
||||||
|
summary = {}
|
||||||
|
for dish in self.dishes.all():
|
||||||
|
for ing in dish.ingredients:
|
||||||
|
name = ing.get('name', '').strip()
|
||||||
|
amount = ing.get('amount', '').strip()
|
||||||
|
if name:
|
||||||
|
if name in summary:
|
||||||
|
if amount:
|
||||||
|
summary[name] += f" + {amount}"
|
||||||
|
else:
|
||||||
|
summary[name] = amount
|
||||||
|
return [{'name': k, 'amount': v} for k, v in summary.items()]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from django.db.models import Count
|
||||||
|
from rest_framework import serializers
|
||||||
|
from .models import Dish, DishCategory, Order
|
||||||
|
|
||||||
|
|
||||||
|
class DishCategorySerializer(serializers.ModelSerializer):
|
||||||
|
"""分类序列化器"""
|
||||||
|
dish_count = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DishCategory
|
||||||
|
fields = ['id', 'name', 'icon', 'order', 'dish_count']
|
||||||
|
|
||||||
|
|
||||||
|
class DishSerializer(serializers.ModelSerializer):
|
||||||
|
"""菜品序列化器"""
|
||||||
|
category_name = serializers.CharField(source='category.name', read_only=True)
|
||||||
|
category_id = serializers.IntegerField(write_only=True, required=False, allow_null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Dish
|
||||||
|
fields = ['id', 'name', 'category', 'category_name', 'category_id',
|
||||||
|
'cover_image', 'description', 'ingredients', 'view_count',
|
||||||
|
'created_at', 'updated_at']
|
||||||
|
read_only_fields = ['created_at', 'updated_at', 'view_count']
|
||||||
|
|
||||||
|
|
||||||
|
class OrderSerializer(serializers.ModelSerializer):
|
||||||
|
"""订单序列化器"""
|
||||||
|
dishes_detail = DishSerializer(source='dishes', many=True, read_only=True)
|
||||||
|
ingredients_summary = serializers.SerializerMethodField()
|
||||||
|
dish_ids = serializers.ListField(
|
||||||
|
child=serializers.IntegerField(),
|
||||||
|
write_only=True,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Order
|
||||||
|
fields = ['id', 'name', 'dishes', 'dishes_detail', 'dish_ids',
|
||||||
|
'status', 'party_date', 'participants',
|
||||||
|
'created_at', 'completed_at', 'note', 'ingredients_summary', 'images']
|
||||||
|
read_only_fields = ['created_at', 'completed_at']
|
||||||
|
extra_kwargs = {
|
||||||
|
'dishes': {'required': False}
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_ingredients_summary(self, obj):
|
||||||
|
return obj.get_ingredients_summary()
|
||||||
|
|
||||||
|
def validate_dishes(self, value):
|
||||||
|
return value
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
dish_ids = validated_data.pop('dish_ids', [])
|
||||||
|
order = Order.objects.create(**validated_data)
|
||||||
|
if dish_ids:
|
||||||
|
dishes = Dish.objects.filter(id__in=dish_ids)
|
||||||
|
order.dishes.set(dishes)
|
||||||
|
return order
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
dish_ids = validated_data.pop('dish_ids', None)
|
||||||
|
for attr, value in validated_data.items():
|
||||||
|
setattr(instance, attr, value)
|
||||||
|
instance.save()
|
||||||
|
if dish_ids is not None:
|
||||||
|
dishes = Dish.objects.filter(id__in=dish_ids)
|
||||||
|
instance.dishes.set(dishes)
|
||||||
|
return instance
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from .dish_service import DishService
|
||||||
|
from .order_service import OrderService
|
||||||
|
from .parser_service import ParserService
|
||||||
|
|
||||||
|
__all__ = ['DishService', 'OrderService', 'ParserService']
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from django.db.models import F
|
||||||
|
from menu.models import Dish
|
||||||
|
|
||||||
|
|
||||||
|
class DishService:
|
||||||
|
"""菜品业务逻辑"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_queryset(category_id=None, search=None):
|
||||||
|
"""获取菜品查询集"""
|
||||||
|
queryset = Dish.objects.select_related('category')
|
||||||
|
|
||||||
|
if category_id:
|
||||||
|
queryset = queryset.filter(category_id=category_id)
|
||||||
|
if search:
|
||||||
|
queryset = queryset.filter(name__icontains=search)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def increment_view_count(dish):
|
||||||
|
"""增加浏览次数"""
|
||||||
|
Dish.objects.filter(pk=dish.pk).update(view_count=F('view_count') + 1)
|
||||||
|
dish.refresh_from_db(fields=['view_count'])
|
||||||
|
return dish
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from menu.models import Order
|
||||||
|
|
||||||
|
|
||||||
|
class OrderService:
|
||||||
|
"""订单业务逻辑"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_queryset(status_filter=None):
|
||||||
|
"""获取订单查询集"""
|
||||||
|
queryset = Order.objects.prefetch_related('dishes', 'dishes__category')
|
||||||
|
if status_filter:
|
||||||
|
queryset = queryset.filter(status=status_filter)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_order(serializer, dish_ids):
|
||||||
|
"""创建订单并绑定菜品"""
|
||||||
|
if dish_ids is None:
|
||||||
|
dish_ids = []
|
||||||
|
|
||||||
|
order = serializer.save(dish_ids=dish_ids)
|
||||||
|
return Order.objects.prefetch_related('dishes', 'dishes__category').get(pk=order.pk)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def complete_order(order):
|
||||||
|
"""完成订单"""
|
||||||
|
order.status = 'completed'
|
||||||
|
order.completed_at = timezone.now()
|
||||||
|
order.save()
|
||||||
|
return order
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from django.conf import settings
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class ParserService:
|
||||||
|
"""URL解析业务逻辑"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_url(url):
|
||||||
|
"""解析外部URL获取菜品信息"""
|
||||||
|
if not url:
|
||||||
|
raise ValueError('请提供URL')
|
||||||
|
|
||||||
|
parsed_url = urlparse(url)
|
||||||
|
hostname = parsed_url.hostname
|
||||||
|
allowed_hosts = getattr(settings, 'ALLOWED_RECIPE_HOSTS', set())
|
||||||
|
|
||||||
|
if parsed_url.scheme not in {'http', 'https'}:
|
||||||
|
raise ValueError('URL协议不受支持')
|
||||||
|
if not hostname:
|
||||||
|
raise ValueError('URL缺少主机名')
|
||||||
|
if hostname not in allowed_hosts:
|
||||||
|
raise ValueError('该站点不在允许的解析范围内')
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||||
|
}
|
||||||
|
response = requests.get(url, headers=headers, timeout=10)
|
||||||
|
response.encoding = 'utf-8'
|
||||||
|
soup = BeautifulSoup(response.text, 'html.parser')
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'name': '',
|
||||||
|
'cover_image': '',
|
||||||
|
'description': '',
|
||||||
|
'ingredients': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# 根据不同网站选择解析方法
|
||||||
|
if hostname in {'xiachufang.com', 'www.xiachufang.com'}:
|
||||||
|
result = ParserService._parse_xiachufang(soup, url)
|
||||||
|
elif hostname in {'meishij.net', 'www.meishij.net'}:
|
||||||
|
result = ParserService._parse_meishij(soup)
|
||||||
|
elif hostname in {'caipuw.com', 'www.caipuw.com'}:
|
||||||
|
result = ParserService._parse_caipuw(soup)
|
||||||
|
else:
|
||||||
|
result = ParserService._parse_generic(soup)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_xiachufang(soup, url):
|
||||||
|
"""解析下厨房网站"""
|
||||||
|
result = {
|
||||||
|
'name': '',
|
||||||
|
'cover_image': '',
|
||||||
|
'description': '',
|
||||||
|
'ingredients': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# 尝试从JSON-LD获取数据
|
||||||
|
json_ld = soup.find_all('script', type='application/ld+json')
|
||||||
|
for script in json_ld:
|
||||||
|
try:
|
||||||
|
data = json.loads(script.string)
|
||||||
|
if data.get('@type') == 'Recipe':
|
||||||
|
result['name'] = data.get('name', '')
|
||||||
|
result['cover_image'] = data.get('image', '')
|
||||||
|
if isinstance(result['cover_image'], list):
|
||||||
|
result['cover_image'] = result['cover_image'][0]
|
||||||
|
|
||||||
|
# 解析材料
|
||||||
|
for ing in data.get('recipeIngredient', []):
|
||||||
|
match = re.match(r'^([\d.]+\s*[克g个勺片块大中小]+)?\s*(.+)$', ing)
|
||||||
|
if match and match.group(2):
|
||||||
|
amount, name = match.groups()
|
||||||
|
result['ingredients'].append({
|
||||||
|
'name': name.strip(),
|
||||||
|
'amount': (amount.strip() + '克') if amount and not ('克' in amount or '个' in amount or '勺' in amount or '片' in amount or '块' in amount) else (amount.strip() if amount else '')
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
result['ingredients'].append({'name': ing, 'amount': ''})
|
||||||
|
|
||||||
|
# 解析做法
|
||||||
|
steps = []
|
||||||
|
recipe_steps = data.get('recipeInstructions', [])
|
||||||
|
if isinstance(recipe_steps, list):
|
||||||
|
for step in recipe_steps:
|
||||||
|
text = step.get('text', '') if isinstance(step, dict) else str(step)
|
||||||
|
if text:
|
||||||
|
steps.append(text)
|
||||||
|
elif isinstance(recipe_steps, str):
|
||||||
|
parts = re.split(r'(?=\d+\.)', recipe_steps)
|
||||||
|
for part in parts:
|
||||||
|
part = re.sub(r'^\d+\.\s*', '', part.strip())
|
||||||
|
if part:
|
||||||
|
steps.append(part)
|
||||||
|
|
||||||
|
if steps:
|
||||||
|
result['description'] = '\n\n'.join([f'{i+1}. {s}' for i, s in enumerate(steps)])
|
||||||
|
break
|
||||||
|
except (TypeError, ValueError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 回退到HTML解析
|
||||||
|
if not result['name']:
|
||||||
|
title = soup.select_one('h1.title')
|
||||||
|
if title:
|
||||||
|
result['name'] = title.get_text(strip=True)
|
||||||
|
|
||||||
|
if not result['cover_image']:
|
||||||
|
cover = soup.select_one('div.cover img') or soup.select_one('div.image img')
|
||||||
|
if cover:
|
||||||
|
result['cover_image'] = cover.get('src', '')
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_meishij(soup):
|
||||||
|
"""解析美食杰网站"""
|
||||||
|
result = {
|
||||||
|
'name': '',
|
||||||
|
'cover_image': '',
|
||||||
|
'description': '',
|
||||||
|
'ingredients': []
|
||||||
|
}
|
||||||
|
|
||||||
|
title = soup.select_one('h1.recipe_title')
|
||||||
|
if title:
|
||||||
|
result['name'] = title.get_text(strip=True)
|
||||||
|
|
||||||
|
cover = soup.select_one('div.recipe_img img')
|
||||||
|
if cover:
|
||||||
|
result['cover_image'] = cover.get('src', '')
|
||||||
|
|
||||||
|
# 材料
|
||||||
|
ing_list = soup.select('div.yl li')
|
||||||
|
for ing in ing_list:
|
||||||
|
text = ing.get_text(strip=True)
|
||||||
|
if text:
|
||||||
|
parts = text.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
result['ingredients'].append({'name': parts[0], 'amount': ' '.join(parts[1:])})
|
||||||
|
elif len(parts) == 1:
|
||||||
|
result['ingredients'].append({'name': parts[0], 'amount': ''})
|
||||||
|
|
||||||
|
# 做法
|
||||||
|
steps = []
|
||||||
|
step_list = soup.select('div.step_content')
|
||||||
|
for step in step_list:
|
||||||
|
text = step.get_text(strip=True)
|
||||||
|
if text:
|
||||||
|
steps.append(text)
|
||||||
|
result['description'] = '\n\n'.join([f'{i+1}. {s}' for i, s in enumerate(steps)])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_caipuw(soup):
|
||||||
|
"""解析菜谱大全网站"""
|
||||||
|
result = {
|
||||||
|
'name': '',
|
||||||
|
'cover_image': '',
|
||||||
|
'description': '',
|
||||||
|
'ingredients': []
|
||||||
|
}
|
||||||
|
|
||||||
|
title = soup.select_one('h1.title')
|
||||||
|
if title:
|
||||||
|
result['name'] = title.get_text(strip=True)
|
||||||
|
|
||||||
|
cover = soup.select_one('div.image img')
|
||||||
|
if cover:
|
||||||
|
result['cover_image'] = cover.get('src', '')
|
||||||
|
|
||||||
|
# 材料
|
||||||
|
ing_list = soup.select('div.ingredients li') or soup.select('ul.ingredients li')
|
||||||
|
for ing in ing_list:
|
||||||
|
text = ing.get_text(strip=True)
|
||||||
|
if text:
|
||||||
|
result['ingredients'].append({'name': text, 'amount': ''})
|
||||||
|
|
||||||
|
# 做法
|
||||||
|
steps = []
|
||||||
|
step_list = soup.select('div.steps li') or soup.select('div.step')
|
||||||
|
for step in step_list:
|
||||||
|
text = step.get_text(strip=True)
|
||||||
|
if text:
|
||||||
|
steps.append(text)
|
||||||
|
result['description'] = '\n\n'.join([f'{i+1}. {s}' for i, s in enumerate(steps)])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_generic(soup):
|
||||||
|
"""通用解析"""
|
||||||
|
result = {
|
||||||
|
'name': '',
|
||||||
|
'cover_image': '',
|
||||||
|
'description': '',
|
||||||
|
'ingredients': []
|
||||||
|
}
|
||||||
|
|
||||||
|
title = soup.select_one('h1') or soup.select_one('title')
|
||||||
|
if title:
|
||||||
|
result['name'] = title.get_text(strip=True)[:100]
|
||||||
|
|
||||||
|
img = soup.select_one('article img') or soup.select_one('div.content img') or soup.select_one('img')
|
||||||
|
if img:
|
||||||
|
result['cover_image'] = img.get('src', '')
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from menu.models import Dish, DishCategory, Order
|
||||||
|
|
||||||
|
|
||||||
|
TEST_MEDIA_ROOT = Path(settings.BASE_DIR) / 'test_media'
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(MEDIA_ROOT=TEST_MEDIA_ROOT)
|
||||||
|
class UploadImageTests(TestCase):
|
||||||
|
def tearDown(self):
|
||||||
|
if TEST_MEDIA_ROOT.exists():
|
||||||
|
for path in sorted(TEST_MEDIA_ROOT.rglob('*'), reverse=True):
|
||||||
|
if path.is_file():
|
||||||
|
path.unlink()
|
||||||
|
elif path.is_dir():
|
||||||
|
path.rmdir()
|
||||||
|
|
||||||
|
def test_upload_rejects_invalid_extension(self):
|
||||||
|
image = SimpleUploadedFile('bad.txt', b'file-content', content_type='image/png')
|
||||||
|
|
||||||
|
response = self.client.post(reverse('upload'), {'image': image})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertEqual(response.json()['error'], 'Unsupported image extension')
|
||||||
|
|
||||||
|
def test_upload_rejects_invalid_content_type(self):
|
||||||
|
image = SimpleUploadedFile('dish.png', b'file-content', content_type='text/plain')
|
||||||
|
|
||||||
|
response = self.client.post(reverse('upload'), {'image': image})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertEqual(response.json()['error'], 'Unsupported image content type')
|
||||||
|
|
||||||
|
@override_settings(MAX_UPLOAD_IMAGE_SIZE=4)
|
||||||
|
def test_upload_rejects_oversized_file(self):
|
||||||
|
image = SimpleUploadedFile('dish.png', b'12345', content_type='image/png')
|
||||||
|
|
||||||
|
response = self.client.post(reverse('upload'), {'image': image})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertEqual(response.json()['error'], 'Image file too large')
|
||||||
|
|
||||||
|
def test_upload_returns_absolute_media_url(self):
|
||||||
|
image = SimpleUploadedFile('dish.png', b'png-bytes', content_type='image/png')
|
||||||
|
|
||||||
|
response = self.client.post(reverse('upload'), {'image': image}, secure=True)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
self.assertTrue(data['url'].startswith('https://testserver/media/dishes/'))
|
||||||
|
|
||||||
|
|
||||||
|
class ParseUrlTests(TestCase):
|
||||||
|
def test_parse_url_rejects_non_allowlisted_host(self):
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/parse-url/',
|
||||||
|
data=json.dumps({'url': 'http://127.0.0.1/internal'}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertEqual(response.json()['error'], '该站点不在允许的解析范围内')
|
||||||
|
|
||||||
|
|
||||||
|
class OrderApiTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
category = DishCategory.objects.create(name='热菜')
|
||||||
|
self.dish1 = Dish.objects.create(name='宫保鸡丁', category=category, ingredients=[{'name': '鸡肉', 'amount': '300g'}])
|
||||||
|
self.dish2 = Dish.objects.create(name='鱼香肉丝', category=category, ingredients=[{'name': '猪肉', 'amount': '200g'}])
|
||||||
|
|
||||||
|
def test_create_order_binds_dish_ids_and_returns_saved_object(self):
|
||||||
|
payload = {
|
||||||
|
'name': '周末聚会',
|
||||||
|
'dish_ids': [self.dish1.id, self.dish2.id],
|
||||||
|
'participants': ['张三', '李四'],
|
||||||
|
'party_date': '2026-03-10T12:30:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.client.post('/api/orders/', payload, content_type='application/json')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
data = response.json()
|
||||||
|
self.assertEqual(data['name'], '周末聚会')
|
||||||
|
self.assertEqual(sorted(d['id'] for d in data['dishes_detail']), sorted([self.dish1.id, self.dish2.id]))
|
||||||
|
self.assertEqual(data['participants'], ['张三', '李四'])
|
||||||
|
|
||||||
|
order = Order.objects.get(pk=data['id'])
|
||||||
|
self.assertEqual(order.dishes.count(), 2)
|
||||||
|
self.assertEqual(sorted(order.dishes.values_list('id', flat=True)), sorted([self.dish1.id, self.dish2.id]))
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'categories', views.DishCategoryViewSet)
|
||||||
|
router.register(r'dishes', views.DishViewSet)
|
||||||
|
router.register(r'orders', views.OrderViewSet)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', views.index, name='index'),
|
||||||
|
path('api/', include(router.urls)),
|
||||||
|
path('api/parse-url/', views.ParseUrlView.as_view()),
|
||||||
|
path('api/upload/', views.upload_image, name='upload'),
|
||||||
|
]
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from rest_framework import status, viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from menu.models import Dish, DishCategory, Order
|
||||||
|
from menu.serializers import DishSerializer, DishCategorySerializer, OrderSerializer
|
||||||
|
from menu.services import DishService, OrderService, ParserService
|
||||||
|
|
||||||
|
|
||||||
|
def index(request):
|
||||||
|
"""首页"""
|
||||||
|
return render(request, 'index.html')
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
def upload_image(request):
|
||||||
|
"""上传图片"""
|
||||||
|
if request.method != 'POST':
|
||||||
|
return JsonResponse({'error': 'Method not allowed'}, status=405)
|
||||||
|
|
||||||
|
image = request.FILES.get('image')
|
||||||
|
if not image:
|
||||||
|
return JsonResponse({'error': 'No image provided'}, status=400)
|
||||||
|
|
||||||
|
ext = Path(image.name).suffix.lower()
|
||||||
|
if ext not in settings.ALLOWED_UPLOAD_IMAGE_EXTENSIONS:
|
||||||
|
return JsonResponse({'error': 'Unsupported image extension'}, status=400)
|
||||||
|
|
||||||
|
content_type = (image.content_type or '').lower()
|
||||||
|
if content_type not in settings.ALLOWED_UPLOAD_IMAGE_CONTENT_TYPES:
|
||||||
|
return JsonResponse({'error': 'Unsupported image content type'}, status=400)
|
||||||
|
|
||||||
|
if image.size > settings.MAX_UPLOAD_IMAGE_SIZE:
|
||||||
|
return JsonResponse({'error': 'Image file too large'}, status=400)
|
||||||
|
|
||||||
|
filename = f"dishes/{uuid.uuid4().hex}{ext}"
|
||||||
|
saved_path = default_storage.save(filename, image)
|
||||||
|
media_path = str(saved_path).lstrip('/')
|
||||||
|
media_url = urljoin(settings.MEDIA_URL, media_path)
|
||||||
|
|
||||||
|
return JsonResponse({'url': request.build_absolute_uri(media_url)})
|
||||||
|
|
||||||
|
|
||||||
|
class DishCategoryViewSet(viewsets.ModelViewSet):
|
||||||
|
"""分类视图"""
|
||||||
|
queryset = DishCategory.objects.all()
|
||||||
|
serializer_class = DishCategorySerializer
|
||||||
|
|
||||||
|
|
||||||
|
class DishViewSet(viewsets.ModelViewSet):
|
||||||
|
"""菜品视图"""
|
||||||
|
queryset = Dish.objects.all()
|
||||||
|
serializer_class = DishSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
category_id = self.request.query_params.get('category')
|
||||||
|
search = self.request.query_params.get('search')
|
||||||
|
return DishService.get_queryset(category_id, search)
|
||||||
|
|
||||||
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
instance = self.get_object()
|
||||||
|
DishService.increment_view_count(instance)
|
||||||
|
serializer = self.get_serializer(instance)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderViewSet(viewsets.ModelViewSet):
|
||||||
|
"""订单视图"""
|
||||||
|
queryset = Order.objects.all()
|
||||||
|
serializer_class = OrderSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
status_filter = self.request.query_params.get('status')
|
||||||
|
return OrderService.get_queryset(status_filter)
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
request_data = request.data.copy()
|
||||||
|
dish_ids = request_data.pop('dish_ids', [])
|
||||||
|
serializer = self.get_serializer(data=request_data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
order = OrderService.create_order(serializer, dish_ids)
|
||||||
|
response_serializer = self.get_serializer(order)
|
||||||
|
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def complete(self, request, pk=None):
|
||||||
|
order = self.get_object()
|
||||||
|
order = OrderService.complete_order(order)
|
||||||
|
serializer = self.get_serializer(order)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'])
|
||||||
|
def share(self, request, pk=None):
|
||||||
|
order = self.get_object()
|
||||||
|
serializer = self.get_serializer(order)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class ParseUrlView(APIView):
|
||||||
|
"""解析外部链接"""
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
url = request.data.get('url', '')
|
||||||
|
try:
|
||||||
|
result = ParserService.parse_url(url)
|
||||||
|
return Response(result)
|
||||||
|
except ValueError as e:
|
||||||
|
return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
except Exception as e:
|
||||||
|
return Response({'error': f'解析失败: {str(e)}'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
Vendored
+5
File diff suppressed because one or more lines are too long
Vendored
+6
File diff suppressed because one or more lines are too long
Vendored
+7
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#fff7ed"/>
|
||||||
|
<circle cx="32" cy="34" r="18" fill="#c2410c"/>
|
||||||
|
<path d="M22 24c0-3.5 2.5-6 5.5-6 1.8 0 3.3.8 4.5 2.3 1.2-1.5 2.7-2.3 4.5-2.3 3 0 5.5 2.5 5.5 6 0 2.9-1.6 5.2-4.8 7.8-1.8 1.5-3.8 2.9-5.2 4-1.4-1.1-3.4-2.5-5.2-4-3.2-2.6-4.8-4.9-4.8-7.8z" fill="#fff7ed"/>
|
||||||
|
<path d="M18 46c3.5-4.2 8.6-6.4 14-6.4S42.5 41.8 46 46" fill="none" stroke="#fdba74" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 510 B |
Binary file not shown.
Binary file not shown.
Vendored
+20
File diff suppressed because one or more lines are too long
@@ -0,0 +1,90 @@
|
|||||||
|
// API 调用封装
|
||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
const API = {
|
||||||
|
// 菜品相关
|
||||||
|
async getDishes(params = {}) {
|
||||||
|
const query = new URLSearchParams(params).toString();
|
||||||
|
const res = await fetch(`${API_BASE}/dishes/${query ? '?' + query : ''}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getDish(id) {
|
||||||
|
const res = await fetch(`${API_BASE}/dishes/${id}/`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async createDish(data) {
|
||||||
|
const res = await fetch(`${API_BASE}/dishes/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateDish(id, data) {
|
||||||
|
const res = await fetch(`${API_BASE}/dishes/${id}/`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteDish(id) {
|
||||||
|
await fetch(`${API_BASE}/dishes/${id}/`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
|
||||||
|
// 分类相关
|
||||||
|
async getCategories() {
|
||||||
|
const res = await fetch(`${API_BASE}/categories/`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
// 订单相关
|
||||||
|
async getOrders(params = {}) {
|
||||||
|
const query = new URLSearchParams(params).toString();
|
||||||
|
const res = await fetch(`${API_BASE}/orders/${query ? '?' + query : ''}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async createOrder(data) {
|
||||||
|
const res = await fetch(`${API_BASE}/orders/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async completeOrder(id) {
|
||||||
|
const res = await fetch(`${API_BASE}/orders/${id}/complete/`, { method: 'POST' });
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteOrder(id) {
|
||||||
|
await fetch(`${API_BASE}/orders/${id}/`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
|
||||||
|
// URL解析
|
||||||
|
async parseUrl(url) {
|
||||||
|
const res = await fetch(`${API_BASE}/parse-url/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url })
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
// 图片上传
|
||||||
|
async uploadImage(file) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
const res = await fetch('/upload-image/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// 应用入口
|
||||||
|
|
||||||
|
const App = {
|
||||||
|
// 初始化应用
|
||||||
|
async init() {
|
||||||
|
console.log('应用初始化...');
|
||||||
|
|
||||||
|
// 初始化事件监听
|
||||||
|
this.initEventListeners();
|
||||||
|
|
||||||
|
// 加载初始数据
|
||||||
|
await this.loadInitialData();
|
||||||
|
|
||||||
|
// 显示默认页面
|
||||||
|
this.switchPage('menu');
|
||||||
|
|
||||||
|
console.log('应用初始化完成');
|
||||||
|
},
|
||||||
|
|
||||||
|
// 初始化事件监听
|
||||||
|
initEventListeners() {
|
||||||
|
// 底部导航点击
|
||||||
|
document.querySelectorAll('.nav-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const page = item.dataset.page;
|
||||||
|
if (page) {
|
||||||
|
App.switchPage(page);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// FAB按钮 - 根据当前页面决定行为
|
||||||
|
const fab = document.getElementById('fab-add');
|
||||||
|
if (fab) {
|
||||||
|
fab.addEventListener('click', () => {
|
||||||
|
if (AppState.currentPage === 'menu') {
|
||||||
|
// 菜单页:创建新菜品
|
||||||
|
showDishModal();
|
||||||
|
} else if (AppState.currentPage === 'orders') {
|
||||||
|
// 订单页:创建新订单
|
||||||
|
showOrderModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索按钮
|
||||||
|
const searchBtn = document.getElementById('search-btn');
|
||||||
|
if (searchBtn) {
|
||||||
|
searchBtn.addEventListener('click', () => {
|
||||||
|
const keyword = document.getElementById('search-input').value;
|
||||||
|
AppState.setPage('search');
|
||||||
|
document.getElementById('search-panel').classList.add('active');
|
||||||
|
MenuPage.searchDishes(keyword);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索面板关闭
|
||||||
|
const searchBackBtn = document.getElementById('search-panel-back');
|
||||||
|
if (searchBackBtn) {
|
||||||
|
searchBackBtn.addEventListener('click', () => {
|
||||||
|
document.getElementById('search-panel').classList.remove('active');
|
||||||
|
App.switchPage(AppState.currentPage || 'menu');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 加载初始数据
|
||||||
|
async loadInitialData() {
|
||||||
|
try {
|
||||||
|
// 并行加载所有数据
|
||||||
|
const [categoriesRes, dishesRes, ordersRes] = await Promise.all([
|
||||||
|
fetch(`${API_BASE}/categories/`),
|
||||||
|
fetch(`${API_BASE}/dishes/`),
|
||||||
|
fetch(`${API_BASE}/orders/`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const categories = await categoriesRes.json();
|
||||||
|
const dishes = await dishesRes.json();
|
||||||
|
const orders = await ordersRes.json();
|
||||||
|
|
||||||
|
AppState.setCategories(categories);
|
||||||
|
AppState.setDishes(dishes);
|
||||||
|
AppState.setOrders(orders);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载初始数据失败:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 切换页面
|
||||||
|
switchPage(page) {
|
||||||
|
// 更新状态
|
||||||
|
AppState.setPage(page);
|
||||||
|
|
||||||
|
// 更新导航
|
||||||
|
document.querySelectorAll('.nav-item').forEach(item => {
|
||||||
|
item.classList.toggle('active', item.dataset.page === page);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新页面显示
|
||||||
|
document.querySelectorAll('.page').forEach(p => {
|
||||||
|
p.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageEl = document.getElementById(`${page}-page`);
|
||||||
|
if (pageEl) {
|
||||||
|
pageEl.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据页面执行对应初始化
|
||||||
|
if (page === 'menu') {
|
||||||
|
MenuPage.init();
|
||||||
|
} else if (page === 'orders') {
|
||||||
|
OrdersPage.init();
|
||||||
|
} else if (page === 'stats') {
|
||||||
|
StatsPage.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// DOM 加载完成后初始化
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// 初始化全局变量(保持向后兼容)
|
||||||
|
window.API_BASE = '/api';
|
||||||
|
|
||||||
|
// 初始化应用
|
||||||
|
App.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 导出
|
||||||
|
window.App = App;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// 菜品卡片组件
|
||||||
|
|
||||||
|
// 渲染菜品卡片
|
||||||
|
function renderDishCard(dish, isInOrder = false) {
|
||||||
|
const indicatorClass = isInOrder ? 'in-order' : 'not-in-order';
|
||||||
|
const indicatorIcon = isInOrder ? '✓' : '+';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="dish-card" data-id="${dish.id}">
|
||||||
|
<div class="img-wrapper">
|
||||||
|
<img src="${dish.cover_image || getDefaultImage()}" alt="${dish.name}">
|
||||||
|
${dish.category_name ? `<span class="category-tag">${dish.category_name}</span>` : ''}
|
||||||
|
<span class="order-indicator ${indicatorClass}">${indicatorIcon}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="dish-name">${dish.name}</div>
|
||||||
|
<div class="dish-meta">${dish.view_count || 0} 次浏览</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染菜品网格
|
||||||
|
function renderDishGrid(dishes, containerSelector, onCardClick) {
|
||||||
|
const container = document.querySelector(containerSelector);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = dishes.map(dish => renderDishCard(dish)).join('');
|
||||||
|
|
||||||
|
container.querySelectorAll('.dish-card').forEach(card => {
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
if (onCardClick) {
|
||||||
|
onCardClick(card.dataset.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出
|
||||||
|
window.DishCard = {
|
||||||
|
render: renderDishCard,
|
||||||
|
renderGrid: renderDishGrid
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// 弹窗组件
|
||||||
|
|
||||||
|
const Modal = {
|
||||||
|
// 显示弹窗
|
||||||
|
show(modalId) {
|
||||||
|
const modal = document.getElementById(modalId);
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'block';
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 隐藏弹窗
|
||||||
|
hide(modalId) {
|
||||||
|
const modal = document.getElementById(modalId);
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 切换弹窗
|
||||||
|
toggle(modalId) {
|
||||||
|
const modal = document.getElementById(modalId);
|
||||||
|
if (modal) {
|
||||||
|
if (modal.style.display === 'none') {
|
||||||
|
this.show(modalId);
|
||||||
|
} else {
|
||||||
|
this.hide(modalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 显示确认对话框
|
||||||
|
confirm(message) {
|
||||||
|
return confirm(message);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 显示加载状态
|
||||||
|
showLoading(modalId) {
|
||||||
|
const modal = document.getElementById(modalId);
|
||||||
|
if (modal) {
|
||||||
|
const footer = modal.querySelector('.modal-footer');
|
||||||
|
if (footer) {
|
||||||
|
footer.dataset.originalContent = footer.innerHTML;
|
||||||
|
footer.innerHTML = '<span class="loading-text">加载中...</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 隐藏加载状态
|
||||||
|
hideLoading(modalId) {
|
||||||
|
const modal = document.getElementById(modalId);
|
||||||
|
if (modal) {
|
||||||
|
const footer = modal.querySelector('.modal-footer');
|
||||||
|
if (footer && footer.dataset.originalContent) {
|
||||||
|
footer.innerHTML = footer.dataset.originalContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bootstrap 模态框包装
|
||||||
|
const BootstrapModal = {
|
||||||
|
init(modalId) {
|
||||||
|
return new bootstrap.Modal(document.getElementById(modalId));
|
||||||
|
},
|
||||||
|
|
||||||
|
show(modalId) {
|
||||||
|
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById(modalId));
|
||||||
|
modal.show();
|
||||||
|
},
|
||||||
|
|
||||||
|
hide(modalId) {
|
||||||
|
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById(modalId));
|
||||||
|
modal.hide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出
|
||||||
|
window.Modal = Modal;
|
||||||
|
window.BootstrapModal = BootstrapModal;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// 订单卡片组件
|
||||||
|
|
||||||
|
// 渲染订单卡片
|
||||||
|
function renderOrderCard(order) {
|
||||||
|
const statusClass = order.status === 'in_progress' ? 'status-progress' : 'status-done';
|
||||||
|
const statusText = order.status === 'in_progress' ? '进行中' : '已完成';
|
||||||
|
|
||||||
|
// 显示聚会时间或创建时间
|
||||||
|
const displayDate = order.party_date
|
||||||
|
? new Date(order.party_date).toLocaleDateString()
|
||||||
|
: (order.created_at ? new Date(order.created_at).toLocaleDateString() : '');
|
||||||
|
|
||||||
|
const dishCount = order.dishes_detail ? order.dishes_detail.length : 0;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="order-card" data-id="${order.id}">
|
||||||
|
<div class="order-top">
|
||||||
|
<span class="order-name">${order.name}</span>
|
||||||
|
<span class="order-status ${statusClass}">${statusText}</span>
|
||||||
|
</div>
|
||||||
|
<div class="order-date">${displayDate} · ${dishCount} 道菜</div>
|
||||||
|
<div class="dish-list">
|
||||||
|
${(order.dishes_detail || []).map(d => {
|
||||||
|
const isCold = d.category_name && d.category_name.includes('凉菜');
|
||||||
|
return `<span class="dish-tag ${isCold ? 'dish-tag-cold' : ''}">${d.name}</span>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染订单列表
|
||||||
|
function renderOrderList(orders, containerSelector, onCardClick) {
|
||||||
|
const container = document.querySelector(containerSelector);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = orders.map(order => renderOrderCard(order)).join('');
|
||||||
|
|
||||||
|
container.querySelectorAll('.order-card').forEach(card => {
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
if (onCardClick) {
|
||||||
|
onCardClick(card.dataset.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出
|
||||||
|
window.OrderCard = {
|
||||||
|
render: renderOrderCard,
|
||||||
|
renderList: renderOrderList
|
||||||
|
};
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// 菜单页面模块
|
||||||
|
|
||||||
|
const MenuPage = {
|
||||||
|
// 初始化
|
||||||
|
init() {
|
||||||
|
this.loadCategories();
|
||||||
|
this.loadDishes();
|
||||||
|
},
|
||||||
|
|
||||||
|
// 加载分类
|
||||||
|
async loadCategories() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/categories/`);
|
||||||
|
const data = await res.json();
|
||||||
|
AppState.setCategories(data);
|
||||||
|
this.renderCategories();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载分类失败:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 加载菜品
|
||||||
|
async loadDishes() {
|
||||||
|
try {
|
||||||
|
const params = {};
|
||||||
|
if (AppState.currentCategory) {
|
||||||
|
const category = AppState.categories.find(c => c.name === AppState.currentCategory);
|
||||||
|
if (category) params.category = category.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/dishes/${new URLSearchParams(params).toString() ? '?' + new URLSearchParams(params).toString() : ''}`);
|
||||||
|
const data = await res.json();
|
||||||
|
AppState.setDishes(data);
|
||||||
|
this.renderDishes();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载菜品失败:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 渲染分类
|
||||||
|
renderCategories() {
|
||||||
|
const container = document.getElementById('category-filter');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<span class="category-chip ${!AppState.currentCategory ? 'active' : ''}" data-category="">全部</span>
|
||||||
|
${AppState.categories.map(c => `
|
||||||
|
<span class="category-chip ${AppState.currentCategory === c.name ? 'active' : ''}" data-category="${c.name}">${c.name}</span>
|
||||||
|
`).join('')}
|
||||||
|
`;
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
// 绑定点击事件
|
||||||
|
container.querySelectorAll('.category-chip').forEach(chip => {
|
||||||
|
chip.addEventListener('click', () => {
|
||||||
|
AppState.setCurrentCategory(chip.dataset.category);
|
||||||
|
this.renderCategories();
|
||||||
|
this.loadDishes();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 渲染菜品
|
||||||
|
renderDishes() {
|
||||||
|
const container = document.getElementById('dish-grid');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const currentOrder = AppState.getCurrentOrder();
|
||||||
|
const dishes = AppState.getFilteredDishes();
|
||||||
|
|
||||||
|
container.innerHTML = dishes.map(dish => {
|
||||||
|
const isInOrder = currentOrder && currentOrder.dishes && currentOrder.dishes.includes(dish.id);
|
||||||
|
return DishCard.render(dish, isInOrder);
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// 绑定点击事件
|
||||||
|
container.querySelectorAll('.dish-card').forEach(card => {
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
showDishDetail(card.dataset.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 搜索菜品
|
||||||
|
async searchDishes(keyword) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/dishes/?search=${encodeURIComponent(keyword)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
AppState.setDishes(data);
|
||||||
|
this.renderDishes();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('搜索失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出
|
||||||
|
window.MenuPage = MenuPage;
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// 订单页面模块
|
||||||
|
|
||||||
|
const OrdersPage = {
|
||||||
|
// 初始化
|
||||||
|
init() {
|
||||||
|
this.loadOrders();
|
||||||
|
},
|
||||||
|
|
||||||
|
// 加载订单
|
||||||
|
async loadOrders() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/orders/`);
|
||||||
|
const data = await res.json();
|
||||||
|
AppState.setOrders(data);
|
||||||
|
this.renderOrders();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载订单失败:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 渲染订单
|
||||||
|
renderOrders() {
|
||||||
|
const container = document.getElementById('order-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const orders = AppState.getFilteredOrders();
|
||||||
|
|
||||||
|
if (orders.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">📋</div>
|
||||||
|
<div class="empty-title">暂无聚会</div>
|
||||||
|
<div class="empty-desc">点击下方按钮创建第一个聚会吧</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = orders.map(order => OrderCard.render(order)).join('');
|
||||||
|
|
||||||
|
// 绑定点击事件
|
||||||
|
container.querySelectorAll('.order-card').forEach(card => {
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
showOrderDetail(card.dataset.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 筛选订单
|
||||||
|
filterOrders(status) {
|
||||||
|
AppState.setCurrentOrderFilter(status);
|
||||||
|
this.renderOrders();
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建订单
|
||||||
|
async createOrder(name, dishIds, partyDate) {
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
name,
|
||||||
|
dish_ids: dishIds,
|
||||||
|
party_date: partyDate
|
||||||
|
};
|
||||||
|
const res = await fetch(`${API_BASE}/orders/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
const order = await res.json();
|
||||||
|
await this.loadOrders();
|
||||||
|
return order;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('创建订单失败:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 完成订单
|
||||||
|
async completeOrder(orderId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/orders/${orderId}/complete/`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
await res.json();
|
||||||
|
await this.loadOrders();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('完成订单失败:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除订单
|
||||||
|
async deleteOrder(orderId) {
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/orders/${orderId}/`, { method: 'DELETE' });
|
||||||
|
await this.loadOrders();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('删除订单失败:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出
|
||||||
|
window.OrdersPage = OrdersPage;
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// 统计页面模块
|
||||||
|
|
||||||
|
const StatsPage = {
|
||||||
|
// 初始化
|
||||||
|
init() {
|
||||||
|
this.loadStats();
|
||||||
|
},
|
||||||
|
|
||||||
|
// 加载统计数据
|
||||||
|
async loadStats() {
|
||||||
|
try {
|
||||||
|
const [dishesRes, ordersRes] = await Promise.all([
|
||||||
|
fetch(`${API_BASE}/dishes/`),
|
||||||
|
fetch(`${API_BASE}/orders/`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allDishes = await dishesRes.json();
|
||||||
|
const allOrders = await ordersRes.json();
|
||||||
|
|
||||||
|
// 计算菜品出现次数
|
||||||
|
const dishCompletedCounts = {};
|
||||||
|
allOrders
|
||||||
|
.filter(o => o.status === 'completed')
|
||||||
|
.forEach(order => {
|
||||||
|
(order.dishes_detail || []).forEach(dish => {
|
||||||
|
dishCompletedCounts[dish.id] = (dishCompletedCounts[dish.id] || 0) + 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
AppState.setDishCompletedCounts(dishCompletedCounts);
|
||||||
|
|
||||||
|
// 统计数据
|
||||||
|
const totalDishes = allDishes.length;
|
||||||
|
const completedOrders = allOrders.filter(o => o.status === 'completed').length;
|
||||||
|
const inProgressOrders = allOrders.filter(o => o.status === 'in_progress').length;
|
||||||
|
|
||||||
|
// 统计参与人员
|
||||||
|
const participantCounts = {};
|
||||||
|
allOrders.forEach(order => {
|
||||||
|
(order.participants || []).forEach(p => {
|
||||||
|
participantCounts[p] = (participantCounts[p] || 0) + 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const uniqueParticipants = Object.keys(participantCounts).length;
|
||||||
|
|
||||||
|
// 渲染统计卡片
|
||||||
|
this.renderStatCards({ totalDishes, completedOrders, inProgressOrders, uniqueParticipants });
|
||||||
|
|
||||||
|
// 渲染菜品排行榜
|
||||||
|
this.renderDishRank(allDishes, dishCompletedCounts);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载统计失败:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 渲染统计卡片
|
||||||
|
renderStatCards(stats) {
|
||||||
|
const container = document.getElementById('stats-cards');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon" style="background: linear-gradient(135deg, var(--primary-soft), #fef3c7); color: var(--primary);">
|
||||||
|
<i class="bi bi-book"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-value">${stats.totalDishes}</div>
|
||||||
|
<div class="stat-label">菜品总数</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon" style="background: var(--secondary-soft); color: var(--secondary);">
|
||||||
|
<i class="bi bi-check-circle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-value">${stats.completedOrders}</div>
|
||||||
|
<div class="stat-label">已完成聚会</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon" style="background: var(--accent-soft); color: #b45309;">
|
||||||
|
<i class="bi bi-clock"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-value">${stats.inProgressOrders}</div>
|
||||||
|
<div class="stat-label">进行中</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 渲染菜品排行榜
|
||||||
|
renderDishRank(dishes, counts) {
|
||||||
|
const container = document.getElementById('dish-rank-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// 按出现次数排序
|
||||||
|
const dishesWithCounts = dishes.map(dish => ({
|
||||||
|
...dish,
|
||||||
|
orderCount: counts[dish.id] || 0
|
||||||
|
})).filter(d => d.orderCount > 0)
|
||||||
|
.sort((a, b) => b.orderCount - a.orderCount);
|
||||||
|
|
||||||
|
if (dishesWithCounts.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-rank">
|
||||||
|
<i class="bi bi-trophy"></i>
|
||||||
|
<p>暂无数据</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = dishesWithCounts.slice(0, 10).map((dish, index) => {
|
||||||
|
let rankClass = 'default';
|
||||||
|
if (index === 0) rankClass = 'gold';
|
||||||
|
else if (index === 1) rankClass = 'silver';
|
||||||
|
else if (index === 2) rankClass = 'bronze';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="rank-item">
|
||||||
|
<span class="rank-number ${rankClass}">${index + 1}</span>
|
||||||
|
<img class="rank-img" src="${dish.cover_image || getDefaultImage()}" alt="${dish.name}">
|
||||||
|
<div class="rank-info">
|
||||||
|
<div class="rank-name">${dish.name}</div>
|
||||||
|
<div class="rank-count">出现 ${dish.orderCount} 次</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出
|
||||||
|
window.StatsPage = StatsPage;
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// 状态管理
|
||||||
|
const state = {
|
||||||
|
currentPage: 'menu',
|
||||||
|
categories: [],
|
||||||
|
dishes: [],
|
||||||
|
orders: [],
|
||||||
|
currentCategory: '',
|
||||||
|
currentOrderFilter: '',
|
||||||
|
dishCompletedCounts: {},
|
||||||
|
|
||||||
|
// 设置状态
|
||||||
|
setPage(page) {
|
||||||
|
this.currentPage = page;
|
||||||
|
},
|
||||||
|
|
||||||
|
setCategories(categories) {
|
||||||
|
this.categories = categories;
|
||||||
|
},
|
||||||
|
|
||||||
|
setDishes(dishes) {
|
||||||
|
this.dishes = dishes;
|
||||||
|
},
|
||||||
|
|
||||||
|
setOrders(orders) {
|
||||||
|
this.orders = orders;
|
||||||
|
},
|
||||||
|
|
||||||
|
setCurrentCategory(category) {
|
||||||
|
this.currentCategory = category;
|
||||||
|
},
|
||||||
|
|
||||||
|
setCurrentOrderFilter(filter) {
|
||||||
|
this.currentOrderFilter = filter;
|
||||||
|
},
|
||||||
|
|
||||||
|
setDishCompletedCounts(counts) {
|
||||||
|
this.dishCompletedCounts = counts;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取过滤后的数据
|
||||||
|
getFilteredDishes() {
|
||||||
|
let filtered = this.dishes;
|
||||||
|
if (this.currentCategory) {
|
||||||
|
filtered = filtered.filter(d => d.category_name === this.currentCategory);
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
},
|
||||||
|
|
||||||
|
getFilteredOrders() {
|
||||||
|
let filtered = this.orders;
|
||||||
|
if (this.currentOrderFilter) {
|
||||||
|
filtered = filtered.filter(o => o.status === this.currentOrderFilter);
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取菜品在当前订单中的状态
|
||||||
|
isDishInCurrentOrder(dishId) {
|
||||||
|
const currentOrder = this.orders.find(o => o.status === 'in_progress');
|
||||||
|
if (!currentOrder) return false;
|
||||||
|
return currentOrder.dishes && currentOrder.dishes.includes(dishId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取当前进行中的订单
|
||||||
|
getCurrentOrder() {
|
||||||
|
return this.orders.find(o => o.status === 'in_progress');
|
||||||
|
},
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
reset() {
|
||||||
|
this.currentPage = 'menu';
|
||||||
|
this.currentCategory = '';
|
||||||
|
this.currentOrderFilter = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出供全局使用
|
||||||
|
window.AppState = state;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// 工具函数
|
||||||
|
|
||||||
|
// 格式化日期时间
|
||||||
|
function formatDateTime(date) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防抖函数
|
||||||
|
function debounce(fn, delay) {
|
||||||
|
let timer;
|
||||||
|
return function(...args) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => fn.apply(this, args), delay);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成默认图片
|
||||||
|
function getDefaultImage() {
|
||||||
|
return 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2248%22 height=%2248%22><rect fill=%22%23fed7aa%22 width=%2248%22 height=%2248%22/><text x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.3em%22 fill=%22%23c2410c%22>🍳</text></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认对话框
|
||||||
|
function confirmAction(message) {
|
||||||
|
return confirm(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示提示
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
// 简单实现,实际可以使用更优雅的toast库
|
||||||
|
alert(message);
|
||||||
|
}
|
||||||
Vendored
+6
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
ASGI config for zhangmenu project.
|
||||||
|
|
||||||
|
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'zhangmenu.settings')
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""
|
||||||
|
Django settings for zhangmenu project.
|
||||||
|
|
||||||
|
Generated by 'django-admin startproject' using Django 5.2.11.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/topics/settings/
|
||||||
|
|
||||||
|
For the full list of settings and their values, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'django-insecure-gi^dn9+3um+rk=1vx)405q^1o$zv*27fh5u3q-7+@+a&a^g&48')
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
DEBUG = os.getenv('DJANGO_DEBUG', 'true').lower() in {'1', 'true', 'yes', 'on'}
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = [
|
||||||
|
host.strip()
|
||||||
|
for host in os.getenv('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1,testserver').split(',')
|
||||||
|
if host.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'rest_framework',
|
||||||
|
'menu',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'zhangmenu.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [BASE_DIR / 'templates'],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'zhangmenu.wsgi.application'
|
||||||
|
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
'NAME': BASE_DIR / 'db.sqlite3',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'en-us'
|
||||||
|
|
||||||
|
TIME_ZONE = 'Asia/Shanghai'
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||||
|
|
||||||
|
STATIC_URL = 'static/'
|
||||||
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
STATICFILES_DIRS = [BASE_DIR / 'static']
|
||||||
|
|
||||||
|
# Media files
|
||||||
|
MEDIA_URL = '/media/'
|
||||||
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
|
||||||
|
# Upload limits
|
||||||
|
MAX_UPLOAD_IMAGE_SIZE = 5 * 1024 * 1024
|
||||||
|
ALLOWED_UPLOAD_IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
|
||||||
|
ALLOWED_UPLOAD_IMAGE_CONTENT_TYPES = {
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
'image/gif',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parser allowlist
|
||||||
|
ALLOWED_RECIPE_HOSTS = {
|
||||||
|
'xiachufang.com',
|
||||||
|
'www.xiachufang.com',
|
||||||
|
'meishij.net',
|
||||||
|
'www.meishij.net',
|
||||||
|
'caipuw.com',
|
||||||
|
'www.caipuw.com',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default primary key field type
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""
|
||||||
|
URL configuration for zhangmenu project.
|
||||||
|
|
||||||
|
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||||
|
https://docs.djangoproject.com/en/5.2/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 path, include
|
||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls.static import static
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('', include('menu.urls')),
|
||||||
|
]
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
WSGI config for zhangmenu project.
|
||||||
|
|
||||||
|
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'zhangmenu.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
Reference in New Issue
Block a user