diff --git a/backend/dishes/admin.py b/backend/dishes/admin.py index 3dc8a4134552e1a0ef81178b2f54a62275a17683..74e03bf9df82664550b501acd547930a0c4c5eef 100644 --- a/backend/dishes/admin.py +++ b/backend/dishes/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import Dish, DishImage, Ingredient, ChefGourmetBinding, CookingStep +from .models import Dish, DishImage, Ingredient, ChefGourmetBinding, CookingStep, NutritionCategory class DishImageInline(admin.TabularInline): @@ -17,11 +17,18 @@ class CookingStepInline(admin.TabularInline): extra = 1 +@admin.register(NutritionCategory) +class NutritionCategoryAdmin(admin.ModelAdmin): + list_display = ['code', 'name', 'order'] + ordering = ['order', 'id'] + + @admin.register(Dish) class DishAdmin(admin.ModelAdmin): - list_display = ['name', 'chef', 'category', 'status', 'created_at'] - list_filter = ['category', 'status', 'created_at'] + list_display = ['name', 'chef', 'dish_type', 'status', 'created_at'] + list_filter = ['dish_type', 'status', 'created_at', 'nutrition_categories'] search_fields = ['name', 'chef__nickname'] + filter_horizontal = ['nutrition_categories'] inlines = [DishImageInline, IngredientInline, CookingStepInline] diff --git a/backend/dishes/apps.py b/backend/dishes/apps.py index 37fff6f134524c1f6f43ea5a7a1056c504336ed2..9be8ec0fe39063611f42af3f1c38cf74456958b7 100644 --- a/backend/dishes/apps.py +++ b/backend/dishes/apps.py @@ -5,4 +5,36 @@ class DishesConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'dishes' verbose_name = '菜品管理' + + def ready(self): + """应用启动时自动创建初始数据""" + # 只在非迁移时运行 + import sys + if 'migrate' not in sys.argv and 'makemigrations' not in sys.argv: + self.create_nutrition_categories() + + def create_nutrition_categories(self): + """创建营养成分分类初始数据""" + try: + from .models import NutritionCategory + + categories = [ + {'code': 'protein', 'name': '蛋白质', 'order': 1}, + {'code': 'carb', 'name': '碳水', 'order': 2}, + {'code': 'fat', 'name': '脂肪', 'order': 3}, + {'code': 'vegetable', 'name': '蔬菜', 'order': 4, 'description': '包含维生素、纤维'}, + ] + + for cat_data in categories: + NutritionCategory.objects.get_or_create( + code=cat_data['code'], + defaults={ + 'name': cat_data['name'], + 'order': cat_data['order'], + 'description': cat_data.get('description', '') + } + ) + except Exception as e: + # 如果表还没创建,忽略错误 + pass diff --git a/backend/dishes/management/__init__.py b/backend/dishes/management/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/dishes/management/commands/__init__.py b/backend/dishes/management/commands/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/dishes/migrations/0001_initial.py b/backend/dishes/migrations/0001_initial.py deleted file mode 100644 index 837d6f35e72842ac910077ac4b1113637bc04f33..0000000000000000000000000000000000000000 --- a/backend/dishes/migrations/0001_initial.py +++ /dev/null @@ -1,208 +0,0 @@ -# Generated by Django 4.2.7 on 2025-10-15 09:46 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name="Dish", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=100, verbose_name="菜品名称")), - ( - "category", - models.CharField( - choices=[ - ("vegetable", "蔬菜"), - ("protein", "蛋白质"), - ("carb", "碳水"), - ("fat", "脂肪"), - ], - max_length=20, - verbose_name="分类", - ), - ), - ( - "status", - models.CharField( - choices=[("draft", "草稿"), ("published", "已发布")], - default="published", - max_length=20, - verbose_name="状态", - ), - ), - ("cooking_steps", models.TextField(blank=True, verbose_name="制作流程")), - ( - "created_at", - models.DateTimeField(auto_now_add=True, verbose_name="创建时间"), - ), - ( - "updated_at", - models.DateTimeField(auto_now=True, verbose_name="更新时间"), - ), - ( - "chef", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="dishes", - to=settings.AUTH_USER_MODEL, - verbose_name="厨神", - ), - ), - ], - options={ - "verbose_name": "菜品", - "verbose_name_plural": "菜品", - "db_table": "dishes", - "ordering": ["-created_at"], - }, - ), - migrations.CreateModel( - name="Ingredient", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=100, verbose_name="食材名称")), - ( - "quantity", - models.DecimalField( - decimal_places=2, max_digits=10, verbose_name="数量" - ), - ), - ("unit", models.CharField(max_length=20, verbose_name="单位")), - ( - "dish", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="ingredients", - to="dishes.dish", - verbose_name="菜品", - ), - ), - ], - options={ - "verbose_name": "食材", - "verbose_name_plural": "食材", - "db_table": "ingredients", - }, - ), - migrations.CreateModel( - name="DishImage", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("image_url", models.URLField(verbose_name="图片URL")), - ("order", models.IntegerField(default=0, verbose_name="排序")), - ( - "created_at", - models.DateTimeField(auto_now_add=True, verbose_name="创建时间"), - ), - ( - "dish", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="images", - to="dishes.dish", - verbose_name="菜品", - ), - ), - ], - options={ - "verbose_name": "菜品图片", - "verbose_name_plural": "菜品图片", - "db_table": "dish_images", - "ordering": ["order", "id"], - }, - ), - migrations.CreateModel( - name="ChefGourmetBinding", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "status", - models.CharField( - choices=[ - ("pending", "待审核"), - ("approved", "已同意"), - ("rejected", "已拒绝"), - ], - default="pending", - max_length=20, - verbose_name="状态", - ), - ), - ( - "created_at", - models.DateTimeField(auto_now_add=True, verbose_name="创建时间"), - ), - ( - "updated_at", - models.DateTimeField(auto_now=True, verbose_name="更新时间"), - ), - ( - "chef", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="gourmet_bindings", - to=settings.AUTH_USER_MODEL, - verbose_name="厨神", - ), - ), - ( - "gourmet", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="chef_bindings", - to=settings.AUTH_USER_MODEL, - verbose_name="食神", - ), - ), - ], - options={ - "verbose_name": "厨神-食神绑定关系", - "verbose_name_plural": "厨神-食神绑定关系", - "db_table": "chef_gourmet_bindings", - "ordering": ["-created_at"], - "unique_together": {("chef", "gourmet")}, - }, - ), - ] diff --git a/backend/dishes/migrations/0001_initial_thumbnail.py b/backend/dishes/migrations/0001_initial_thumbnail.py new file mode 100644 index 0000000000000000000000000000000000000000..58206fea2b6e240721bb32b678aa4423752e39c1 --- /dev/null +++ b/backend/dishes/migrations/0001_initial_thumbnail.py @@ -0,0 +1,20 @@ +# Generated migration for adding thumbnail field to DishImage + +from django.db import migrations, models +import dishes.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dishes', '0001_initial'), # 请根据实际的最新迁移文件修改 + ] + + operations = [ + migrations.AddField( + model_name='dishimage', + name='thumbnail', + field=models.ImageField(blank=True, null=True, upload_to=dishes.models.dish_thumbnail_upload_path, verbose_name='缩略图'), + ), + ] + diff --git a/backend/dishes/migrations/0002_remove_dishimage_image_url_dishimage_image.py b/backend/dishes/migrations/0002_remove_dishimage_image_url_dishimage_image.py deleted file mode 100644 index 9331d4bfdc812600b607b6922860a4d5030d8064..0000000000000000000000000000000000000000 --- a/backend/dishes/migrations/0002_remove_dishimage_image_url_dishimage_image.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 4.2.7 on 2025-10-23 09:06 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("dishes", "0001_initial"), - ] - - operations = [ - migrations.RemoveField( - model_name="dishimage", - name="image_url", - ), - migrations.AddField( - model_name="dishimage", - name="image", - field=models.ImageField( - blank=True, - null=True, - upload_to="dishes/images/%Y/%m/%d/", - verbose_name="图片", - ), - ), - ] diff --git a/backend/dishes/migrations/0003_alter_chefgourmetbinding_options_and_more.py b/backend/dishes/migrations/0003_alter_chefgourmetbinding_options_and_more.py deleted file mode 100644 index 1854f92c6e81cab57aee347440e50cdde4496b2e..0000000000000000000000000000000000000000 --- a/backend/dishes/migrations/0003_alter_chefgourmetbinding_options_and_more.py +++ /dev/null @@ -1,136 +0,0 @@ -# Generated by Django 4.2.7 on 2025-10-24 02:22 - -import dishes.models -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone - - -class Migration(migrations.Migration): - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("dishes", "0002_remove_dishimage_image_url_dishimage_image"), - ] - - operations = [ - migrations.AlterModelOptions( - name="chefgourmetbinding", - options={ - "ordering": ["-applied_at"], - "verbose_name": "厨神食神绑定关系", - "verbose_name_plural": "厨神食神绑定关系", - }, - ), - migrations.AddField( - model_name="chefgourmetbinding", - name="applied_at", - field=models.DateTimeField( - default=django.utils.timezone.now, - help_text="绑定申请发起的时间", - verbose_name="申请时间", - ), - ), - migrations.AddField( - model_name="chefgourmetbinding", - name="applied_by", - field=models.ForeignKey( - blank=True, - help_text="发起绑定申请的用户", - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="applied_bindings", - to=settings.AUTH_USER_MODEL, - verbose_name="申请人", - ), - ), - migrations.AddField( - model_name="chefgourmetbinding", - name="apply_message", - field=models.TextField( - blank=True, help_text="申请时的留言信息", verbose_name="申请留言" - ), - ), - migrations.AddField( - model_name="chefgourmetbinding", - name="processed_at", - field=models.DateTimeField( - blank=True, help_text="绑定申请被处理的时间", null=True, verbose_name="处理时间" - ), - ), - migrations.AddField( - model_name="chefgourmetbinding", - name="processed_by", - field=models.ForeignKey( - blank=True, - help_text="处理绑定申请的用户", - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="processed_bindings", - to=settings.AUTH_USER_MODEL, - verbose_name="处理人", - ), - ), - migrations.AddField( - model_name="chefgourmetbinding", - name="reject_reason", - field=models.TextField( - blank=True, help_text="拒绝绑定的原因", verbose_name="拒绝原因" - ), - ), - migrations.AlterField( - model_name="chefgourmetbinding", - name="chef", - field=models.ForeignKey( - help_text="绑定关系中的厨神", - on_delete=django.db.models.deletion.CASCADE, - related_name="chef_bindings", - to=settings.AUTH_USER_MODEL, - verbose_name="厨神", - ), - ), - migrations.AlterField( - model_name="chefgourmetbinding", - name="created_at", - field=models.DateTimeField( - default=django.utils.timezone.now, verbose_name="创建时间" - ), - ), - migrations.AlterField( - model_name="chefgourmetbinding", - name="gourmet", - field=models.ForeignKey( - help_text="绑定关系中的食神", - on_delete=django.db.models.deletion.CASCADE, - related_name="gourmet_bindings", - to=settings.AUTH_USER_MODEL, - verbose_name="食神", - ), - ), - migrations.AlterField( - model_name="chefgourmetbinding", - name="status", - field=models.CharField( - choices=[ - ("pending", "待处理"), - ("accepted", "已同意"), - ("rejected", "已拒绝"), - ("cancelled", "已取消"), - ], - default="pending", - help_text="绑定关系的当前状态", - max_length=20, - verbose_name="绑定状态", - ), - ), - migrations.AlterField( - model_name="dishimage", - name="image", - field=models.ImageField( - blank=True, - null=True, - upload_to=dishes.models.dish_image_upload_path, - verbose_name="图片", - ), - ), - ] diff --git a/backend/dishes/migrations/0004_remove_dish_cooking_steps_dish_description_and_more.py b/backend/dishes/migrations/0004_remove_dish_cooking_steps_dish_description_and_more.py deleted file mode 100644 index 1ee44a9cdb89d7c473e138dda1802dbc53745ee7..0000000000000000000000000000000000000000 --- a/backend/dishes/migrations/0004_remove_dish_cooking_steps_dish_description_and_more.py +++ /dev/null @@ -1,43 +0,0 @@ -# Generated by Django 4.2.7 on 2025-10-25 11:37 - -import dishes.models -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('dishes', '0003_alter_chefgourmetbinding_options_and_more'), - ] - - operations = [ - migrations.RemoveField( - model_name='dish', - name='cooking_steps', - ), - migrations.AddField( - model_name='dish', - name='description', - field=models.TextField(blank=True, help_text='菜品的简要介绍', verbose_name='菜品描述'), - ), - migrations.CreateModel( - name='CookingStep', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('step_number', models.IntegerField(help_text='制作步骤的顺序', verbose_name='步骤序号')), - ('description', models.TextField(help_text='详细的制作说明', verbose_name='步骤描述')), - ('image', models.ImageField(blank=True, help_text='该步骤的配图(可选)', null=True, upload_to=dishes.models.cooking_step_image_upload_path, verbose_name='步骤图片')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('dish', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cooking_steps', to='dishes.dish', verbose_name='菜品')), - ], - options={ - 'verbose_name': '制作步骤', - 'verbose_name_plural': '制作步骤', - 'db_table': 'cooking_steps', - 'ordering': ['step_number'], - 'unique_together': {('dish', 'step_number')}, - }, - ), - ] diff --git a/backend/dishes/models.py b/backend/dishes/models.py index 7245d0955d3418a5c83eac3a8c60d7e6529e5cbe..376bcd33cc63e81f0d7ead38c432fe21109335a0 100644 --- a/backend/dishes/models.py +++ b/backend/dishes/models.py @@ -3,13 +3,49 @@ from django.conf import settings from django.utils import timezone +class NutritionCategory(models.Model): + """营养成分分类模型""" + code = models.CharField('代码', max_length=20, unique=True) + name = models.CharField('名称', max_length=50) + order = models.IntegerField('排序', default=0) + description = models.TextField('说明', blank=True, help_text='营养成分的说明,如:蔬菜包含维生素、纤维') + + class Meta: + db_table = 'nutrition_categories' + verbose_name = '营养成分分类' + verbose_name_plural = '营养成分分类' + ordering = ['order', 'id'] + + def __str__(self): + return self.name + + class Dish(models.Model): """菜品模型""" - CATEGORY_CHOICES = ( - ('vegetable', '蔬菜'), - ('protein', '蛋白质'), - ('carb', '碳水'), - ('fat', '脂肪'), + # 菜品类型(必选) + DISH_TYPE_CHOICES = ( + ('stir_fry', '炒菜'), + ('steam', '蒸菜'), + ('braise', '烧菜'), + ('cold', '凉菜'), + ('bbq', '烧烤'), + ('boiled', '水煮菜'), + ('hotpot', '火锅配餐'), + ('bbq_side', '烧烤配菜'), + ('dessert', '甜点'), + ('staple', '主食'), + ('noodle', '面'), + ('soup', '汤类'), + ('stew', '炖菜'), + ('braised_food', '卤菜'), + ('fried', '煎炸'), + ) + + # 餐别适用性(多选) + MEAL_TYPE_CHOICES = ( + ('breakfast', '早餐'), + ('lunch', '午餐'), + ('dinner', '晚餐'), ) STATUS_CHOICES = ( @@ -24,9 +60,27 @@ class Dish(models.Model): verbose_name='厨神' ) name = models.CharField('菜品名称', max_length=100) - category = models.CharField('分类', max_length=20, choices=CATEGORY_CHOICES) + dish_type = models.CharField('菜品类型', max_length=20, choices=DISH_TYPE_CHOICES) status = models.CharField('状态', max_length=20, choices=STATUS_CHOICES, default='published') description = models.TextField('菜品描述', blank=True, help_text='菜品的简要介绍') + + # 营养成分分类(多选,可选) + nutrition_categories = models.ManyToManyField( + NutritionCategory, + related_name='dishes', + verbose_name='营养成分分类', + blank=True, + help_text='一个菜品可以包含多种营养成分(多选)' + ) + + # 餐别适用性(多选,可选) + suitable_meal_types = models.JSONField( + '适合的餐别', + default=list, + blank=True, + help_text='该菜品适合的餐别列表,如:["breakfast", "lunch"]' + ) + created_at = models.DateTimeField('创建时间', auto_now_add=True) updated_at = models.DateTimeField('更新时间', auto_now=True) @@ -37,7 +91,7 @@ class Dish(models.Model): ordering = ['-created_at'] def __str__(self): - return f"{self.name} ({self.get_category_display()})" + return f"{self.name} ({self.get_dish_type_display()})" def dish_image_upload_path(instance, filename): @@ -46,6 +100,12 @@ def dish_image_upload_path(instance, filename): return f'dishes/images/chef_{instance.dish.chef.id}/dish_{instance.dish.id}/{filename}' +def dish_thumbnail_upload_path(instance, filename): + """生成菜品缩略图的存储路径""" + # 格式:dishes/thumbnails/chef_id/dish_id/filename + return f'dishes/thumbnails/chef_{instance.dish.chef.id}/dish_{instance.dish.id}/{filename}' + + class DishImage(models.Model): """菜品图片模型""" dish = models.ForeignKey( @@ -55,6 +115,7 @@ class DishImage(models.Model): verbose_name='菜品' ) image = models.ImageField('图片', upload_to=dish_image_upload_path, null=True, blank=True) + thumbnail = models.ImageField('缩略图', upload_to=dish_thumbnail_upload_path, null=True, blank=True) order = models.IntegerField('排序', default=0) created_at = models.DateTimeField('创建时间', auto_now_add=True) diff --git a/backend/dishes/serializers.py b/backend/dishes/serializers.py index 89a6d8beb666e0099f1ca239db9189e533e8b77d..bf545fad2896a36efbc08404a6da4dab1ef4ec1d 100644 --- a/backend/dishes/serializers.py +++ b/backend/dishes/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import Dish, DishImage, Ingredient, ChefGourmetBinding, CookingStep +from .models import Dish, DishImage, Ingredient, ChefGourmetBinding, CookingStep, NutritionCategory from users.serializers import UserSerializer @@ -37,29 +37,38 @@ class IngredientSerializer(serializers.ModelSerializer): class DishImageSerializer(serializers.ModelSerializer): """菜品图片序列化器""" image_url = serializers.SerializerMethodField() + thumbnail_url = serializers.SerializerMethodField() class Meta: model = DishImage - fields = ['id', 'image', 'image_url', 'order'] + fields = ['id', 'image', 'image_url', 'thumbnail', 'thumbnail_url', 'order'] read_only_fields = ['id'] - def get_image_url(self, obj): - """获取图片的完整URL""" - if obj.image: + def _build_image_url(self, image_field, obj): + """构建图片URL的辅助方法""" + if image_field: request = self.context.get('request') if request: - return request.build_absolute_uri(obj.image.url) + return request.build_absolute_uri(image_field.url) else: # 如果没有request上下文,手动构建完整URL from django.conf import settings base_url = getattr(settings, 'BASE_URL', 'http://localhost:8000') if not base_url.endswith('/'): base_url += '/' - if obj.image.url.startswith('/'): - return f"{base_url.rstrip('/')}{obj.image.url}" + if image_field.url.startswith('/'): + return f"{base_url.rstrip('/')}{image_field.url}" else: - return f"{base_url}{obj.image.url}" + return f"{base_url}{image_field.url}" return None + + def get_image_url(self, obj): + """获取原图的完整URL""" + return self._build_image_url(obj.image, obj) + + def get_thumbnail_url(self, obj): + """获取缩略图的完整URL""" + return self._build_image_url(obj.thumbnail, obj) class CookingStepSerializer(serializers.ModelSerializer): @@ -108,18 +117,51 @@ class DishSerializer(serializers.ModelSerializer): ingredients = IngredientSerializer(many=True, required=False) images = DishImageSerializer(many=True, required=False) cooking_steps = CookingStepSerializer(many=True, required=False) - category_display = serializers.CharField(source='get_category_display', read_only=True) + + # 新字段 + dish_type = serializers.CharField(required=True) + dish_type_display = serializers.CharField(source='get_dish_type_display', read_only=True) + nutrition_categories = serializers.SerializerMethodField() + suitable_meal_types = serializers.JSONField(required=False, allow_null=True) status_display = serializers.CharField(source='get_status_display', read_only=True) + # 兼容字段(供食神端使用) + category = serializers.SerializerMethodField() + category_display = serializers.SerializerMethodField() + class Meta: model = Dish fields = [ - 'id', 'chef', 'name', 'category', 'category_display', + 'id', 'chef', 'name', + # 新字段 + 'dish_type', 'dish_type_display', + 'nutrition_categories', 'suitable_meal_types', + # 兼容字段(供食神端使用) + 'category', 'category_display', + # 其他字段 'status', 'status_display', 'description', 'ingredients', 'images', 'cooking_steps', 'created_at', 'updated_at' ] read_only_fields = ['id', 'chef', 'created_at', 'updated_at'] + def get_nutrition_categories(self, obj): + """获取营养成分分类""" + if obj.nutrition_categories.exists(): + return [{'code': nc.code, 'name': nc.name} for nc in obj.nutrition_categories.all()] + return [] + + def get_category(self, obj): + """兼容字段:返回第一个营养成分的code(供食神端筛选使用)""" + if obj.nutrition_categories.exists(): + return obj.nutrition_categories.first().code + return None + + def get_category_display(self, obj): + """兼容字段:返回第一个营养成分的名称""" + if obj.nutrition_categories.exists(): + return obj.nutrition_categories.first().name + return '' + def validate_name(self, value): """验证菜品名称""" if not value or not value.strip(): @@ -154,16 +196,31 @@ class DishSerializer(serializers.ModelSerializer): ingredients_data = validated_data.pop('ingredients', []) images_data = validated_data.pop('images', []) cooking_steps_data = validated_data.pop('cooking_steps', []) + nutrition_categories = validated_data.pop('nutrition_categories', []) dish = Dish.objects.create(**validated_data) + # 设置营养成分分类 + if nutrition_categories: + # 如果传入的是code列表,需要转换为NutritionCategory对象 + if isinstance(nutrition_categories, list) and len(nutrition_categories) > 0: + if isinstance(nutrition_categories[0], str): + # 传入的是code列表,转换为对象 + category_objects = NutritionCategory.objects.filter(code__in=nutrition_categories) + dish.nutrition_categories.set(category_objects) + elif isinstance(nutrition_categories[0], dict): + # 传入的是字典列表,提取code + codes = [nc.get('code') if isinstance(nc, dict) else nc for nc in nutrition_categories] + category_objects = NutritionCategory.objects.filter(code__in=codes) + dish.nutrition_categories.set(category_objects) + # 创建食材 for ingredient_data in ingredients_data: Ingredient.objects.create(dish=dish, **ingredient_data) # 创建图片 for image_data in images_data: - # 如果传入的是文件对象,直接使用 + # 如果传入的是文件对象,直接使用(包含image和thumbnail) if 'image' in image_data: DishImage.objects.create(dish=dish, **image_data) # 如果传入的是URL(兼容旧数据),跳过 @@ -180,12 +237,29 @@ class DishSerializer(serializers.ModelSerializer): ingredients_data = validated_data.pop('ingredients', None) images_data = validated_data.pop('images', None) cooking_steps_data = validated_data.pop('cooking_steps', None) + nutrition_categories = validated_data.pop('nutrition_categories', None) # 更新菜品基本信息 for attr, value in validated_data.items(): setattr(instance, attr, value) instance.save() + # 更新营养成分分类 + if nutrition_categories is not None: + if isinstance(nutrition_categories, list) and len(nutrition_categories) > 0: + if isinstance(nutrition_categories[0], str): + # 传入的是code列表 + category_objects = NutritionCategory.objects.filter(code__in=nutrition_categories) + instance.nutrition_categories.set(category_objects) + elif isinstance(nutrition_categories[0], dict): + # 传入的是字典列表 + codes = [nc.get('code') if isinstance(nc, dict) else nc for nc in nutrition_categories] + category_objects = NutritionCategory.objects.filter(code__in=codes) + instance.nutrition_categories.set(category_objects) + else: + # 空列表,清空所有分类 + instance.nutrition_categories.clear() + # 更新食材 if ingredients_data is not None: instance.ingredients.all().delete() @@ -214,42 +288,61 @@ class DishSerializer(serializers.ModelSerializer): class DishListSerializer(serializers.ModelSerializer): """菜品列表序列化器(简化版)""" + chef_id = serializers.IntegerField(source='chef.id', read_only=True) chef_name = serializers.CharField(source='chef.nickname', read_only=True) - category_display = serializers.CharField(source='get_category_display', read_only=True) + dish_type_display = serializers.CharField(source='get_dish_type_display', read_only=True) + category = serializers.SerializerMethodField() + category_display = serializers.SerializerMethodField() status_display = serializers.CharField(source='get_status_display', read_only=True) main_image = serializers.SerializerMethodField() class Meta: model = Dish fields = [ - 'id', 'name', 'category', 'category_display', - 'status', 'status_display', 'chef_name', 'main_image', 'created_at' + 'id', 'name', 'dish_type', 'dish_type_display', + 'category', 'category_display', + 'status', 'status_display', 'chef_id', 'chef_name', 'main_image', 'created_at' ] + def get_category(self, obj): + """兼容字段:返回第一个营养成分的code""" + if obj.nutrition_categories.exists(): + return obj.nutrition_categories.first().code + return None + + def get_category_display(self, obj): + """兼容字段:返回第一个营养成分的名称""" + if obj.nutrition_categories.exists(): + return obj.nutrition_categories.first().name + return '' + def get_main_image(self, obj): - """获取图片的完整URL""" + """获取图片的完整URL(优先返回缩略图,用于列表页)""" first_image = obj.images.first() - if first_image and first_image.image: - request = self.context.get('request') - if request: - # 确保URL是完整的绝对路径 - image_url = request.build_absolute_uri(first_image.image.url) - print(f"构建的图片URL: {image_url}") - return image_url + if not first_image: + return None + + # 优先使用缩略图(列表页使用) + image_field = first_image.thumbnail if first_image.thumbnail else first_image.image + if not image_field: + return None + + request = self.context.get('request') + if request: + # 确保URL是完整的绝对路径 + image_url = request.build_absolute_uri(image_field.url) + return image_url + else: + # 如果没有request上下文,手动构建完整URL + from django.conf import settings + base_url = getattr(settings, 'BASE_URL', 'http://localhost:8000') + if not base_url.endswith('/'): + base_url += '/' + if image_field.url.startswith('/'): + image_url = f"{base_url.rstrip('/')}{image_field.url}" else: - # 如果没有request上下文,手动构建完整URL - from django.conf import settings - base_url = getattr(settings, 'BASE_URL', 'http://localhost:8000') - if not base_url.endswith('/'): - base_url += '/' - if first_image.image.url.startswith('/'): - image_url = f"{base_url.rstrip('/')}{first_image.image.url}" - else: - image_url = f"{base_url}{first_image.image.url}" - print(f"手动构建的图片URL: {image_url}") - return image_url - print(f"菜品 {obj.name} 没有图片") - return None + image_url = f"{base_url}{image_field.url}" + return image_url class ChefGourmetBindingSerializer(serializers.ModelSerializer): diff --git a/backend/dishes/urls.py b/backend/dishes/urls.py index f7f56b66f770dc96e7c845a2afb46e5d924247f4..61f6b1108165be919dbc38ab5f1a6538c005688c 100644 --- a/backend/dishes/urls.py +++ b/backend/dishes/urls.py @@ -10,6 +10,10 @@ urlpatterns = [ path('', include(router.urls)), path('gourmets/', views.get_bound_gourmets, name='get_bound_gourmets'), path('unit-choices/', views.get_unit_choices, name='get_unit_choices'), + path('category-choices/', views.get_category_choices, name='get_category_choices'), + path('dish-type-choices/', views.get_dish_type_choices, name='get_dish_type_choices'), + path('nutrition-category-choices/', views.get_nutrition_category_choices, name='get_nutrition_category_choices'), path('dishes//steps//', views.upload_step_image, name='upload_step_image'), + path('dishes//pdf/', views.generate_dish_pdf, name='generate_dish_pdf'), ] diff --git a/backend/dishes/views.py b/backend/dishes/views.py index 236df9b0bc723851519284bbdf9be470b1e83842..8b2c865d4029d3f5d121e25ad1de5949a72f0e2b 100644 --- a/backend/dishes/views.py +++ b/backend/dishes/views.py @@ -6,7 +6,8 @@ from rest_framework.parsers import MultiPartParser, FormParser, JSONParser from django.db.models import Q from django.conf import settings from meal_architect.utils.log_manager import logger -from .models import Dish, ChefGourmetBinding, DishImage, CookingStep +from meal_architect.utils.image_processor import process_dish_image, process_step_image +from .models import Dish, ChefGourmetBinding, DishImage, CookingStep, NutritionCategory from .serializers import ( DishSerializer, DishListSerializer, ChefGourmetBindingSerializer, DishImageSerializer, CookingStepSerializer @@ -32,7 +33,7 @@ class DishViewSet(viewsets.ModelViewSet): # 食神可以看到所有已绑定厨神的已发布菜品 approved_chefs = ChefGourmetBinding.objects.filter( gourmet=user, - status='approved' + status='accepted' ).values_list('chef_id', flat=True) return Dish.objects.filter( @@ -57,8 +58,11 @@ class DishViewSet(viewsets.ModelViewSet): if 'images' in request.FILES: images_data = [] for i, image_file in enumerate(request.FILES.getlist('images')): + # 处理图片:压缩并生成缩略图 + compressed_image, thumbnail = process_dish_image(image_file) images_data.append({ - 'image': image_file, + 'image': compressed_image, + 'thumbnail': thumbnail, 'order': i }) request.data['images'] = images_data @@ -125,10 +129,14 @@ class DishViewSet(viewsets.ModelViewSet): order = int(request.data.get('order', 0)) try: + # 处理图片:压缩并生成缩略图 + compressed_image, thumbnail = process_dish_image(image_file) + # 创建图片记录 dish_image = DishImage.objects.create( dish=dish, - image=image_file, + image=compressed_image, + thumbnail=thumbnail, order=order ) @@ -143,6 +151,52 @@ class DishViewSet(viewsets.ModelViewSet): status=status.HTTP_500_INTERNAL_SERVER_ERROR ) + @action(detail=True, methods=['delete']) + def delete_image(self, request, pk=None): + """删除菜品图片""" + if request.user.role != 'chef': + return Response( + {'error': '只有厨神可以删除图片'}, + status=status.HTTP_403_FORBIDDEN + ) + + dish = self.get_object() + + # 检查权限 + if dish.chef != request.user: + return Response( + {'error': '无权操作此菜品'}, + status=status.HTTP_403_FORBIDDEN + ) + + # 获取要删除的图片ID + image_id = request.data.get('image_id') + if not image_id: + return Response( + {'error': '请提供图片ID'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + # 查找并删除图片 + dish_image = DishImage.objects.get(id=image_id, dish=dish) + dish_image.delete() + + logger.log_d(f"删除图片成功: image_id={image_id}, dish_id={pk}") + return Response(status=status.HTTP_204_NO_CONTENT) + + except DishImage.DoesNotExist: + return Response( + {'error': '图片不存在'}, + status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + logger.log_e(f"删除图片失败: {str(e)}") + return Response( + {'error': '删除图片失败'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + @action(detail=True, methods=['post']) def toggle_status(self, request, pk=None): """切换菜品状态(发布/草稿)""" @@ -184,7 +238,7 @@ class DishViewSet(viewsets.ModelViewSet): ).distinct() if category: - queryset = queryset.filter(category=category) + queryset = queryset.filter(nutrition_categories__code=category).distinct() # 分页 page = self.paginate_queryset(queryset) @@ -213,11 +267,12 @@ class DishViewSet(viewsets.ModelViewSet): 'category_stats': {} } - # 按分类统计 - for category_code, category_name in Dish.CATEGORY_CHOICES: - count = queryset.filter(category=category_code).count() - stats['category_stats'][category_code] = { - 'name': category_name, + # 按营养成分分类统计 + from .models import NutritionCategory + for nutrition_cat in NutritionCategory.objects.all().order_by('order', 'id'): + count = queryset.filter(nutrition_categories=nutrition_cat).distinct().count() + stats['category_stats'][nutrition_cat.code] = { + 'name': nutrition_cat.name, 'count': count } @@ -232,6 +287,37 @@ def get_unit_choices(request): unit_choices = [choice[0] for choice in Ingredient.UNIT_CHOICES] return Response({'unit_choices': unit_choices}) +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_category_choices(request): + """获取菜品分类选项(兼容接口,返回营养成分分类)""" + categories = NutritionCategory.objects.all().order_by('order', 'id') + result = [ + {'value': nc.code, 'label': nc.name} + for nc in categories + ] + return Response({'categories': result}) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_dish_type_choices(request): + """获取菜品类型选项""" + choices = [ + {'value': choice[0], 'label': choice[1]} + for choice in Dish.DISH_TYPE_CHOICES + ] + return Response({'dish_types': choices}) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_nutrition_category_choices(request): + """获取营养成分分类选项""" + categories = NutritionCategory.objects.all().order_by('order', 'id') + choices = [{'code': nc.code, 'name': nc.name} for nc in categories] + return Response({'nutrition_categories': choices}) + @api_view(['POST']) @permission_classes([IsAuthenticated]) @@ -259,8 +345,11 @@ def upload_step_image(request, dish_id, step_number): logger.log_e(f"步骤不存在: dish_id={dish_id}, step_number={step_number}") return Response({'error': f'步骤{step_number}不存在,请先创建菜品'}, status=status.HTTP_400_BAD_REQUEST) + # 处理图片:压缩 + compressed_image = process_step_image(image_file) + # 更新图片 - cooking_step.image = image_file + cooking_step.image = compressed_image cooking_step.save() logger.log_d(f"上传步骤图片成功: dish_id={dish_id}, step_number={step_number}") @@ -324,7 +413,7 @@ class ChefGourmetBindingViewSet(viewsets.ModelViewSet): ).first() if existing: - if existing.status == 'approved': + if existing.status == 'accepted': return Response( {'error': '已经存在绑定关系'}, status=status.HTTP_400_BAD_REQUEST @@ -369,7 +458,7 @@ class ChefGourmetBindingViewSet(viewsets.ModelViewSet): status=status.HTTP_403_FORBIDDEN ) - binding.status = 'approved' + binding.status = 'accepted' binding.save() return Response(ChefGourmetBindingSerializer(binding).data) @@ -421,7 +510,7 @@ def get_bound_gourmets(request): bindings = ChefGourmetBinding.objects.filter( chef=request.user, - status='approved' + status='accepted' ).select_related('gourmet') from users.serializers import UserSerializer @@ -430,3 +519,49 @@ def get_bound_gourmets(request): return Response(serializer.data) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def generate_dish_pdf(request, dish_id): + """生成菜品详情PDF""" + from django.http import HttpResponse + from meal_architect.utils.pdf_generator import generate_dish_detail_pdf + + try: + # 获取菜品信息 + dish = Dish.objects.prefetch_related('images', 'ingredients', 'cooking_steps').get(id=dish_id) + + # 权限检查:食神只能查看已绑定厨神的菜品 + if request.user.role == 'gourmet': + approved_chefs = ChefGourmetBinding.objects.filter( + gourmet=request.user, + status='accepted' + ).values_list('chef_id', flat=True) + + if dish.chef_id not in approved_chefs: + return Response({'error': '无权访问此菜品'}, status=status.HTTP_403_FORBIDDEN) + + # 序列化菜品数据 + serializer = DishSerializer(dish) + dish_data = serializer.data + + # 是否包含图片 + include_images = request.query_params.get('include_images', 'true').lower() == 'true' + + # 生成PDF + pdf_content = generate_dish_detail_pdf(dish_data, include_images=include_images) + + # 返回PDF文件 + response = HttpResponse(pdf_content, content_type='application/pdf') + filename = f"菜品详情_{dish.name}.pdf" + response['Content-Disposition'] = f'attachment; filename="{filename}"' + return response + + except Dish.DoesNotExist: + return Response({'error': '菜品不存在'}, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + logger.error(f"生成菜品PDF失败: {str(e)}") + return Response( + {'error': f'生成PDF失败: {str(e)}'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) diff --git a/backend/meal_architect/middleware.py b/backend/meal_architect/middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..e4c6ca490a63f9774b9a39d384b950a6696c5e7f --- /dev/null +++ b/backend/meal_architect/middleware.py @@ -0,0 +1,109 @@ +""" +自定义中间件 +""" +import os +import hashlib +from django.utils.deprecation import MiddlewareMixin +from django.http import HttpResponse, HttpResponseNotModified +from django.conf import settings +from django.utils.http import http_date + + +class MediaCacheMiddleware(MiddlewareMixin): + """ + 为媒体文件添加HTTP缓存头和支持304响应 + 减少服务器压力和带宽消耗 + """ + def process_request(self, request): + """处理请求,检查缓存验证头""" + # 只处理媒体文件和静态文件 + if not (request.path.startswith('/media/') or request.path.startswith('/static/')): + return None + + # 获取文件路径 + if request.path.startswith('/media/'): + file_path = os.path.join(settings.MEDIA_ROOT, request.path.replace('/media/', '', 1)) + else: + file_path = os.path.join(settings.STATIC_ROOT, request.path.replace('/static/', '', 1)) + + # 检查文件是否存在 + if not os.path.exists(file_path) or not os.path.isfile(file_path): + return None + + # 获取文件修改时间 + try: + stat = os.stat(file_path) + mtime = stat.st_mtime + file_size = stat.st_size + except OSError: + return None + + # 生成ETag(基于文件路径、修改时间和大小) + etag = self._generate_etag(file_path, mtime, file_size) + last_modified = http_date(mtime) + + # 检查If-None-Match(ETag验证) + if_none_match = request.META.get('HTTP_IF_NONE_MATCH', '') + if if_none_match and etag in if_none_match: + # 资源未修改,返回304 + response = HttpResponseNotModified() + response['ETag'] = etag + response['Last-Modified'] = last_modified + return response + + # 检查If-Modified-Since(Last-Modified验证) + if_modified_since = request.META.get('HTTP_IF_MODIFIED_SINCE', '') + if if_modified_since: + try: + from django.utils.http import parse_http_date + if_modified_since_time = parse_http_date(if_modified_since) + if mtime <= if_modified_since_time: + # 资源未修改,返回304 + response = HttpResponseNotModified() + response['ETag'] = etag + response['Last-Modified'] = last_modified + return response + except (ValueError, TypeError): + pass + + # 将ETag和Last-Modified存储到request中,供process_response使用 + request._media_etag = etag + request._media_last_modified = last_modified + + return None + + def process_response(self, request, response): + """处理响应,添加缓存头""" + # 只对媒体文件添加缓存头 + if request.path.startswith('/media/'): + # 设置缓存时间为1年(31536000秒) + response['Cache-Control'] = 'public, max-age=31536000, immutable' + response['Expires'] = 'Thu, 31 Dec 2025 23:59:59 GMT' + + # 添加ETag和Last-Modified(如果之前计算过) + if hasattr(request, '_media_etag'): + response['ETag'] = request._media_etag + if hasattr(request, '_media_last_modified'): + response['Last-Modified'] = request._media_last_modified + + # 对静态文件也添加缓存头 + elif request.path.startswith('/static/'): + response['Cache-Control'] = 'public, max-age=31536000, immutable' + response['Expires'] = 'Thu, 31 Dec 2025 23:59:59 GMT' + + # 添加ETag和Last-Modified(如果之前计算过) + if hasattr(request, '_media_etag'): + response['ETag'] = request._media_etag + if hasattr(request, '_media_last_modified'): + response['Last-Modified'] = request._media_last_modified + + return response + + def _generate_etag(self, file_path, mtime, file_size): + """生成ETag(基于文件路径、修改时间和大小)""" + # 使用文件路径、修改时间和大小生成ETag + # 这样如果文件未改变,ETag就不会变 + content = f"{file_path}:{mtime}:{file_size}" + etag = hashlib.md5(content.encode('utf-8')).hexdigest() + return f'"{etag}"' + diff --git a/backend/meal_architect/settings.py b/backend/meal_architect/settings.py index 4dbb2f9ae322ebe63025a0d0794acd59b4c10fd5..001168ce73fc25e3b807aa237b557c19147065c8 100644 --- a/backend/meal_architect/settings.py +++ b/backend/meal_architect/settings.py @@ -7,9 +7,14 @@ from pathlib import Path from datetime import timedelta from dotenv import load_dotenv -load_dotenv() - +# BASE_DIR 是 backend 目录 BASE_DIR = Path(__file__).resolve().parent.parent +# PROJECT_ROOT 是项目根目录(backend 的上一级) +PROJECT_ROOT = BASE_DIR.parent + +# 从项目根目录加载 .env 文件 +env_path = PROJECT_ROOT / '.env' +load_dotenv(dotenv_path=env_path) SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-default-key-change-in-production') @@ -45,6 +50,7 @@ MIDDLEWARE = [ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'meal_architect.middleware.MediaCacheMiddleware', # 媒体文件缓存中间件 ] ROOT_URLCONF = 'meal_architect.urls' @@ -76,10 +82,10 @@ if DEBUG and os.getenv('USE_SQLITE', 'True') == 'True': if sqlite_db_path: # 如果是相对路径,则相对于项目根目录 if not os.path.isabs(sqlite_db_path): - sqlite_db_path = os.path.join(BASE_DIR.parent, sqlite_db_path) + sqlite_db_path = os.path.join(PROJECT_ROOT, sqlite_db_path) db_name = sqlite_db_path else: - db_name = BASE_DIR / 'db.sqlite3' + db_name = PROJECT_ROOT / 'data' / 'meal_architect.db' DATABASES = { 'default': { @@ -122,11 +128,12 @@ USE_I18N = True USE_TZ = True # Static files (CSS, JavaScript, Images) +# 静态文件和媒体文件都放在项目根目录的 data 文件夹中,backend 顶部只放代码 STATIC_URL = 'static/' -STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') +STATIC_ROOT = os.path.join(PROJECT_ROOT, 'data', 'staticfiles') MEDIA_URL = 'media/' -MEDIA_ROOT = os.path.join(BASE_DIR, 'media') +MEDIA_ROOT = os.path.join(PROJECT_ROOT, 'data', 'media') DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' @@ -225,13 +232,13 @@ LOGGING = { 'file': { 'level': 'INFO', 'class': 'logging.FileHandler', - 'filename': os.path.join(BASE_DIR, '..', 'logs', 'meal_architect.log'), + 'filename': os.path.join(PROJECT_ROOT, 'logs', 'meal_architect.log'), 'formatter': 'verbose', }, 'error_file': { 'level': 'WARNING', 'class': 'logging.FileHandler', - 'filename': os.path.join(BASE_DIR, '..', 'logs', 'meal_architect_error.log'), + 'filename': os.path.join(PROJECT_ROOT, 'logs', 'meal_architect_error.log'), 'formatter': 'verbose', }, 'console': { diff --git a/backend/meal_architect/urls.py b/backend/meal_architect/urls.py index 96198c8998fdc716487114dd1d3bfa2a9dcdf1eb..9caddbfade2a2340c8ba838f06b844bdcc033387 100644 --- a/backend/meal_architect/urls.py +++ b/backend/meal_architect/urls.py @@ -33,6 +33,8 @@ urlpatterns = [ path('api/dishes/', include('dishes.urls')), # 添加通用的meal-sets路径,兼容前端调用 path('api/meal-sets/', include('meals.urls')), + # 添加兼容路径:/api/plans/ 指向 plans.urls(兼容前端调用) + path('api/plans/', include('plans.urls')), path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), ] diff --git a/backend/meal_architect/utils/image_processor.py b/backend/meal_architect/utils/image_processor.py new file mode 100644 index 0000000000000000000000000000000000000000..ed5c099c0e2aa3c232649d99cd176acf78487499 --- /dev/null +++ b/backend/meal_architect/utils/image_processor.py @@ -0,0 +1,277 @@ +""" +图片处理工具类 +用于压缩图片、生成缩略图等 +""" +import io +from PIL import Image +from django.core.files.uploadedfile import InMemoryUploadedFile +import sys + + +def compress_image(image_file, max_size=(1920, 1080), quality=85): + """ + 压缩图片 + + Args: + image_file: Django上传的文件对象 + max_size: 最大尺寸 (width, height),默认 (1920, 1080) + quality: JPEG质量 (1-100),默认85 + + Returns: + 压缩后的InMemoryUploadedFile对象 + """ + try: + # 打开图片 + img = Image.open(image_file) + + # 获取原始文件名和扩展名 + original_name = image_file.name + ext = original_name.split('.')[-1].lower() if '.' in original_name else 'jpg' + + # 转换模式为RGB(JPEG不支持透明通道) + if img.mode in ('RGBA', 'LA', 'P'): + # 如果有透明通道,创建白色背景 + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = background + elif img.mode != 'RGB': + img = img.convert('RGB') + + # 调整尺寸(保持宽高比) + img.thumbnail(max_size, Image.Resampling.LANCZOS) + + # 压缩保存到内存 + output = io.BytesIO() + img.save(output, format='JPEG', quality=quality, optimize=True) + output.seek(0) + + # 创建新的文件对象 + file_name = f"{original_name.rsplit('.', 1)[0] if '.' in original_name else 'image'}.jpg" + compressed_file = InMemoryUploadedFile( + output, + 'ImageField', + file_name, + 'image/jpeg', + sys.getsizeof(output), + None + ) + + return compressed_file + + except Exception as e: + # 如果处理失败,返回原文件 + print(f"图片压缩失败: {str(e)}") + image_file.seek(0) # 重置文件指针 + return image_file + + +def generate_thumbnail(image_file, size=(400, 300), quality=80): + """ + 生成缩略图 + + Args: + image_file: Django上传的文件对象或PIL Image对象 + size: 缩略图尺寸 (width, height),默认 (400, 300) + quality: JPEG质量 (1-100),默认80 + + Returns: + 缩略图的InMemoryUploadedFile对象 + """ + try: + # 如果是文件对象,打开图片;如果是Image对象,直接使用 + if isinstance(image_file, Image.Image): + img = image_file.copy() + original_name = 'thumbnail.jpg' + else: + img = Image.open(image_file) + original_name = image_file.name + + ext = original_name.split('.')[-1].lower() if '.' in original_name else 'jpg' + + # 转换模式为RGB + if img.mode in ('RGBA', 'LA', 'P'): + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = background + elif img.mode != 'RGB': + img = img.convert('RGB') + + # 生成缩略图(保持宽高比) + img.thumbnail(size, Image.Resampling.LANCZOS) + + # 保存到内存 + output = io.BytesIO() + img.save(output, format='JPEG', quality=quality, optimize=True) + output.seek(0) + + # 创建新的文件对象 + file_name = f"{original_name.rsplit('.', 1)[0] if '.' in original_name else 'thumbnail'}_thumb.jpg" + thumbnail_file = InMemoryUploadedFile( + output, + 'ImageField', + file_name, + 'image/jpeg', + sys.getsizeof(output), + None + ) + + return thumbnail_file + + except Exception as e: + print(f"生成缩略图失败: {str(e)}") + return None + + +def process_dish_image(image_file): + """ + 处理菜品图片:压缩原图并生成缩略图 + + Args: + image_file: Django上传的文件对象 + + Returns: + tuple: (压缩后的原图文件, 缩略图文件) + """ + # 重置文件指针 + image_file.seek(0) + + # 压缩原图 + compressed_image = compress_image(image_file, max_size=(1920, 1080), quality=85) + + # 生成缩略图(从压缩后的图片生成) + compressed_image.seek(0) + thumbnail = generate_thumbnail(compressed_image, size=(400, 300), quality=80) + + return compressed_image, thumbnail + + +def process_avatar_image(image_file): + """ + 处理头像图片:压缩 + + Args: + image_file: Django上传的文件对象 + + Returns: + 压缩后的文件对象 + """ + # 头像使用较小的尺寸 + image_file.seek(0) + compressed_image = compress_image(image_file, max_size=(800, 800), quality=85) + return compressed_image + + +def resize_to_fixed_size(image_file, target_size=(750, 750), quality=85): + """ + 将图片调整到固定尺寸(保持宽高比,居中裁剪或填充) + + Args: + image_file: Django上传的文件对象 + target_size: 目标尺寸 (width, height),默认 (750, 750) + quality: JPEG质量 (1-100),默认85 + + Returns: + 调整后的InMemoryUploadedFile对象 + """ + try: + # 打开图片 + img = Image.open(image_file) + + # 获取原始文件名 + original_name = image_file.name + + # 转换模式为RGB + if img.mode in ('RGBA', 'LA', 'P'): + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = background + elif img.mode != 'RGB': + img = img.convert('RGB') + + target_width, target_height = target_size + img_width, img_height = img.size + + # 计算缩放比例(保持宽高比) + scale = max(target_width / img_width, target_height / img_height) + + # 先缩放图片 + new_width = int(img_width * scale) + new_height = int(img_height * scale) + img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # 居中裁剪到目标尺寸 + left = (new_width - target_width) // 2 + top = (new_height - target_height) // 2 + right = left + target_width + bottom = top + target_height + + img = img.crop((left, top, right, bottom)) + + # 保存到内存 + output = io.BytesIO() + img.save(output, format='JPEG', quality=quality, optimize=True) + output.seek(0) + + # 创建新的文件对象 + file_name = f"{original_name.rsplit('.', 1)[0] if '.' in original_name else 'image'}.jpg" + resized_file = InMemoryUploadedFile( + output, + 'ImageField', + file_name, + 'image/jpeg', + sys.getsizeof(output), + None + ) + + return resized_file + + except Exception as e: + print(f"调整图片尺寸失败: {str(e)}") + image_file.seek(0) + return image_file + + +def process_dish_image(image_file): + """ + 处理菜品图片:调整到固定尺寸750x750并生成缩略图 + + Args: + image_file: Django上传的文件对象 + + Returns: + tuple: (调整后的原图文件(750x750), 缩略图文件) + """ + # 重置文件指针 + image_file.seek(0) + + # 调整到固定尺寸750x750 + resized_image = resize_to_fixed_size(image_file, target_size=(750, 750), quality=85) + + # 生成缩略图(从调整后的图片生成) + resized_image.seek(0) + thumbnail = generate_thumbnail(resized_image, size=(400, 300), quality=80) + + return resized_image, thumbnail + + +def process_step_image(image_file): + """ + 处理制作步骤图片:调整到固定尺寸1280x960 + + Args: + image_file: Django上传的文件对象 + + Returns: + 调整后的文件对象(1280x960) + """ + # 步骤图片固定尺寸1280x960(4:3比例) + image_file.seek(0) + resized_image = resize_to_fixed_size(image_file, target_size=(1280, 960), quality=85) + return resized_image + diff --git a/backend/meal_architect/utils/pdf_generator.py b/backend/meal_architect/utils/pdf_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..6daeb712c647547eb4178dcc59908c1f3aac144d --- /dev/null +++ b/backend/meal_architect/utils/pdf_generator.py @@ -0,0 +1,494 @@ +""" +通用PDF生成工具类 +支持生成采购清单、菜品详情等内容的PDF文件 +""" +import os +import tempfile +import requests +from datetime import datetime +from io import BytesIO +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.units import cm +from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image, PageBreak +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT +from django.conf import settings + + +class PDFGenerator: + """PDF生成器基类""" + + def __init__(self): + self.buffer = BytesIO() + self.page_size = A4 + self.setup_fonts() + self.styles = self.create_styles() + + def setup_fonts(self): + """设置中文字体支持""" + import subprocess + + try: + # 优先使用 fc-list 查找字体(如果可用) + font_path = None + + # 尝试使用 fc-list 命令查找中文字体 + try: + # 查找文泉驿字体文件路径 + result = subprocess.run( + ['fc-list', ':lang=zh', 'file'], + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode == 0: + # fc-list 输出格式: /path/to/font: 字体名:样式 + for line in result.stdout.split('\n'): + # 提取文件路径(冒号前的部分) + if ':' in line: + path = line.split(':')[0].strip() + if 'wqy-zenhei' in path.lower() and os.path.exists(path): + font_path = path + break + elif line.strip() and os.path.exists(line.strip()): + # 有时输出就是纯路径 + if 'wqy-zenhei' in line.lower(): + font_path = line.strip() + break + except (subprocess.TimeoutExpired, FileNotFoundError, Exception): + pass + + # 如果 fc-list 没找到,尝试常见路径 + if not font_path: + font_paths = [ + '/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc', + '/usr/share/fonts/wqy-zenhei/wqy-zenhei.ttc', + '/usr/share/fonts/opentype/wqy/wqy-zenhei.otf', + '/System/Library/Fonts/PingFang.ttc', # macOS + 'C:\\Windows\\Fonts\\simhei.ttf', # Windows + 'C:\\Windows\\Fonts\\msyh.ttc', # Windows 微软雅黑 + ] + + for path in font_paths: + if os.path.exists(path): + font_path = path + break + + # 注册字体 + if font_path: + try: + # 尝试注册为 TTFont + pdfmetrics.registerFont(TTFont('SimHei', font_path)) + self.chinese_font = 'SimHei' + print(f"成功加载中文字体: {font_path}") + return + except Exception as e: + print(f"注册字体失败 {font_path}: {e}") + # 继续尝试其他路径 + pass + + except Exception as e: + print(f"警告: 无法加载中文字体: {e}") + + # 如果所有方法都失败,使用默认字体(会显示为方块) + self.chinese_font = 'Helvetica' + print("警告: 未找到中文字体,PDF中的中文可能显示为方块。请安装中文字体包: apt install fonts-wqy-zenhei") + + def create_styles(self): + """创建样式""" + styles = getSampleStyleSheet() + + # 标题样式 + styles.add(ParagraphStyle( + name='ChineseTitle', + fontName=self.chinese_font, + fontSize=24, + textColor=colors.HexColor('#333333'), + alignment=TA_CENTER, + spaceAfter=20, + )) + + # 副标题样式 + styles.add(ParagraphStyle( + name='ChineseSubtitle', + fontName=self.chinese_font, + fontSize=16, + textColor=colors.HexColor('#666666'), + alignment=TA_CENTER, + spaceAfter=12, + )) + + # 正文样式 + styles.add(ParagraphStyle( + name='ChineseBody', + fontName=self.chinese_font, + fontSize=12, + textColor=colors.HexColor('#333333'), + alignment=TA_LEFT, + spaceAfter=6, + )) + + # 标注样式 + styles.add(ParagraphStyle( + name='ChineseCaption', + fontName=self.chinese_font, + fontSize=10, + textColor=colors.HexColor('#999999'), + alignment=TA_LEFT, + spaceAfter=6, + )) + + return styles + + def create_document(self, title="Document"): + """创建PDF文档""" + doc = SimpleDocTemplate( + self.buffer, + pagesize=self.page_size, + title=title, + author="配膳官", + leftMargin=2*cm, + rightMargin=2*cm, + topMargin=2*cm, + bottomMargin=2*cm, + ) + return doc + + def get_pdf_value(self): + """获取PDF内容""" + return self.buffer.getvalue() + + +class ShoppingListPDFGenerator(PDFGenerator): + """采购清单PDF生成器""" + + def generate(self, shopping_list_data): + """ + 生成采购清单PDF + + Args: + shopping_list_data: 采购清单数据 + { + 'date_range': '日期范围', + 'gourmet_count': 食神数量, + 'shopping_list': [{'name': '食材名', 'quantity': 数量, 'unit': '单位'}, ...], + 'generated_at': '生成时间' + } + """ + doc = self.create_document("采购清单") + story = [] + + # 标题 + title = Paragraph("采购清单", self.styles['ChineseTitle']) + story.append(title) + story.append(Spacer(1, 0.5*cm)) + + # 基本信息 + info_data = [ + ['日期范围', shopping_list_data.get('date_range', '-')], + ['食神数量', f"{shopping_list_data.get('gourmet_count', 0)}位"], + ['生成时间', shopping_list_data.get('generated_at', datetime.now().strftime('%Y-%m-%d %H:%M:%S'))], + ] + + info_table = Table(info_data, colWidths=[4*cm, 12*cm]) + info_table.setStyle(TableStyle([ + ('FONTNAME', (0, 0), (-1, -1), self.chinese_font), + ('FONTSIZE', (0, 0), (-1, -1), 11), + ('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#666666')), + ('TEXTCOLOR', (1, 0), (1, -1), colors.HexColor('#333333')), + ('ALIGN', (0, 0), (-1, -1), 'LEFT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('BOTTOMPADDING', (0, 0), (-1, -1), 8), + ])) + story.append(info_table) + story.append(Spacer(1, 1*cm)) + + # 分隔线 + line = Table([['']], colWidths=[16*cm]) + line.setStyle(TableStyle([ + ('LINEABOVE', (0, 0), (-1, -1), 1, colors.HexColor('#E0E0E0')), + ])) + story.append(line) + story.append(Spacer(1, 0.5*cm)) + + # 食材清单标题 + subtitle = Paragraph("食材清单", self.styles['ChineseSubtitle']) + story.append(subtitle) + story.append(Spacer(1, 0.5*cm)) + + # 食材列表 + shopping_list = shopping_list_data.get('shopping_list', []) + if shopping_list: + # 表头 + table_data = [['序号', '食材名称', '数量', '单位']] + + # 食材数据 + for idx, item in enumerate(shopping_list, 1): + table_data.append([ + str(idx), + item.get('name', '-'), + str(item.get('quantity', '-')), + item.get('unit', '-'), + ]) + + # 创建表格 + ingredient_table = Table(table_data, colWidths=[2*cm, 7*cm, 4*cm, 3*cm]) + ingredient_table.setStyle(TableStyle([ + ('FONTNAME', (0, 0), (-1, -1), self.chinese_font), + ('FONTSIZE', (0, 0), (-1, -1), 10), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), + ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#4CAF50')), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#E0E0E0')), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#F5F5F5')]), + ('TOPPADDING', (0, 0), (-1, -1), 8), + ('BOTTOMPADDING', (0, 0), (-1, -1), 8), + ])) + story.append(ingredient_table) + else: + no_data = Paragraph("暂无食材", self.styles['ChineseBody']) + story.append(no_data) + + story.append(Spacer(1, 1*cm)) + + # 底部信息 + footer = Paragraph( + "来自:配膳官小程序", + self.styles['ChineseCaption'] + ) + story.append(footer) + + # 构建PDF + doc.build(story) + return self.get_pdf_value() + + +class DishDetailPDFGenerator(PDFGenerator): + """菜品详情PDF生成器""" + + def __init__(self): + super().__init__() + self.temp_files = [] # 保存临时文件路径,在PDF构建完成后删除 + + def _download_image(self, image_url, max_size=(14*cm, 10*cm)): + """ + 下载图片并返回 Image 对象 + + Args: + image_url: 图片URL + max_size: 最大尺寸 (width, height) + + Returns: + Image 对象或 None(如果下载失败) + """ + try: + # 下载图片 + response = requests.get(image_url, timeout=10, stream=True) + if response.status_code != 200: + print(f"下载图片失败: {image_url}, 状态码: {response.status_code}") + return None + + # 保存到临时文件 + with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as tmp_file: + for chunk in response.iter_content(chunk_size=8192): + tmp_file.write(chunk) + tmp_path = tmp_file.name + + # 保存临时文件路径,用于后续清理 + self.temp_files.append(tmp_path) + + # 创建 Image 对象(reportlab会在构建时读取文件) + img = Image(tmp_path, width=max_size[0], height=max_size[1], kind='proportional') + + return img + except Exception as e: + print(f"处理图片失败 {image_url}: {str(e)}") + return None + + def _cleanup_temp_files(self): + """清理临时文件""" + for tmp_path in self.temp_files: + try: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + except Exception as e: + print(f"删除临时文件失败 {tmp_path}: {str(e)}") + self.temp_files = [] + + def generate(self, dish_data, include_images=True): + """ + 生成菜品详情PDF + + Args: + dish_data: 菜品数据 + include_images: 是否包含图片 + """ + doc = self.create_document(f"菜品详情 - {dish_data.get('name', '未命名')}") + story = [] + + # 标题 + title = Paragraph(dish_data.get('name', '未命名菜品'), self.styles['ChineseTitle']) + story.append(title) + story.append(Spacer(1, 0.3*cm)) + + # 厨神信息 + chef = dish_data.get('chef', {}) + chef_name = chef.get('nickname') or chef.get('username', '未知厨神') + chef_info = Paragraph(f"厨神:{chef_name}", self.styles['ChineseSubtitle']) + story.append(chef_info) + story.append(Spacer(1, 0.5*cm)) + + # 基本信息 + info_list = [] + if dish_data.get('category'): + info_list.append(f"分类:{dish_data.get('category')}") + if dish_data.get('status'): + status_map = {'draft': '草稿', 'published': '已发布'} + info_list.append(f"状态:{status_map.get(dish_data.get('status'), dish_data.get('status'))}") + if dish_data.get('updated_at'): + info_list.append(f"更新时间:{dish_data.get('updated_at')}") + + if info_list: + for info in info_list: + info_p = Paragraph(info, self.styles['ChineseBody']) + story.append(info_p) + story.append(Spacer(1, 0.5*cm)) + + # 菜品图片 + if include_images: + images = dish_data.get('images', []) + if images: + img_title = Paragraph("菜品图片", self.styles['ChineseSubtitle']) + story.append(img_title) + story.append(Spacer(1, 0.3*cm)) + + for img_data in images: + image_url = img_data.get('image_url') + if image_url: + img = self._download_image(image_url, max_size=(14*cm, 10*cm)) + if img: + story.append(img) + story.append(Spacer(1, 0.3*cm)) + + story.append(Spacer(1, 0.5*cm)) + + # 描述 + if dish_data.get('description'): + desc_title = Paragraph("菜品描述", self.styles['ChineseSubtitle']) + story.append(desc_title) + story.append(Spacer(1, 0.3*cm)) + + desc = Paragraph(dish_data.get('description'), self.styles['ChineseBody']) + story.append(desc) + story.append(Spacer(1, 0.8*cm)) + + # 食材清单 + ingredients = dish_data.get('ingredients', []) + if ingredients: + ing_title = Paragraph("食材清单", self.styles['ChineseSubtitle']) + story.append(ing_title) + story.append(Spacer(1, 0.3*cm)) + + # 食材表格 + table_data = [['序号', '食材名称', '数量', '单位', '分类']] + for idx, ing in enumerate(ingredients, 1): + category_map = { + 'vegetable': '蔬菜', + 'protein': '蛋白质', + 'carb': '碳水', + 'fat': '脂肪' + } + table_data.append([ + str(idx), + ing.get('name', '-'), + str(ing.get('quantity', '-')), + ing.get('unit', '-'), + category_map.get(ing.get('category', ''), '-'), + ]) + + ing_table = Table(table_data, colWidths=[2*cm, 5*cm, 3*cm, 3*cm, 3*cm]) + ing_table.setStyle(TableStyle([ + ('FONTNAME', (0, 0), (-1, -1), self.chinese_font), + ('FONTSIZE', (0, 0), (-1, -1), 10), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), + ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#FF9800')), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#E0E0E0')), + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#FFF8F0')]), + ('TOPPADDING', (0, 0), (-1, -1), 8), + ('BOTTOMPADDING', (0, 0), (-1, -1), 8), + ])) + story.append(ing_table) + story.append(Spacer(1, 0.8*cm)) + + # 制作步骤 + cooking_steps = dish_data.get('cooking_steps', []) + if cooking_steps: + step_title = Paragraph("制作步骤", self.styles['ChineseSubtitle']) + story.append(step_title) + story.append(Spacer(1, 0.3*cm)) + + for step in cooking_steps: + step_num = f"步骤 {step.get('step_number', 1)}" + step_p = Paragraph(f"{step_num}", self.styles['ChineseBody']) + story.append(step_p) + + desc_p = Paragraph(step.get('description', ''), self.styles['ChineseBody']) + story.append(desc_p) + + # 步骤图片 + if include_images and step.get('image_url'): + story.append(Spacer(1, 0.2*cm)) + img = self._download_image(step.get('image_url'), max_size=(12*cm, 8*cm)) + if img: + story.append(img) + + story.append(Spacer(1, 0.3*cm)) + + # 营养标签 + nutrition_tags = dish_data.get('nutrition_tags', []) + if nutrition_tags: + story.append(Spacer(1, 0.5*cm)) + nut_title = Paragraph("营养标签", self.styles['ChineseSubtitle']) + story.append(nut_title) + story.append(Spacer(1, 0.3*cm)) + + tags_text = "、".join(nutrition_tags) + tags_p = Paragraph(tags_text, self.styles['ChineseBody']) + story.append(tags_p) + + story.append(Spacer(1, 1*cm)) + + # 底部信息 + footer = Paragraph( + "来自:配膳官小程序", + self.styles['ChineseCaption'] + ) + story.append(footer) + + # 构建PDF + doc.build(story) + + # 清理临时文件 + self._cleanup_temp_files() + + return self.get_pdf_value() + + +# 便捷函数 +def generate_shopping_list_pdf(shopping_list_data): + """生成采购清单PDF""" + generator = ShoppingListPDFGenerator() + return generator.generate(shopping_list_data) + + +def generate_dish_detail_pdf(dish_data, include_images=True): + """生成菜品详情PDF""" + generator = DishDetailPDFGenerator() + return generator.generate(dish_data, include_images) + diff --git a/backend/meals/migrations/0001_initial.py b/backend/meals/migrations/0001_initial.py deleted file mode 100644 index cb7787cc88370d372e83928dfefdc300c5ea0f85..0000000000000000000000000000000000000000 --- a/backend/meals/migrations/0001_initial.py +++ /dev/null @@ -1,73 +0,0 @@ -# Generated by Django 4.2.7 on 2025-10-15 09:46 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("dishes", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="MealSet", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=100, verbose_name="套餐名称")), - ( - "meal_type", - models.CharField( - choices=[ - ("breakfast", "早餐"), - ("lunch", "午餐"), - ("dinner", "晚餐"), - ], - max_length=20, - verbose_name="餐别", - ), - ), - ( - "created_at", - models.DateTimeField(auto_now_add=True, verbose_name="创建时间"), - ), - ( - "updated_at", - models.DateTimeField(auto_now=True, verbose_name="更新时间"), - ), - ( - "chef", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="meal_sets", - to=settings.AUTH_USER_MODEL, - verbose_name="厨神", - ), - ), - ( - "dishes", - models.ManyToManyField( - related_name="meal_sets", to="dishes.dish", verbose_name="菜品" - ), - ), - ], - options={ - "verbose_name": "套餐", - "verbose_name_plural": "套餐", - "db_table": "meal_sets", - "ordering": ["-created_at"], - }, - ), - ] diff --git a/backend/meals/models.py b/backend/meals/models.py index f89eb9d5110b4655ea217e43c071528d7b681cba..7e1748ba90298f2598da132259bcaa3fff3e6c52 100644 --- a/backend/meals/models.py +++ b/backend/meals/models.py @@ -35,7 +35,12 @@ class MealSet(models.Model): def validate_dishes(self): """验证菜品组合是否符合配餐公式""" dishes = self.dishes.all() - categories = set(dish.category for dish in dishes) + + # 收集所有菜品的营养成分分类(从nutrition_categories) + categories = set() + for dish in dishes: + for nc in dish.nutrition_categories.all(): + categories.add(nc.code) # 配餐公式 required_categories = { diff --git a/backend/meals/views.py b/backend/meals/views.py index 935a0285b1655bb845070072f2dc6aa54605e5e5..c85e2e78b25f704ff88965916cd320f0e5321f52 100644 --- a/backend/meals/views.py +++ b/backend/meals/views.py @@ -27,7 +27,7 @@ class MealSetViewSet(viewsets.ModelViewSet): # 食神可以看到所有已绑定厨神的套餐 approved_chefs = ChefGourmetBinding.objects.filter( gourmet=user, - status='approved' + status='accepted' ).values_list('chef_id', flat=True) return MealSet.objects.filter( diff --git a/backend/plans/migrations/0001_initial.py b/backend/plans/migrations/0001_initial.py deleted file mode 100644 index 2522cecbbaf89248ce8d4dd7ebb8c2ef92517be4..0000000000000000000000000000000000000000 --- a/backend/plans/migrations/0001_initial.py +++ /dev/null @@ -1,90 +0,0 @@ -# Generated by Django 4.2.7 on 2025-10-15 09:46 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - ("meals", "0001_initial"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("dishes", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="GourmetDailyPlan", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("date", models.DateField(verbose_name="日期")), - ( - "meal_type", - models.CharField( - choices=[ - ("breakfast", "早餐"), - ("lunch", "午餐"), - ("dinner", "晚餐"), - ], - max_length=20, - verbose_name="餐别", - ), - ), - ("notes", models.TextField(blank=True, verbose_name="备注")), - ( - "created_at", - models.DateTimeField(auto_now_add=True, verbose_name="创建时间"), - ), - ( - "updated_at", - models.DateTimeField(auto_now=True, verbose_name="更新时间"), - ), - ( - "dishes", - models.ManyToManyField( - blank=True, - related_name="daily_plans", - to="dishes.dish", - verbose_name="菜品", - ), - ), - ( - "gourmet", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="daily_plans", - to=settings.AUTH_USER_MODEL, - verbose_name="食神", - ), - ), - ( - "meal_set", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="daily_plans", - to="meals.mealset", - verbose_name="套餐", - ), - ), - ], - options={ - "verbose_name": "食神每日配餐计划", - "verbose_name_plural": "食神每日配餐计划", - "db_table": "gourmet_daily_plans", - "ordering": ["date", "meal_type"], - "unique_together": {("gourmet", "date", "meal_type")}, - }, - ), - ] diff --git a/backend/plans/models.py b/backend/plans/models.py index 0b62bead67a8ccc6d4194db4477711c4e3e7db8f..cb508840a0e949173db26330ddc04a760d95de07 100644 --- a/backend/plans/models.py +++ b/backend/plans/models.py @@ -45,13 +45,19 @@ class GourmetDailyPlan(models.Model): def validate_nutrition_balance(self): """验证营养搭配是否合理""" - dishes = self.dishes.all() + # 使用prefetch_related优化查询,避免N+1查询问题 + dishes = self.dishes.all().prefetch_related('nutrition_categories') if not dishes.exists(): return True, "暂无菜品" - categories = set(dish.category for dish in dishes) + # 收集所有菜品的营养成分分类(从nutrition_categories多对多关系) + categories = set() + for dish in dishes: + nutrition_cats = dish.nutrition_categories.all() + for nc in nutrition_cats: + categories.add(nc.code) - # 配餐公式 + # 配餐公式(使用营养成分分类) required_categories = { 'breakfast': {'vegetable', 'protein'}, 'lunch': {'vegetable', 'protein', 'carb', 'fat'}, @@ -62,7 +68,11 @@ class GourmetDailyPlan(models.Model): if not required.issubset(categories): missing = required - categories - return False, f"缺少以下分类: {', '.join(missing)}" + # 获取营养成分分类的中文名称 + from dishes.models import NutritionCategory + nutrition_names = {nc.code: nc.name for nc in NutritionCategory.objects.filter(code__in=missing)} + missing_names = [nutrition_names.get(code, code) for code in missing] + return False, f"缺少以下营养成分: {', '.join(missing_names)}" return True, "营养搭配合理" diff --git a/backend/plans/serializers.py b/backend/plans/serializers.py index b13256f2b076c58bd3a341177f2b2ae717aa5023..6d6d4a6eaf725c4edd959806c919e46bd6d808b9 100644 --- a/backend/plans/serializers.py +++ b/backend/plans/serializers.py @@ -51,7 +51,7 @@ class GourmetDailyPlanSerializer(serializers.ModelSerializer): # 获取已绑定的厨神 approved_chefs = ChefGourmetBinding.objects.filter( gourmet=request.user, - status='approved' + status='accepted' ).values_list('chef_id', flat=True) # 验证菜品是否属于已绑定的厨神 @@ -78,7 +78,7 @@ class GourmetDailyPlanSerializer(serializers.ModelSerializer): # 获取已绑定的厨神 approved_chefs = ChefGourmetBinding.objects.filter( gourmet=request.user, - status='approved' + status='accepted' ).values_list('chef_id', flat=True) # 验证套餐是否属于已绑定的厨神 @@ -180,7 +180,7 @@ class GourmetDailyPlanCreateSerializer(serializers.ModelSerializer): # 获取已绑定的厨神 approved_chefs = ChefGourmetBinding.objects.filter( gourmet=request.user, - status='approved' + status='accepted' ).values_list('chef_id', flat=True) # 验证菜品是否属于已绑定的厨神 @@ -207,7 +207,7 @@ class GourmetDailyPlanCreateSerializer(serializers.ModelSerializer): # 获取已绑定的厨神 approved_chefs = ChefGourmetBinding.objects.filter( gourmet=request.user, - status='approved' + status='accepted' ).values_list('chef_id', flat=True) # 验证套餐是否属于已绑定的厨神 diff --git a/backend/plans/urls.py b/backend/plans/urls.py index 68b9d4c60170af01577ac542ba28fbfc9c887a83..186a63a2817c3c0e1363925bb3cf5ddf28182956 100644 --- a/backend/plans/urls.py +++ b/backend/plans/urls.py @@ -6,17 +6,19 @@ router = DefaultRouter() router.register(r'plans', views.GourmetDailyPlanViewSet, basename='plan') urlpatterns = [ - path('', include(router.urls)), - # 厨神相关API - path('chef/schedule-summary/', views.chef_schedule_summary, name='chef_schedule_summary'), - path('chef/shopping-list/', views.generate_shopping_list, name='generate_shopping_list'), - path('chef/gourmet-plans/', views.chef_gourmet_plans, name='chef_gourmet_plans'), + # 菜品选择器API - 放在router之前,避免被router捕获 + path('dish-selector/', views.dish_selector, name='dish_selector'), + path('meal-set-selector/', views.meal_set_selector, name='meal_set_selector'), # 食神相关API path('chefs/search/', views.search_chefs, name='search_chefs'), path('bindings/', views.gourmet_bindings, name='gourmet_bindings'), path('menus/', views.browse_menus, name='browse_menus'), - # 菜品选择器API - path('dish-selector/', views.dish_selector, name='dish_selector'), - path('meal-set-selector/', views.meal_set_selector, name='meal_set_selector'), + # 厨神相关API + path('chef/schedule-summary/', views.chef_schedule_summary, name='chef_schedule_summary'), + path('chef/shopping-list/', views.generate_shopping_list, name='generate_shopping_list'), + path('chef/shopping-list/pdf/', views.generate_shopping_list_pdf, name='generate_shopping_list_pdf'), + path('chef/gourmet-plans/', views.chef_gourmet_plans, name='chef_gourmet_plans'), + # ViewSet路由 - 放在最后 + path('', include(router.urls)), ] diff --git a/backend/plans/views.py b/backend/plans/views.py index 6ddcf938b6cb4d7ef553d608c46d36254195ed58..178dc306597748c1894e8a0adfa10826271249d0 100644 --- a/backend/plans/views.py +++ b/backend/plans/views.py @@ -3,7 +3,7 @@ from rest_framework.decorators import action, api_view, permission_classes from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated from django.db.models import Q, Count -from datetime import datetime, timedelta +from datetime import datetime, timedelta, date from collections import defaultdict from meal_architect.utils.log_manager import logger from meal_architect.utils.cache_manager import CacheManager, cache_result, gourmet_plans_cache_key, chef_schedule_cache_key, shopping_list_cache_key @@ -40,7 +40,7 @@ class GourmetDailyPlanViewSet(viewsets.ModelViewSet): # 厨神可以看到已绑定食神的配餐计划 approved_gourmets = ChefGourmetBinding.objects.filter( chef=user, - status='approved' + status='accepted' ).values_list('gourmet_id', flat=True) return GourmetDailyPlan.objects.filter( @@ -131,6 +131,23 @@ class GourmetDailyPlanViewSet(viewsets.ModelViewSet): status=status.HTTP_404_NOT_FOUND ) + # 验证目标日期不能是今天之前的日期 + today = date.today() + invalid_dates = [] + for target_date_str in target_dates: + try: + target_date = datetime.strptime(target_date_str, '%Y-%m-%d').date() + if target_date < today: + invalid_dates.append(target_date_str) + except ValueError: + continue + + if invalid_dates: + return Response( + {'error': f'不能复制到今天之前的日期:{", ".join(invalid_dates)}'}, + status=status.HTTP_400_BAD_REQUEST + ) + created_plans = [] for target_date_str in target_dates: try: @@ -329,7 +346,7 @@ def chef_schedule_summary(request): # 获取已绑定的食神(优化查询) approved_gourmets = ChefGourmetBinding.objects.filter( chef=request.user, - status='approved' + status='accepted' ).select_related('gourmet') # 如果指定了食神ID,则只查询这些食神 @@ -465,7 +482,7 @@ def chef_gourmet_plans(request): binding = ChefGourmetBinding.objects.filter( chef=request.user, gourmet_id=gourmet_id, - status='approved' + status='accepted' ).select_related('gourmet').first() if not binding: @@ -593,7 +610,7 @@ def generate_shopping_list(request): # 获取已绑定的食神 approved_gourmets = ChefGourmetBinding.objects.filter( chef=request.user, - status='approved' + status='accepted' ).select_related('gourmet') # 如果指定了食神ID,则只查询这些食神 @@ -733,10 +750,121 @@ def generate_shopping_list(request): 'gourmet_info': gourmet_info, 'stats': stats, 'gourmet_breakdown': gourmet_breakdown, - 'generated_at': datetime.now().isoformat() + 'generated_at': datetime.now().isoformat(), + 'date_range': f"{start_date_str} 至 {end_date_str}" }) +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def generate_shopping_list_pdf(request): + """生成采购清单PDF""" + if request.user.role != 'chef': + return Response({'error': '只有厨神可以生成采购清单PDF'}, status=status.HTTP_403_FORBIDDEN) + + from django.http import HttpResponse + from meal_architect.utils.pdf_generator import generate_shopping_list_pdf as gen_pdf + + # 获取参数 + start_date_str = request.query_params.get('start_date') + end_date_str = request.query_params.get('end_date') + gourmet_ids = request.query_params.getlist('gourmet_ids') + meal_types = request.query_params.getlist('meal_types') + + if not start_date_str or not end_date_str: + return Response( + {'error': '请提供start_date和end_date参数'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() + end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() + except ValueError: + return Response( + {'error': '日期格式错误,应为YYYY-MM-DD'}, + status=status.HTTP_400_BAD_REQUEST + ) + + if start_date > end_date: + return Response( + {'error': '开始日期不能晚于结束日期'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 获取已绑定的食神 + approved_gourmets = ChefGourmetBinding.objects.filter( + chef=request.user, + status='accepted' + ).select_related('gourmet') + + if gourmet_ids: + gourmet_ids = [int(gid) for gid in gourmet_ids if gid.isdigit()] + approved_gourmets = approved_gourmets.filter(gourmet_id__in=gourmet_ids) + + gourmet_ids_list = list(approved_gourmets.values_list('gourmet_id', flat=True)) + + if not gourmet_ids_list: + return Response( + {'error': '您没有已绑定的食神'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 查询配餐计划 + queryset = GourmetDailyPlan.objects.filter( + gourmet_id__in=gourmet_ids_list, + date__range=[start_date, end_date] + ).prefetch_related('dishes__ingredients', 'gourmet') + + if meal_types: + queryset = queryset.filter(meal_type__in=meal_types) + + # 汇总食材 + ingredients_summary = defaultdict(lambda: defaultdict(float)) + + for plan in queryset: + for dish in plan.dishes.all(): + for ingredient in dish.ingredients.all(): + ingredients_summary[ingredient.name][ingredient.unit] += float(ingredient.quantity) + + # 生成采购清单 + shopping_list = [] + for name, units in ingredients_summary.items(): + for unit, quantity in units.items(): + shopping_list.append({ + 'name': name, + 'quantity': round(quantity, 2), + 'unit': unit + }) + + # 按名称排序 + shopping_list.sort(key=lambda x: x['name']) + + # 准备PDF数据 + pdf_data = { + 'date_range': f"{start_date_str} 至 {end_date_str}", + 'gourmet_count': len(gourmet_ids_list), + 'shopping_list': shopping_list, + 'generated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + } + + # 生成PDF + try: + pdf_content = gen_pdf(pdf_data) + + # 返回PDF文件 + response = HttpResponse(pdf_content, content_type='application/pdf') + filename = f"shopping_list_{start_date_str}_{end_date_str}.pdf" + response['Content-Disposition'] = f'attachment; filename="{filename}"' + return response + except Exception as e: + logger.error(f"生成采购清单PDF失败: {str(e)}") + return Response( + {'error': f'生成PDF失败: {str(e)}'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + @api_view(['GET']) @permission_classes([IsAuthenticated]) def search_chefs(request): @@ -785,33 +913,46 @@ def browse_menus(request): return Response({'error': '只有食神可以浏览菜单'}, status=status.HTTP_403_FORBIDDEN) # 获取已绑定的厨神 - approved_chefs = ChefGourmetBinding.objects.filter( + bindings = ChefGourmetBinding.objects.filter( gourmet=request.user, - status='approved' - ).values_list('chef_id', flat=True) + status='accepted' + ).select_related('chef') + + approved_chef_ids = [binding.chef_id for binding in bindings] # 获取菜品和套餐 from dishes.models import Dish from meals.models import MealSet + from users.serializers import UserSerializer dishes = Dish.objects.filter( - chef_id__in=approved_chefs, + chef_id__in=approved_chef_ids, status='published' ).prefetch_related('images', 'ingredients') meal_sets = MealSet.objects.filter( - chef_id__in=approved_chefs + chef_id__in=approved_chef_ids ).prefetch_related('dishes') from dishes.serializers import DishListSerializer from meals.serializers import MealSetListSerializer - dishes_data = DishListSerializer(dishes, many=True).data - meal_sets_data = MealSetListSerializer(meal_sets, many=True).data + dishes_data = DishListSerializer(dishes, many=True, context={'request': request}).data + meal_sets_data = MealSetListSerializer(meal_sets, many=True, context={'request': request}).data + + # 获取厨神列表 + chefs_data = [ + { + 'id': binding.chef.id, + 'nickname': binding.chef.nickname + } + for binding in bindings + ] return Response({ 'dishes': dishes_data, - 'meal_sets': meal_sets_data + 'meal_sets': meal_sets_data, + 'chefs': chefs_data }) @@ -833,7 +974,7 @@ def dish_selector(request): # 获取已绑定的厨神 approved_chefs = ChefGourmetBinding.objects.filter( gourmet=request.user, - status='approved' + status='accepted' ).values_list('chef_id', flat=True) if not approved_chefs.exists(): @@ -848,12 +989,17 @@ def dish_selector(request): ).prefetch_related('images', 'ingredients') # 如果指定了厨神 - if chef_id and int(chef_id) in approved_chefs: - dishes_query = dishes_query.filter(chef_id=chef_id) + if chef_id and chef_id != 'null' and chef_id != '': + try: + chef_id_int = int(chef_id) + if chef_id_int in approved_chefs: + dishes_query = dishes_query.filter(chef_id=chef_id_int) + except (ValueError, TypeError): + pass - # 如果指定了分类 + # 如果指定了分类(改为使用nutrition_categories筛选) if category: - dishes_query = dishes_query.filter(category=category) + dishes_query = dishes_query.filter(nutrition_categories__code=category).distinct() # 如果有关键词搜索 if keyword: @@ -868,17 +1014,49 @@ def dish_selector(request): recommended = recommended_categories.get(meal_type, []) - # 按分类组织菜品 + # 调试:检查查询结果数量 + dishes_count = dishes_query.count() + import logging + logger = logging.getLogger(__name__) + logger.debug(f'菜品选择器查询结果数量: {dishes_count}, 厨师ID: {chef_id}, 餐别: {meal_type}') + + # 按分类组织菜品(使用dish_type作为分类,用于前端显示) + from dishes.models import NutritionCategory + dishes_by_category = {} for dish in dishes_query: - cat = dish.category - if cat not in dishes_by_category: - dishes_by_category[cat] = [] - dishes_by_category[cat].append(dish) + # 优先使用菜品类型(dish_type)作为分类,用于前端显示 + # 如果菜品类型存在,使用菜品类型作为分类 + if dish.dish_type: + dish_type = dish.dish_type + if dish_type not in dishes_by_category: + dishes_by_category[dish_type] = [] + dishes_by_category[dish_type].append(dish) + else: + # 如果没有菜品类型,使用第一个营养成分分类作为分类 + nutrition_cats = dish.nutrition_categories.all() + if nutrition_cats.exists(): + main_cat = nutrition_cats.first() + cat_code = main_cat.code + if cat_code not in dishes_by_category: + dishes_by_category[cat_code] = [] + dishes_by_category[cat_code].append(dish) + else: + # 如果既没有菜品类型也没有营养成分分类,放到"未分类" + if 'uncategorized' not in dishes_by_category: + dishes_by_category['uncategorized'] = [] + dishes_by_category['uncategorized'].append(dish) # 序列化数据 from dishes.serializers import DishListSerializer + # 获取所有营养成分分类的映射(用于配餐公式) + all_nutrition_categories = {nc.code: nc.name for nc in NutritionCategory.objects.all()} + + # 获取菜品类型映射(用于显示分类名称) + from dishes.models import Dish + dish_type_choices = dict(Dish.DISH_TYPE_CHOICES) + result = { 'meal_type': meal_type, 'recommended_categories': recommended, @@ -886,10 +1064,15 @@ def dish_selector(request): } for cat, dish_list in dishes_by_category.items(): + # 优先使用菜品类型名称,如果没有则使用营养成分分类名称 + category_name = dish_type_choices.get(cat, all_nutrition_categories.get(cat, cat)) + if cat == 'uncategorized': + category_name = '未分类' + result['dishes_by_category'][cat] = { - 'category_name': dict(Dish.CATEGORY_CHOICES)[cat], + 'category_name': category_name, 'is_recommended': cat in recommended, - 'dishes': DishListSerializer(dish_list, many=True).data + 'dishes': DishListSerializer(dish_list, many=True, context={'request': request}).data } return Response(result) @@ -912,7 +1095,7 @@ def meal_set_selector(request): # 获取已绑定的厨神 approved_chefs = ChefGourmetBinding.objects.filter( gourmet=request.user, - status='approved' + status='accepted' ).values_list('chef_id', flat=True) if not approved_chefs.exists(): @@ -927,8 +1110,13 @@ def meal_set_selector(request): ).prefetch_related('dishes') # 如果指定了厨神 - if chef_id and int(chef_id) in approved_chefs: - meal_sets_query = meal_sets_query.filter(chef_id=chef_id) + if chef_id and chef_id != 'null' and chef_id != '': + try: + chef_id_int = int(chef_id) + if chef_id_int in approved_chefs: + meal_sets_query = meal_sets_query.filter(chef_id=chef_id_int) + except (ValueError, TypeError): + pass # 如果有关键词搜索 if keyword: diff --git a/backend/users/serializers.py b/backend/users/serializers.py index 367964ee8406100bb00a40a85fa044299d0149cc..e7f6907a27018b60f1d9997c33d675a5ba005e3e 100644 --- a/backend/users/serializers.py +++ b/backend/users/serializers.py @@ -192,17 +192,17 @@ class BindingProcessSerializer(serializers.ModelSerializer): class BindingListSerializer(serializers.ModelSerializer): """绑定列表序列化器(简化版)""" - chef_nickname = serializers.CharField(source='chef.nickname', read_only=True) - chef_avatar = serializers.URLField(source='chef.avatar_url', read_only=True) - gourmet_nickname = serializers.CharField(source='gourmet.nickname', read_only=True) - gourmet_avatar = serializers.URLField(source='gourmet.avatar_url', read_only=True) + chef = UserSerializer(read_only=True) + gourmet = UserSerializer(read_only=True) status_display = serializers.CharField(source='get_status_display', read_only=True) + created_at = serializers.DateTimeField(format='%Y-%m-%d %H:%M:%S', read_only=True) + applied_at = serializers.DateTimeField(format='%Y-%m-%d %H:%M:%S', read_only=True) + processed_at = serializers.DateTimeField(format='%Y-%m-%d %H:%M:%S', read_only=True, allow_null=True) class Meta: model = ChefGourmetBinding fields = [ 'id', 'chef', 'gourmet', 'status', 'status_display', - 'chef_nickname', 'chef_avatar', 'gourmet_nickname', 'gourmet_avatar', - 'applied_at', 'processed_at', 'apply_message' + 'applied_at', 'processed_at', 'apply_message', 'created_at' ] diff --git a/backend/users/views.py b/backend/users/views.py index 6eb185e265cb2a618d7e62bc4468b3093da7b94d..cbc7eee6b63333215b309a36be69d1b2a582d735 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -262,13 +262,17 @@ def upload_avatar(request): filename = f"avatar_{request.user.id}_{uuid.uuid4().hex}{ext}" logger.log_d(f"生成的文件名: {filename}") + # 处理图片:压缩 + from meal_architect.utils.image_processor import process_avatar_image + compressed_image = process_avatar_image(image_file) + # 构建存储路径 upload_path = f"users/avatars/{filename}" logger.log_d(f"存储路径: {upload_path}") - # 保存文件到media目录 + # 保存压缩后的文件到media目录 from django.core.files.storage import default_storage - file_path = default_storage.save(upload_path, image_file) + file_path = default_storage.save(upload_path, compressed_image) logger.log_d(f"文件保存成功: {file_path}") # 生成访问URL @@ -351,9 +355,31 @@ def search_users(request): users = users[:20] serializer = UserSerializer(users, many=True) + user_data = serializer.data + + # 如果是食神搜索厨神,添加绑定状态信息 + if request.user.role == 'gourmet' and role == 'chef': + # 获取当前用户的所有绑定关系 + bindings = ChefGourmetBinding.objects.filter( + gourmet=request.user + ).select_related('chef') + + # 创建绑定状态映射 + binding_status_map = {} + for binding in bindings: + chef_id = binding.chef.id + # 只记录有效的绑定状态(pending或accepted),cancelled和rejected允许重新申请 + if binding.status in ['pending', 'accepted']: + binding_status_map[chef_id] = binding.status + + # 为每个用户添加绑定状态 + for user in user_data: + chef_id = user['id'] + user['binding_status'] = binding_status_map.get(chef_id, None) + return Response({ - 'users': serializer.data, - 'count': len(serializer.data) + 'users': user_data, + 'count': len(user_data) }) @@ -453,10 +479,10 @@ def list_bindings(request): if user.role == 'gourmet': # 食神查看自己的绑定关系 - bindings = ChefGourmetBinding.objects.filter(gourmet=user) + bindings = ChefGourmetBinding.objects.filter(gourmet=user).select_related('chef', 'gourmet') elif user.role == 'chef': # 厨神查看自己的绑定关系 - bindings = ChefGourmetBinding.objects.filter(chef=user) + bindings = ChefGourmetBinding.objects.filter(chef=user).select_related('chef', 'gourmet') else: return Response({ 'error': '用户角色未设置' @@ -467,6 +493,9 @@ def list_bindings(request): if status_filter in ['pending', 'accepted', 'rejected', 'cancelled']: bindings = bindings.filter(status=status_filter) + # 按申请时间倒序排列 + bindings = bindings.order_by('-applied_at', '-created_at') + # 分页 from rest_framework.pagination import PageNumberPagination paginator = PageNumberPagination() @@ -500,12 +529,12 @@ def get_binding_detail(request, binding_id): return Response(serializer.data) -@api_view(['PUT']) +@api_view(['POST', 'PUT']) @permission_classes([IsAuthenticated]) def process_binding(request, binding_id): """ 处理绑定申请(同意/拒绝) - PUT /api/users/bindings/{id}/process/ + POST/PUT /api/users/bindings/{id}/process/ """ try: binding = ChefGourmetBinding.objects.get(id=binding_id) @@ -616,7 +645,7 @@ def get_pending_requests(request): pending_bindings = ChefGourmetBinding.objects.filter( chef=request.user, status='pending' - ).order_by('-applied_at') + ).select_related('chef', 'gourmet').order_by('-applied_at') serializer = BindingListSerializer(pending_bindings, many=True) return Response({ diff --git a/data/meal_architect.db b/data/meal_architect.db deleted file mode 100644 index 7d89789c124244db90a8b46605baa0010bd953c1..0000000000000000000000000000000000000000 Binary files a/data/meal_architect.db and /dev/null differ diff --git a/deploy/README_CONFIG.md b/deploy/README_CONFIG.md new file mode 100644 index 0000000000000000000000000000000000000000..3905219d6ba79e03c90358a81964780a40664fb8 --- /dev/null +++ b/deploy/README_CONFIG.md @@ -0,0 +1,95 @@ +# 统一配置管理说明 + +## 概述 + +所有生产环境配置都集中在 `deploy/config.sh` 文件中,这是**唯一的配置来源**。 + +## 配置文件位置 + +- **唯一配置源**: `deploy/config.sh` +- **模板文件**(自动从 config.sh 填充): + - `deploy/env.prod.template` - Django 环境配置 + - `deploy/config/nginx.conf.template` - Nginx 配置 + - `deploy/config/supervisor.conf.template` - Supervisor 配置(可选) + +## 需要修改配置时 + +**只需要修改 `deploy/config.sh` 文件中的以下变量:** + +```bash +# 域名配置(唯一源) +PROD_DOMAIN="b106.xyz" +PROD_WWW_DOMAIN="www.b106.xyz" + +# 路径配置(唯一源) +PROD_PROJECT_DIR="/root/Meal_Architect" +``` + +修改后,运行 `install.sh` 会自动: +1. 更新 `.env` 文件中的 `BASE_URL` 和 `ALLOWED_HOSTS` +2. 更新 `nginx.conf` 中的域名和路径(从 `nginx.conf.template` 生成) +3. 更新 `supervisor.conf` 中的路径(从 `supervisor.conf.template` 生成,如果使用) +4. 更新所有相关路径 + +## 配置项说明 + +### 域名配置 +- `PROD_DOMAIN`: 主域名(如:b106.xyz) +- `PROD_WWW_DOMAIN`: WWW子域名(如:www.b106.xyz) +- `PROD_BASE_URL`: 自动生成(https://${PROD_DOMAIN}) + +### 路径配置 +- `PROD_PROJECT_DIR`: 项目根目录 +- `PROD_BACKEND_DIR`: Backend目录 +- `PROD_VENV_DIR`: 虚拟环境目录 +- `PROD_DATA_DIR`: 数据目录(包含staticfiles和media) +- `PROD_STATIC_DIR`: 静态文件目录 +- `PROD_MEDIA_DIR`: 媒体文件目录 +- `PROD_LOGS_DIR`: 日志目录 + +## 使用方式 + +其他脚本通过 `source` 命令加载配置: + +```bash +CONFIG_FILE="$SCRIPT_DIR/config.sh" +source "$CONFIG_FILE" + +# 使用配置变量 +echo "域名: $PROD_DOMAIN" +echo "BASE_URL: $PROD_BASE_URL" +``` + +## 注意事项 + +⚠️ **不要**在以下文件中手动修改域名或路径: +- ❌ `deploy/env.prod.template` - 使用占位符 `{{BASE_URL}}`、`{{DOMAIN}}`,会自动替换 +- ❌ `deploy/config/nginx.conf.template` - 使用占位符 `{{DOMAIN}}`、`{{STATIC_DIR}}` 等,会自动替换 +- ❌ `deploy/config/supervisor.conf.template` - 使用占位符 `{{VENV_DIR}}`、`{{BACKEND_DIR}}` 等,会自动替换 +- ❌ `deploy/install.sh` - 从 config.sh 加载配置 +- ❌ `deploy/update.sh` - 从 config.sh 加载配置(需要时) +- ❌ `.env` 文件 - 由 install.sh 自动生成 +- ❌ `/etc/nginx/sites-available/b106.xyz` - 由模板自动生成 + +✅ **只在** `deploy/config.sh` 中修改配置 + +## 模板文件占位符说明 + +### nginx.conf.template +- `{{DOMAIN}}` → 从 `PROD_DOMAIN` 读取 +- `{{WWW_DOMAIN}}` → 从 `PROD_WWW_DOMAIN` 读取 +- `{{SSL_CERT_PATH}}` → 从 `PROD_SSL_CERT_PATH` 读取 +- `{{STATIC_DIR}}` → 从 `PROD_STATIC_DIR` 读取 +- `{{MEDIA_DIR}}` → 从 `PROD_MEDIA_DIR` 读取 +- `{{PROJECT_DIR}}` → 从 `PROD_PROJECT_DIR` 读取 + +### supervisor.conf.template +- `{{VENV_DIR}}` → 从 `PROD_VENV_DIR` 读取 +- `{{BACKEND_DIR}}` → 从 `PROD_BACKEND_DIR` 读取 +- `{{PROJECT_DIR}}` → 从 `PROD_PROJECT_DIR` 读取 + +### env.prod.template +- `{{BASE_URL}}` → 从 `PROD_BASE_URL` 读取 +- `{{DOMAIN}}` → 从 `PROD_DOMAIN` 读取 +- `{{WWW_DOMAIN}}` → 从 `PROD_WWW_DOMAIN` 读取 + diff --git a/deploy/clean.bat b/deploy/clean.bat new file mode 100644 index 0000000000000000000000000000000000000000..f25c28dd029f5b860154b75c9ddc734b850dfd3c --- /dev/null +++ b/deploy/clean.bat @@ -0,0 +1,188 @@ +@echo off +chcp 65001 >nul + +echo ================================== +echo Meal Architect Windows Cleanup Script +echo 清理脚本 +echo ================================== +echo. +echo [WARNING] This will remove: +echo - All __pycache__ directories +echo - Data directory contents (database, staticfiles, media) +echo - Log files +echo. +echo [INFO] This script will NOT remove: +echo - Virtual environment (venv/) +echo - .env configuration file +echo - Source code files +echo. +echo Press any key to continue or Ctrl+C to cancel... +pause >nul +echo. + +REM ============================================ +REM 路径配置 - 集中管理所有路径,便于维护 +REM ============================================ +set "PROJECT_DIR=%~dp0.." +set "BACKEND_DIR=%PROJECT_DIR%\backend" +set "DATA_DIR=%PROJECT_DIR%\data" +set "LOGS_DIR=%PROJECT_DIR%\logs" +set "STATIC_DIR=%DATA_DIR%\staticfiles" +set "MEDIA_DIR=%DATA_DIR%\media" + +echo [INFO] Starting cleanup... +echo. +echo [DEBUG] PROJECT_DIR: %PROJECT_DIR% +echo [DEBUG] BACKEND_DIR: %BACKEND_DIR% +echo [DEBUG] DATA_DIR: %DATA_DIR% +echo [DEBUG] LOGS_DIR: %LOGS_DIR% +echo. + +REM ============================================ +REM 1. 清理 __pycache__ 目录 +REM ============================================ +echo [INFO] Step 1: Cleaning __pycache__ directories... +set "CACHE_COUNT=0" + +REM 遍历所有 __pycache__ 目录并删除 +for /d /r "%PROJECT_DIR%" %%d in (__pycache__) do ( + if exist "%%d" ( + echo [INFO] Removing: %%d + rmdir /s /q "%%d" >nul 2>&1 + if errorlevel 1 ( + echo [WARNING] Failed to remove: %%d + ) else ( + set /a CACHE_COUNT+=1 + ) + ) +) + +echo [OK] Removed %CACHE_COUNT% __pycache__ directories +echo. + +REM ============================================ +REM 2. 清理 .pyc 文件 +REM ============================================ +echo [INFO] Step 2: Cleaning .pyc files... +set "PYC_COUNT=0" + +for /r "%PROJECT_DIR%" %%f in (*.pyc) do ( + if exist "%%f" ( + del /q "%%f" >nul 2>&1 + if not errorlevel 1 ( + set /a PYC_COUNT+=1 + ) + ) +) + +echo [OK] Removed %PYC_COUNT% .pyc files +echo. + +REM ============================================ +REM 3. 清理 data 目录 +REM ============================================ +echo [INFO] Step 3: Cleaning data directory... +if exist "%DATA_DIR%" ( + echo [INFO] Data directory exists, cleaning contents... + + REM 清理数据库文件 + if exist "%DATA_DIR%\*.db" ( + echo [INFO] Removing database files... + del /q "%DATA_DIR%\*.db" >nul 2>&1 + echo [OK] Database files removed + ) + + REM 清理静态文件目录 + if exist "%STATIC_DIR%" ( + echo [INFO] Removing staticfiles directory... + rmdir /s /q "%STATIC_DIR%" >nul 2>&1 + if errorlevel 1 ( + echo [WARNING] Failed to remove staticfiles, may be in use + ) else ( + echo [OK] Staticfiles directory removed + ) + ) + + REM 清理媒体文件目录 + if exist "%MEDIA_DIR%" ( + echo [INFO] Removing media directory... + rmdir /s /q "%MEDIA_DIR%" >nul 2>&1 + if errorlevel 1 ( + echo [WARNING] Failed to remove media, may be in use + ) else ( + echo [OK] Media directory removed + ) + ) + + REM 清理 data 目录下的其他文件(除了目录结构) + for %%f in ("%DATA_DIR%\*") do ( + if not "%%~nxf"=="." if not "%%~nxf"==".." ( + if exist "%%f" ( + if /i "%%~xf"==".db" ( + REM 已处理 + ) else ( + echo [INFO] Removing: %%~nxf + del /q "%%f" >nul 2>&1 + ) + ) + ) + ) + + echo [OK] Data directory cleaned +) else ( + echo [INFO] Data directory does not exist, skipping +) +echo. + +REM ============================================ +REM 4. 清理日志文件 +REM ============================================ +echo [INFO] Step 4: Cleaning log files... +if exist "%LOGS_DIR%" ( + if exist "%LOGS_DIR%\*.log" ( + echo [INFO] Removing log files... + del /q "%LOGS_DIR%\*.log" >nul 2>&1 + echo [OK] Log files removed + ) else ( + echo [INFO] No log files found + ) +) else ( + echo [INFO] Logs directory does not exist, skipping +) +echo. + +REM ============================================ +REM 5. 清理旧的 backend 目录下的生成文件(兼容旧路径) +REM ============================================ +echo [INFO] Step 5: Cleaning old backend directory generated files... +if exist "%BACKEND_DIR%\staticfiles" ( + echo [INFO] Removing old staticfiles from backend directory... + rmdir /s /q "%BACKEND_DIR%\staticfiles" >nul 2>&1 + echo [OK] Old staticfiles removed +) +if exist "%BACKEND_DIR%\media" ( + echo [INFO] Removing old media from backend directory... + rmdir /s /q "%BACKEND_DIR%\media" >nul 2>&1 + echo [OK] Old media removed +) +if exist "%BACKEND_DIR%\db.sqlite3" ( + echo [INFO] Removing old database from backend directory... + del /q "%BACKEND_DIR%\db.sqlite3" >nul 2>&1 + echo [OK] Old database removed +) +echo. + +echo ================================== +echo Cleanup Complete! +echo ================================== +echo. +echo Summary: +echo - __pycache__ directories: %CACHE_COUNT% +echo - .pyc files: %PYC_COUNT% +echo - Data directory: Cleaned +echo - Log files: Cleaned +echo. +echo Note: Virtual environment and .env file are preserved. +echo. +pause + diff --git a/deploy/config.sh b/deploy/config.sh new file mode 100644 index 0000000000000000000000000000000000000000..41611c2682a9c80ff8140e43beb349ef461b1076 --- /dev/null +++ b/deploy/config.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# ============================================ +# 统一配置文件 - 所有配置的唯一来源 +# ============================================ +# +# ⚠️ 重要:这是项目中所有配置的唯一来源! +# +# 如果需要修改以下任何配置: +# - 域名(DOMAIN, BASE_URL) +# - 路径(PROJECT_DIR, DATA_DIR等) +# +# 只需要修改这个文件,其他文件会自动使用这些配置。 +# +# 其他脚本应该通过 source 命令加载此文件: +# source "$SCRIPT_DIR/config.sh" +# +# ============================================ + +# 生产环境域名配置(唯一源) +# ⚠️ 修改域名时,只需修改这两个变量即可 +PROD_DOMAIN="b106.xyz" +PROD_WWW_DOMAIN="www.b106.xyz" + +# 生产环境路径配置 +PROD_PROJECT_DIR="/root/Meal_Architect" +PROD_BACKEND_DIR="$PROD_PROJECT_DIR/backend" +PROD_VENV_DIR="$PROD_PROJECT_DIR/venv" +PROD_DATA_DIR="$PROD_PROJECT_DIR/data" +PROD_LOGS_DIR="$PROD_PROJECT_DIR/logs" +PROD_STATIC_DIR="$PROD_DATA_DIR/staticfiles" +PROD_MEDIA_DIR="$PROD_DATA_DIR/media" +PROD_ENV_FILE="$PROD_PROJECT_DIR/.env" + +# 生产环境其他配置 +PROD_BASE_URL="https://${PROD_DOMAIN}" +PROD_SSL_CERT_PATH="/etc/ssl/certs/${PROD_DOMAIN}" + +# 导出函数:获取生产环境配置 +get_prod_config() { + echo "DOMAIN=${PROD_DOMAIN}" + echo "WWW_DOMAIN=${PROD_WWW_DOMAIN}" + echo "BASE_URL=${PROD_BASE_URL}" + echo "PROJECT_DIR=${PROD_PROJECT_DIR}" + echo "BACKEND_DIR=${PROD_BACKEND_DIR}" + echo "VENV_DIR=${PROD_VENV_DIR}" + echo "DATA_DIR=${PROD_DATA_DIR}" + echo "LOGS_DIR=${PROD_LOGS_DIR}" + echo "STATIC_DIR=${PROD_STATIC_DIR}" + echo "MEDIA_DIR=${PROD_MEDIA_DIR}" + echo "ENV_FILE=${PROD_ENV_FILE}" + echo "SSL_CERT_PATH=${PROD_SSL_CERT_PATH}" +} + +# 如果直接执行此脚本,输出配置信息 +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + get_prod_config +fi + diff --git a/deploy/config/nginx.conf.template b/deploy/config/nginx.conf.template index c1e5553ece136ea14df370e250896ca730bbad20..694fd9a88a8fbcb4ec7b5331f6f0a3c5c866ba74 100644 --- a/deploy/config/nginx.conf.template +++ b/deploy/config/nginx.conf.template @@ -68,9 +68,9 @@ server { proxy_read_timeout 300s; } - # 静态文件 + # 静态文件(使用集中路径配置) location /static/ { - alias {{BACKEND_DIR}}/staticfiles/; + alias {{STATIC_DIR}}/; expires 1y; add_header Cache-Control "public, immutable"; @@ -81,11 +81,48 @@ server { gzip_types text/css application/javascript application/json image/svg+xml; } - # 媒体文件 + # 媒体文件(使用集中路径配置) location /media/ { - alias {{BACKEND_DIR}}/media/; + alias {{MEDIA_DIR}}/; expires 1y; add_header Cache-Control "public"; + + # 禁用目录列表 + autoindex off; + + # 允许访问所有文件类型 + types { + image/jpeg jpg jpeg; + image/png png; + image/gif gif; + image/webp webp; + image/svg+xml svg; + application/pdf pdf; + text/plain txt; + application/octet-stream bin; + } + default_type application/octet-stream; + + # 优化文件传输 + sendfile on; + tcp_nopush on; + tcp_nodelay on; + + # 安全配置:允许跨域访问图片(小程序需要) + add_header Access-Control-Allow-Origin * always; + add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always; + add_header Access-Control-Allow-Headers "Range" always; + + # 处理OPTIONS请求 + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin * always; + add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always; + add_header Access-Control-Allow-Headers "Range" always; + add_header Access-Control-Max-Age 1728000; + add_header Content-Type "text/plain charset=UTF-8"; + add_header Content-Length 0; + return 204; + } } # 根路径代理到Django diff --git a/deploy/env.dev.template b/deploy/env.dev.template index 8dac7485b1cfd8187e6b7d7ff1dcab41207bee5e..6ab20a17101381f26f83d3533b7fcce1b828040f 100644 --- a/deploy/env.dev.template +++ b/deploy/env.dev.template @@ -1,15 +1,15 @@ # Django配置 SECRET_KEY=django-insecure-change-this-in-production DEBUG=True -ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0,172.16.8.41,172.17.149.151 -BASE_URL=http://172.17.149.151:8000 +ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0,172.16.8.1,172.16.8.41 +BASE_URL=http://172.16.8.41:8000 # 数据库配置 # 开发环境建议使用SQLite,生产环境使用PostgreSQL USE_SQLITE=True # SQLite数据库路径配置(相对于项目根目录) -# 将数据库放在与虚拟环境同级的data目录,方便管理和删除 +# 数据库放在项目根目录的data目录中 SQLITE_DB_PATH=data/meal_architect.db # PostgreSQL配置(当USE_SQLITE=False时使用) diff --git a/deploy/env.prod.template b/deploy/env.prod.template index 0f5017b14fb4ecfe6e70554541b3deea9f817feb..90f9cc9c015cbb9c883d061381484d899f665c1e 100644 --- a/deploy/env.prod.template +++ b/deploy/env.prod.template @@ -1,14 +1,16 @@ -# Django生产环境配置 - www.b106.xyz +# Django生产环境配置 +# 注意:以下配置会在安装时自动从统一配置文件(deploy/config.sh)中替换 +# BASE_URL 和 ALLOWED_HOSTS 会在 install.sh 执行时自动填充,请勿手动修改 SECRET_KEY=django-insecure-b106xyz-meal-architect-prod-$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-25) DEBUG=False -ALLOWED_HOSTS=www.b106.xyz,b106.xyz,8.156.83.135,localhost,127.0.0.1 -BASE_URL=https://b106.xyz +ALLOWED_HOSTS={{WWW_DOMAIN}},{{DOMAIN}},8.156.83.135,localhost,127.0.0.1 +BASE_URL={{BASE_URL}} # 数据库配置 - 生产环境使用PostgreSQL USE_SQLITE=False # SQLite数据库路径配置(如果需要使用SQLite的话) -# 将数据库放在与虚拟环境同级的data目录,方便管理和删除 +# 数据库放在项目根目录的data目录中 SQLITE_DB_PATH=data/meal_architect_prod.db # PostgreSQL配置(生产环境推荐) diff --git a/deploy/install-dev.bat b/deploy/install-dev.bat index 9506f1275b3e6869878362acec561283cdc09f41..4382bf25349c9fe754547cae0d8dc68320373889 100644 --- a/deploy/install-dev.bat +++ b/deploy/install-dev.bat @@ -21,12 +21,17 @@ echo Press any key to continue or Ctrl+C to cancel... pause >nul echo. -REM Configuration +REM ============================================ +REM 路径配置 - 集中管理所有路径,便于维护 +REM ============================================ set "PROJECT_DIR=%~dp0.." set "BACKEND_DIR=%PROJECT_DIR%\backend" set "VENV_DIR=%PROJECT_DIR%\venv" set "DATA_DIR=%PROJECT_DIR%\data" set "LOGS_DIR=%PROJECT_DIR%\logs" +set "STATIC_DIR=%DATA_DIR%\staticfiles" +set "MEDIA_DIR=%DATA_DIR%\media" +set "ENV_FILE=%PROJECT_DIR%\.env" echo [INFO] Starting development environment setup... echo. @@ -98,26 +103,43 @@ if exist "%LOGS_DIR%" ( echo [INFO] No existing logs found ) +REM 清理旧的.env文件(兼容旧位置和新位置) if exist "%BACKEND_DIR%\.env" ( - echo [INFO] Removing existing .env file... + echo [INFO] Removing existing .env file from backend directory... del /q "%BACKEND_DIR%\.env" >nul 2>&1 - echo [OK] .env file removed + echo [OK] .env file removed from backend directory +) +if exist "%ENV_FILE%" ( + echo [INFO] Removing existing .env file from project root... + del /q "%ENV_FILE%" >nul 2>&1 + echo [OK] .env file removed from project root ) else ( echo [INFO] No existing .env file found ) +REM 清理旧的静态文件和媒体文件(兼容旧位置和新位置) if exist "%BACKEND_DIR%\staticfiles" ( - echo [INFO] Removing existing static files directory... + echo [INFO] Removing existing static files directory from backend... rmdir /s /q "%BACKEND_DIR%\staticfiles" >nul 2>&1 - echo [OK] Static files directory removed + echo [OK] Static files directory removed from backend +) +if exist "%STATIC_DIR%" ( + echo [INFO] Removing existing static files from data directory... + rmdir /s /q "%STATIC_DIR%" >nul 2>&1 + echo [OK] Static files removed from data directory ) else ( echo [INFO] No existing static files directory found ) if exist "%BACKEND_DIR%\media" ( - echo [INFO] Removing existing media files directory... + echo [INFO] Removing existing media files directory from backend... rmdir /s /q "%BACKEND_DIR%\media" >nul 2>&1 - echo [OK] Media files directory removed + echo [OK] Media files directory removed from backend +) +if exist "%MEDIA_DIR%" ( + echo [INFO] Removing existing media files from data directory... + rmdir /s /q "%MEDIA_DIR%" >nul 2>&1 + echo [OK] Media files removed from data directory ) else ( echo [INFO] No existing media files directory found ) @@ -133,6 +155,8 @@ echo. echo [INFO] Creating project directories... if not exist "%DATA_DIR%" mkdir "%DATA_DIR%" if not exist "%LOGS_DIR%" mkdir "%LOGS_DIR%" +if not exist "%STATIC_DIR%" mkdir "%STATIC_DIR%" +if not exist "%MEDIA_DIR%" mkdir "%MEDIA_DIR%" echo. > "%LOGS_DIR%\meal_architect.log" echo. > "%LOGS_DIR%\meal_architect_error.log" echo [OK] Directories created @@ -175,13 +199,15 @@ if errorlevel 1 ( REM Install dependencies echo. echo [INFO] Installing Python dependencies... -if not exist "%BACKEND_DIR%\requirements.txt" ( - echo [ERROR] requirements.txt not found: %BACKEND_DIR%\requirements.txt +REM requirements.txt 在 deploy 目录中 +set "REQUIREMENTS_FILE=%~dp0requirements.txt" +if not exist "%REQUIREMENTS_FILE%" ( + echo [ERROR] requirements.txt not found: %REQUIREMENTS_FILE% pause exit /b 1 ) -pip install -r "%BACKEND_DIR%\requirements.txt" -i https://pypi.tuna.tsinghua.edu.cn/simple/ +pip install -r "%REQUIREMENTS_FILE%" -i https://pypi.tuna.tsinghua.edu.cn/simple/ if errorlevel 1 ( echo [ERROR] Failed to install dependencies pause @@ -192,16 +218,17 @@ echo [OK] Dependencies installed REM Setup environment configuration echo. echo [INFO] Setting up environment configuration... -if exist "%BACKEND_DIR%\.env" ( - echo [INFO] .env file already exists +REM .env文件放在项目根目录,不在backend目录中 +if exist "%ENV_FILE%" ( + echo [INFO] .env file already exists: %ENV_FILE% ) else ( if not exist "%PROJECT_DIR%\deploy\env.dev.template" ( echo [ERROR] Environment template not found: %PROJECT_DIR%\deploy\env.dev.template pause exit /b 1 ) - copy "%PROJECT_DIR%\deploy\env.dev.template" "%BACKEND_DIR%\.env" >nul - echo [OK] Environment configuration created + copy "%PROJECT_DIR%\deploy\env.dev.template" "%ENV_FILE%" >nul + echo [OK] Environment configuration created: %ENV_FILE% ) REM Initialize Django diff --git a/deploy/install.sh b/deploy/install.sh index 9d76c2eef6ca491a0732f63cfa095de56976d0ee..c53584565d1906294bdc3f95821ca0a8864614cc 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1,8 +1,7 @@ #!/bin/bash -# Meal Architect 环境安装脚本 -# 支持开发环境和生产环境的一键部署 -# 参考scripts/install.sh的最佳实践 +# Meal Architect 生产环境安装脚本 +# 一键部署生产环境,自动配置所有必需组件 set -e @@ -10,6 +9,15 @@ set -e SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +# 加载统一配置文件(唯一配置源) +CONFIG_FILE="$SCRIPT_DIR/config.sh" +if [ -f "$CONFIG_FILE" ]; then + source "$CONFIG_FILE" +else + echo "错误: 配置文件不存在: $CONFIG_FILE" + exit 1 +fi + # 颜色定义 RED='\033[0;31m' GREEN='\033[0;32m' @@ -17,19 +25,28 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' -# 配置变量 +# ============================================ +# 路径配置 - 从统一配置文件加载 +# ============================================ PROJECT_NAME="meal_architect" -PROJECT_DIR="/root/Meal_Architect" -BACKEND_DIR="$PROJECT_DIR/backend" -VENV_DIR="$PROJECT_DIR/venv" -# 生产环境配置 -DOMAIN="b106.xyz" -WWW_DOMAIN="www.b106.xyz" +# 使用生产环境路径(从 config.sh 加载) +DOMAIN="$PROD_DOMAIN" +WWW_DOMAIN="$PROD_WWW_DOMAIN" +PROJECT_DIR="$PROD_PROJECT_DIR" +BACKEND_DIR="$PROD_BACKEND_DIR" +VENV_DIR="$PROD_VENV_DIR" +DATA_DIR="$PROD_DATA_DIR" +LOGS_DIR="$PROD_LOGS_DIR" +STATIC_DIR="$PROD_STATIC_DIR" +MEDIA_DIR="$PROD_MEDIA_DIR" +ENV_FILE="$PROD_ENV_FILE" +SSL_CERT_PATH="$PROD_SSL_CERT_PATH" + +# Nginx 配置路径(基于DOMAIN) NGINX_CONF="/etc/nginx/sites-available/$DOMAIN" NGINX_ENABLED="/etc/nginx/sites-enabled/$DOMAIN" -SSL_CERT_PATH="/etc/ssl/certs/$DOMAIN" -ZIP_FILE="$SCRIPT_DIR/www.b106.xyz_nginx.zip" +ZIP_FILE="$SCRIPT_DIR/www.${DOMAIN}_nginx.zip" # 日志函数 log_info() { @@ -50,17 +67,64 @@ log_error() { # 检查命令执行结果 check_result() { - if [ $? -ne 0 ]; then - log_error "$1 failed" + local exit_code=$? + if [ $exit_code -ne 0 ]; then + log_error "$1 failed (退出码: $exit_code)" exit 1 fi } -# 检查是否为root用户(生产环境需要) +# 检查必需的工具 +check_required_tools() { + log_info "检查必需的系统工具..." + local missing_tools=() + + for tool in python3 pip3 systemctl nginx psql; do + if ! command -v $tool &> /dev/null; then + missing_tools+=($tool) + fi + done + + if [ ${#missing_tools[@]} -gt 0 ]; then + log_error "缺少必需的工具: ${missing_tools[*]}" + log_info "正在安装..." + return 1 + fi + + log_success "系统工具检查完成" + return 0 +} + +# 验证环境配置 +verify_environment() { + log_info "验证环境配置..." + + # 检查.env文件 + if [ ! -f "$ENV_FILE" ]; then + log_error ".env文件不存在: $ENV_FILE" + return 1 + fi + + # 检查关键配置项 + if ! grep -q "SECRET_KEY" "$ENV_FILE"; then + log_error ".env文件中缺少SECRET_KEY配置" + return 1 + fi + + # 检查数据库配置 + if ! grep -q "DB_NAME" "$ENV_FILE"; then + log_warning ".env文件中缺少数据库配置,将使用默认配置" + fi + + log_success "环境配置验证完成" + return 0 +} + +# 检查是否为root用户 check_root() { if [ "$EUID" -ne 0 ]; then - log_error "生产环境需要root权限运行此脚本" - log_info "使用命令: sudo $0 prod" + log_error "此脚本需要root权限运行" + log_info "使用命令: sudo $0" exit 1 fi } @@ -96,18 +160,33 @@ install_base_deps() { log_success "基础系统依赖安装完成" } +# 安装中文字体(用于PDF生成) +install_chinese_fonts() { + log_info "安装中文字体(用于PDF生成)..." + + wait_for_apt + + # 安装文泉驿字体包 + apt install -y fonts-wqy-zenhei fonts-wqy-microhei + check_result "chinese fonts installation" + + # 刷新字体缓存 + fc-cache -fv + + # 验证字体安装 + if fc-list :lang=zh | grep -q -i "wqy"; then + log_success "中文字体安装成功" + log_info "已安装字体: $(fc-list :lang=zh | grep -i wqy | head -2 | tr '\n' ' ')" + else + log_warning "中文字体安装可能失败,但继续执行" + fi +} + # 创建项目目录和虚拟环境 setup_project_env() { - local env_type=$1 - log_info "设置项目环境 ($env_type)..." + log_info "设置项目环境..." # 确保项目目录存在 - if [ "$env_type" = "dev" ]; then - PROJECT_DIR="$HOME/Meal_Architect" - BACKEND_DIR="$PROJECT_DIR/backend" - VENV_DIR="$PROJECT_DIR/venv" - fi - mkdir -p "$PROJECT_DIR" cd "$PROJECT_DIR" @@ -132,64 +211,64 @@ setup_project_env() { # 安装Python依赖 install_python_deps() { - local env_type=$1 - log_info "安装Python依赖 ($env_type)..." + log_info "安装Python依赖..." source "$VENV_DIR/bin/activate" - # 安装基础依赖 - if [ -f "$BACKEND_DIR/requirements.txt" ]; then - pip install -r "$BACKEND_DIR/requirements.txt" -i https://pypi.tuna.tsinghua.edu.cn/simple/ + # 安装基础依赖(requirements.txt 在 deploy 目录中) + REQUIREMENTS_FILE="$SCRIPT_DIR/requirements.txt" + if [ -f "$REQUIREMENTS_FILE" ]; then + pip install -r "$REQUIREMENTS_FILE" -i https://pypi.tuna.tsinghua.edu.cn/simple/ check_result "requirements installation" else - log_warning "requirements.txt不存在,跳过依赖安装" + log_warning "requirements.txt不存在: $REQUIREMENTS_FILE,跳过依赖安装" fi - # 生产环境安装额外依赖 - if [ "$env_type" = "prod" ]; then - pip install gunicorn whitenoise redis celery django-redis \ - sentry-sdk[django] django-ratelimit django-extensions \ - -i https://pypi.tuna.tsinghua.edu.cn/simple/ - check_result "production dependencies installation" - fi + # 安装生产环境额外依赖 + pip install gunicorn whitenoise redis celery django-redis \ + sentry-sdk[django] django-ratelimit django-extensions \ + -i https://pypi.tuna.tsinghua.edu.cn/simple/ + check_result "production dependencies installation" log_success "Python依赖安装完成" } # 配置环境变量 setup_env_config() { - local env_type=$1 - log_info "配置环境变量 ($env_type)..." + log_info "配置环境变量..." - # 选择配置模板 - if [ "$env_type" = "prod" ]; then - ENV_TEMPLATE="$SCRIPT_DIR/env.prod.template" - else - ENV_TEMPLATE="$SCRIPT_DIR/env.dev.template" - fi + # 使用生产环境配置模板 + ENV_TEMPLATE="$SCRIPT_DIR/env.prod.template" - # 创建.env文件 - if [ ! -f "$BACKEND_DIR/.env" ]; then + # 创建.env文件(放在项目根目录,不在backend目录中) + if [ ! -f "$ENV_FILE" ]; then if [ -f "$ENV_TEMPLATE" ]; then - cp "$ENV_TEMPLATE" "$BACKEND_DIR/.env" - log_info "已从模板创建.env文件" + cp "$ENV_TEMPLATE" "$ENV_FILE" + log_info "已从模板创建.env文件: $ENV_FILE" - # 生产环境需要替换敏感信息 - if [ "$env_type" = "prod" ]; then - # 替换数据库密码 - sed -i "s/your_strong_password_here/MealArchitect2024!/g" "$BACKEND_DIR/.env" - - # 生成随机SECRET_KEY - SECRET_KEY=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-50) - sed -i "s/django-insecure-b106xyz-meal-architect-prod-.*/django-insecure-b106xyz-meal-architect-prod-$SECRET_KEY/g" "$BACKEND_DIR/.env" - fi + # 从统一配置文件替换所有配置项(支持占位符和直接替换) + # BASE_URL 从统一配置读取 + sed -i "s|BASE_URL={{BASE_URL}}|BASE_URL=${PROD_BASE_URL}|g" "$ENV_FILE" + sed -i "s|BASE_URL=.*|BASE_URL=${PROD_BASE_URL}|g" "$ENV_FILE" + + # ALLOWED_HOSTS 从统一配置读取 + sed -i "s|ALLOWED_HOSTS={{WWW_DOMAIN}},{{DOMAIN}}|ALLOWED_HOSTS=${WWW_DOMAIN},${DOMAIN}|g" "$ENV_FILE" + sed -i "s|ALLOWED_HOSTS=.*|ALLOWED_HOSTS=${WWW_DOMAIN},${DOMAIN},8.156.83.135,localhost,127.0.0.1|g" "$ENV_FILE" + + # 替换数据库密码 + sed -i "s/your_strong_password_here/MealArchitect2024!/g" "$ENV_FILE" + + # 生成随机SECRET_KEY + SECRET_KEY=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-50) + sed -i "s/django-insecure-b106xyz-meal-architect-prod-.*/django-insecure-b106xyz-meal-architect-prod-$SECRET_KEY/g" "$ENV_FILE" + + log_info "已从统一配置自动替换 BASE_URL 和 DOMAIN" else log_error "环境配置模板不存在: $ENV_TEMPLATE" exit 1 fi else - log_info ".env文件已存在,跳过创建" - log_warning "如果需要修复配置问题,请运行: $0 fix" + log_info ".env文件已存在,跳过创建: $ENV_FILE" fi log_success "环境配置完成" @@ -197,33 +276,56 @@ setup_env_config() { # 创建项目目录结构 create_project_directories() { - local env_type=$1 log_info "创建项目目录结构..." - # 创建日志目录 - mkdir -p "$PROJECT_DIR/logs" + # 创建日志目录(放在项目根目录) + mkdir -p "$LOGS_DIR" - # 创建数据目录(用于存放SQLite数据库等数据文件) - mkdir -p "$PROJECT_DIR/data" + # 创建数据目录(用于存放数据库、静态文件、媒体文件等) + mkdir -p "$DATA_DIR" - # 设置权限 - if [ "$env_type" = "prod" ]; then - chown -R root:root "$PROJECT_DIR/logs" "$PROJECT_DIR/data" - else - chown -R $USER:$USER "$PROJECT_DIR/logs" "$PROJECT_DIR/data" + # 创建静态文件和媒体文件目录 + mkdir -p "$STATIC_DIR" + mkdir -p "$MEDIA_DIR" + + # 日志目录:root所有 + chown -R root:root "$LOGS_DIR" + chmod 755 "$LOGS_DIR" + + # 确保www-data组存在 + if ! getent group www-data >/dev/null 2>&1; then + log_warning "www-data组不存在,创建中..." + groupadd www-data 2>/dev/null || true fi - chmod -R 755 "$PROJECT_DIR/logs" "$PROJECT_DIR/data" - # 创建日志文件 - touch "$PROJECT_DIR/logs/meal_architect.log" - touch "$PROJECT_DIR/logs/meal_architect_error.log" + # 媒体文件目录需要nginx可读,设置为www-data + # 设置setgid位,确保新创建的文件自动继承www-data组 + if [ -d "$MEDIA_DIR" ]; then + chown -R www-data:www-data "$MEDIA_DIR" 2>/dev/null || true + chmod -R 2775 "$MEDIA_DIR" 2>/dev/null || true # 2755 = setgid + 775 + log_info "媒体文件目录权限已设置(包含setgid位)" + fi - if [ "$env_type" = "prod" ]; then - touch "$PROJECT_DIR/logs/nginx_access.log" - touch "$PROJECT_DIR/logs/nginx_error.log" + # 静态文件目录也需要nginx可读 + if [ -d "$STATIC_DIR" ]; then + chown -R www-data:www-data "$STATIC_DIR" 2>/dev/null || true + chmod -R 755 "$STATIC_DIR" 2>/dev/null || true fi - chmod 644 "$PROJECT_DIR/logs"/*.log + # data目录本身保持root所有,但需要www-data组可执行 + chown root:www-data "$DATA_DIR" 2>/dev/null || chown root:root "$DATA_DIR" + chmod 755 "$DATA_DIR" + + # 创建日志文件 + touch "$LOGS_DIR/meal_architect.log" + touch "$LOGS_DIR/meal_architect_error.log" + touch "$LOGS_DIR/nginx_access.log" + touch "$LOGS_DIR/nginx_error.log" + touch "$LOGS_DIR/gunicorn_access.log" + touch "$LOGS_DIR/gunicorn_error.log" + + # 设置日志文件权限 + chmod 644 "$LOGS_DIR"/*.log 2>/dev/null || true log_success "项目目录结构创建完成" } @@ -231,72 +333,140 @@ create_project_directories() { # 初始化Django应用 init_django() { - local env_type=$1 - log_info "初始化Django应用 ($env_type)..." + log_info "初始化Django应用..." cd "$BACKEND_DIR" source "$VENV_DIR/bin/activate" # 数据库迁移 python manage.py makemigrations - python manage.py migrate - check_result "database migration" - # 清理旧的静态文件 + # 智能迁移:处理已存在的数据库 + log_info "执行数据库迁移..." + echo "" # 空行,让输出更清晰 + + # 迁移重试次数 + max_retries=5 + retry_count=0 + + while [ $retry_count -lt $max_retries ]; do + if [ $retry_count -gt 0 ]; then + log_info "第 $retry_count 次重试迁移..." + fi + + # 执行迁移并实时显示输出,同时捕获到变量 + # 使用临时文件来捕获输出 + temp_output=$(mktemp) + python manage.py migrate 2>&1 | tee "$temp_output" + migrate_status=${PIPESTATUS[0]} + migrate_output=$(cat "$temp_output") + rm -f "$temp_output" + + if [ $migrate_status -eq 0 ]; then + echo "" # 空行 + log_success "数据库迁移完成" + break + fi + + # 检查是否是表已存在的错误 + if echo "$migrate_output" | grep -qE "relation.*already exists|DuplicateTable|relation.*does not exist.*already exists"; then + log_warning "检测到表已存在但迁移记录缺失,尝试修复..." + + # 从错误信息中提取迁移名称(格式:app_name.000X_migration_name) + # 错误信息格式通常是:Applying app_name.000X_migration_name... + # 或者从错误堆栈中提取:File ".../migrations/000X_migration_name.py" + failed_migration=$(echo "$migrate_output" | grep -oE "Applying [a-z_]+\.000[0-9]+_[a-z_]+" | head -1 | sed 's/Applying //') + + # 如果第一种方法没找到,尝试从堆栈跟踪中提取 + if [ -z "$failed_migration" ]; then + migration_path=$(echo "$migrate_output" | grep -oE "[a-z_]+/migrations/000[0-9]+_[a-z_]+\.py" | head -1) + if [ -n "$migration_path" ]; then + app_name=$(echo "$migration_path" | sed 's|/migrations/.*||') + migration_name=$(echo "$migration_path" | sed 's|.*migrations/||' | sed 's|\.py||') + failed_migration="${app_name}.${migration_name}" + fi + fi + + if [ -n "$failed_migration" ]; then + app_name=$(echo "$failed_migration" | cut -d'.' -f1) + migration_name=$(echo "$failed_migration" | cut -d'.' -f2) + + log_info "检测到失败的迁移: $failed_migration,尝试标记为已应用..." + + # 使用 --fake 标记该迁移为已应用 + if python manage.py migrate --fake "$app_name" "$migration_name" 2>/dev/null; then + log_success "迁移 $failed_migration 已标记为已应用" + retry_count=$((retry_count + 1)) + continue + else + log_warning "无法标记迁移 $failed_migration,尝试使用 --fake-initial..." + # 如果单独fake失败,尝试对整个app使用 --fake-initial + python manage.py migrate --fake-initial "$app_name" 2>/dev/null || true + retry_count=$((retry_count + 1)) + continue + fi + else + # 如果无法提取迁移名称,尝试使用 --fake-initial + log_warning "无法提取迁移名称,尝试使用 --fake-initial..." + python manage.py migrate --fake-initial 2>/dev/null || true + retry_count=$((retry_count + 1)) + continue + fi + else + # 其他错误,输出错误信息并退出 + log_error "数据库迁移失败" + echo "$migrate_output" + exit 1 + fi + done + + # 如果达到最大重试次数仍未成功,最后尝试一次正常迁移 + if [ $retry_count -ge $max_retries ]; then + log_warning "达到最大重试次数,最后尝试一次迁移..." + python manage.py migrate + check_result "final database migration" + fi + + # 清理旧的静态文件(兼容旧路径) log_info "清理旧的静态文件..." if [ -d "$BACKEND_DIR/staticfiles" ]; then rm -rf "$BACKEND_DIR/staticfiles" log_success "旧的静态文件已清理" + fi + if [ -d "$STATIC_DIR" ]; then + rm -rf "$STATIC_DIR"/* + log_info "已清理新路径的静态文件目录" else - log_info "没有找到旧的静态文件目录" + mkdir -p "$STATIC_DIR" + log_info "创建静态文件目录: $STATIC_DIR" fi - # 收集静态文件 + # 收集静态文件(会输出到 data/staticfiles) python manage.py collectstatic --noinput check_result "static files collection" - # 生产环境创建超级用户 - if [ "$env_type" = "prod" ]; then - echo "from django.contrib.auth import get_user_model; User = get_user_model(); User.objects.create_superuser(openid='admin_openid', nickname='admin', is_staff=True, is_superuser=True) if not User.objects.filter(openid='admin_openid').exists() else None" | python manage.py shell + # 设置静态文件和媒体文件权限 + # 修复静态文件权限 + if [ -d "$STATIC_DIR" ]; then + chown -R www-data:www-data "$STATIC_DIR" + find "$STATIC_DIR" -type d -exec chmod 755 {} \; 2>/dev/null || true + find "$STATIC_DIR" -type f -exec chmod 644 {} \; 2>/dev/null || true fi - cd "$PROJECT_DIR" - log_success "Django应用初始化完成" -} - -# 安装开发环境 -install_dev() { - log_info "安装开发环境..." - - # 设置项目目录为用户目录 - PROJECT_DIR="$HOME/Meal_Architect" - BACKEND_DIR="$PROJECT_DIR/backend" - VENV_DIR="$PROJECT_DIR/venv" + # 确保媒体文件目录存在且权限正确 + mkdir -p "$MEDIA_DIR" + chown -R www-data:www-data "$MEDIA_DIR" + find "$MEDIA_DIR" -type d -exec chmod 775 {} \; 2>/dev/null || true + find "$MEDIA_DIR" -type f -exec chmod 664 {} \; 2>/dev/null || true - install_base_deps - setup_project_env "dev" - install_python_deps "dev" - setup_env_config "dev" - create_project_directories "dev" - init_django "dev" + # 创建超级用户 + echo "from django.contrib.auth import get_user_model; User = get_user_model(); User.objects.create_superuser(openid='admin_openid', nickname='admin', is_staff=True, is_superuser=True) if not User.objects.filter(openid='admin_openid').exists() else None" | python manage.py shell - log_success "开发环境安装完成!" - echo "" - echo "==================================" - echo " 开发环境安装完成" - echo "==================================" - echo "" - echo "项目目录: $PROJECT_DIR" - echo "虚拟环境: $VENV_DIR" - echo "启动命令: cd $BACKEND_DIR && source $VENV_DIR/bin/activate && python manage.py runserver" - echo "" - echo "或使用管理脚本:" - echo " ./deploy/start.sh dev # 启动开发服务器" - echo " ./deploy/start.sh dev 8080 # 在指定端口启动" - echo "" + cd "$PROJECT_DIR" + log_success "Django应用初始化完成" } -# 配置PostgreSQL数据库(生产环境) +# 配置PostgreSQL数据库 setup_database() { log_info "配置PostgreSQL数据库..." @@ -423,6 +593,8 @@ setup_nginx() { sed -i "s|{{SSL_CERT_PATH}}|$SSL_CERT_PATH|g" "$NGINX_CONF" sed -i "s|{{BACKEND_DIR}}|$BACKEND_DIR|g" "$NGINX_CONF" sed -i "s|{{PROJECT_DIR}}|$PROJECT_DIR|g" "$NGINX_CONF" + sed -i "s|{{STATIC_DIR}}|$STATIC_DIR|g" "$NGINX_CONF" + sed -i "s|{{MEDIA_DIR}}|$MEDIA_DIR|g" "$NGINX_CONF" else log_info "使用内置配置创建Nginx配置..." @@ -442,7 +614,12 @@ server { ssl_certificate_key $SSL_CERT_PATH/privkey.pem; location /static/ { - alias $BACKEND_DIR/staticfiles/; + alias $STATIC_DIR/; + expires 1y; + } + + location /media/ { + alias $MEDIA_DIR/; expires 1y; } @@ -468,10 +645,227 @@ EOF log_success "Nginx配置完成" } +# 清理旧进程和端口占用(仅在启动前调用,不会停止systemd服务) +cleanup_old_processes() { + log_info "清理旧的Django进程和端口占用..." + + local cleaned=false + local stop_service=${1:-false} # 第一个参数:是否停止systemd服务 + + # 1. 停止systemd服务(仅在明确要求时) + if [ "$stop_service" = "true" ]; then + if systemctl list-units --full -a | grep -q "meal-architect.service"; then + if systemctl is-active --quiet meal-architect 2>/dev/null; then + log_info "停止现有systemd服务..." + systemctl stop meal-architect 2>/dev/null || true + sleep 3 + cleaned=true + fi + fi + fi + + # 2. 查找并杀死所有gunicorn进程(排除systemd管理的进程) + # 如果systemd服务正在运行,获取其主进程PID和进程组 + SYSTEMD_MAIN_PID="" + SYSTEMD_PGID="" + if systemctl list-units --full -a | grep -q "meal-architect.service"; then + if systemctl is-active --quiet meal-architect 2>/dev/null; then + SYSTEMD_MAIN_PID=$(systemctl show meal-architect.service -p MainPID --value 2>/dev/null || echo "") + if [ -n "$SYSTEMD_MAIN_PID" ] && [ "$SYSTEMD_MAIN_PID" != "0" ]; then + SYSTEMD_PGID=$(ps -o pgid= -p "$SYSTEMD_MAIN_PID" 2>/dev/null | tr -d ' ' || echo "") + fi + fi + fi + + GUNICORN_PIDS=$(pgrep -f "gunicorn.*meal_architect" 2>/dev/null || true) + if [ -n "$GUNICORN_PIDS" ]; then + # 只清理非systemd管理的进程 + CLEANED_PIDS=() + SKIPPED_COUNT=0 + for pid in $GUNICORN_PIDS; do + SKIP_PID=false + + # 检查是否是systemd服务的主进程 + if [ -n "$SYSTEMD_MAIN_PID" ] && [ "$pid" = "$SYSTEMD_MAIN_PID" ]; then + SKIP_PID=true + fi + + # 检查是否属于systemd服务的进程组 + if [ -n "$SYSTEMD_PGID" ] && [ "$SKIP_PID" = false ]; then + PID_PGID=$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ' || echo "") + if [ -n "$PID_PGID" ] && [ "$PID_PGID" = "$SYSTEMD_PGID" ]; then + SKIP_PID=true + fi + fi + + if [ "$SKIP_PID" = true ]; then + SKIPPED_COUNT=$((SKIPPED_COUNT + 1)) + continue + fi + + CLEANED_PIDS+=($pid) + kill -9 "$pid" 2>/dev/null || true + cleaned=true + done + + if [ ${#CLEANED_PIDS[@]} -gt 0 ]; then + log_warning "清理了 ${#CLEANED_PIDS[@]} 个旧的gunicorn进程 (PIDs: ${CLEANED_PIDS[*]})" + sleep 2 + fi + if [ $SKIPPED_COUNT -gt 0 ]; then + log_info "跳过了 $SKIPPED_COUNT 个systemd管理的进程" + fi + fi + + # 3. 检查8000端口是否被占用(排除systemd管理的进程) + PORT_PIDS=$(lsof -ti :8000 2>/dev/null || true) + if [ -n "$PORT_PIDS" ]; then + # 获取systemd服务的主进程PID和进程组(如果服务正在运行) + SYSTEMD_MAIN_PID="" + SYSTEMD_PGID="" + if systemctl list-units --full -a | grep -q "meal-architect.service"; then + if systemctl is-active --quiet meal-architect 2>/dev/null; then + SYSTEMD_MAIN_PID=$(systemctl show meal-architect.service -p MainPID --value 2>/dev/null || echo "") + if [ -n "$SYSTEMD_MAIN_PID" ] && [ "$SYSTEMD_MAIN_PID" != "0" ]; then + SYSTEMD_PGID=$(ps -o pgid= -p "$SYSTEMD_MAIN_PID" 2>/dev/null | tr -d ' ' || echo "") + fi + fi + fi + + # 只清理非systemd管理的进程 + PORT_CLEANED_PIDS=() + PORT_SKIPPED_COUNT=0 + for pid in $PORT_PIDS; do + SKIP_PID=false + + # 检查是否是systemd服务的主进程 + if [ -n "$SYSTEMD_MAIN_PID" ] && [ "$pid" = "$SYSTEMD_MAIN_PID" ]; then + SKIP_PID=true + fi + + # 检查是否属于systemd服务的进程组 + if [ -n "$SYSTEMD_PGID" ] && [ "$SKIP_PID" = false ]; then + PID_PGID=$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ' || echo "") + if [ -n "$PID_PGID" ] && [ "$PID_PGID" = "$SYSTEMD_PGID" ]; then + SKIP_PID=true + fi + fi + + if [ "$SKIP_PID" = true ]; then + PORT_SKIPPED_COUNT=$((PORT_SKIPPED_COUNT + 1)) + continue + fi + + PORT_CLEANED_PIDS+=($pid) + kill -9 "$pid" 2>/dev/null || true + done + + if [ ${#PORT_CLEANED_PIDS[@]} -gt 0 ]; then + log_warning "清理了 ${#PORT_CLEANED_PIDS[@]} 个占用8000端口的进程 (PIDs: ${PORT_CLEANED_PIDS[*]})" + sleep 2 + cleaned=true + fi + + if [ $PORT_SKIPPED_COUNT -gt 0 ]; then + log_info "跳过了 $PORT_SKIPPED_COUNT 个systemd管理的端口占用进程" + fi + + # 如果还有残留的非systemd进程,使用fuser强制清理 + if [ ${#PORT_CLEANED_PIDS[@]} -gt 0 ]; then + REMAINING_PIDS=$(lsof -ti :8000 2>/dev/null || true) + if [ -n "$REMAINING_PIDS" ]; then + # 检查是否还有非systemd进程 + HAS_NON_SYSTEMD=false + for pid in $REMAINING_PIDS; do + SKIP_PID=false + if [ -n "$SYSTEMD_MAIN_PID" ] && [ "$pid" = "$SYSTEMD_MAIN_PID" ]; then + SKIP_PID=true + fi + if [ -n "$SYSTEMD_PGID" ] && [ "$SKIP_PID" = false ]; then + PID_PGID=$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ' || echo "") + if [ -n "$PID_PGID" ] && [ "$PID_PGID" = "$SYSTEMD_PGID" ]; then + SKIP_PID=true + fi + fi + if [ "$SKIP_PID" = false ]; then + HAS_NON_SYSTEMD=true + break + fi + done + + if [ "$HAS_NON_SYSTEMD" = true ]; then + log_warning "使用fuser强制清理残留进程..." + fuser -k 8000/tcp 2>/dev/null || true + sleep 2 + fi + fi + fi + fi + + # 4. 确认端口已释放(允许systemd管理的进程占用) + REMAINING_PIDS=$(lsof -ti :8000 2>/dev/null || true) + if [ -n "$REMAINING_PIDS" ]; then + # 获取systemd服务的主进程PID和进程组(如果服务正在运行) + SYSTEMD_MAIN_PID="" + SYSTEMD_PGID="" + if systemctl list-units --full -a | grep -q "meal-architect.service"; then + if systemctl is-active --quiet meal-architect 2>/dev/null; then + SYSTEMD_MAIN_PID=$(systemctl show meal-architect.service -p MainPID --value 2>/dev/null || echo "") + if [ -n "$SYSTEMD_MAIN_PID" ] && [ "$SYSTEMD_MAIN_PID" != "0" ]; then + SYSTEMD_PGID=$(ps -o pgid= -p "$SYSTEMD_MAIN_PID" 2>/dev/null | tr -d ' ' || echo "") + fi + fi + fi + + # 检查是否只剩下systemd管理的进程 + ONLY_SYSTEMD=true + for pid in $REMAINING_PIDS; do + SKIP_PID=false + if [ -n "$SYSTEMD_MAIN_PID" ] && [ "$pid" = "$SYSTEMD_MAIN_PID" ]; then + SKIP_PID=true + fi + if [ -n "$SYSTEMD_PGID" ] && [ "$SKIP_PID" = false ]; then + PID_PGID=$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ' || echo "") + if [ -n "$PID_PGID" ] && [ "$PID_PGID" = "$SYSTEMD_PGID" ]; then + SKIP_PID=true + fi + fi + if [ "$SKIP_PID" = false ]; then + ONLY_SYSTEMD=false + break + fi + done + + if [ "$ONLY_SYSTEMD" = true ] && [ -n "$SYSTEMD_MAIN_PID" ]; then + log_info "8000端口被systemd管理的服务正常占用 (主进程PID: $SYSTEMD_MAIN_PID)" + else + log_warning "8000端口仍被非systemd进程占用,可能需要手动检查" + fi + else + if [ "$cleaned" = true ]; then + log_success "端口8000已释放" + else + log_info "端口8000未被占用" + fi + fi +} + # 配置systemd服务 setup_systemd_service() { log_info "配置systemd服务..." + # 清理旧进程(在配置服务之前,停止systemd服务并彻底清理) + log_info "停止旧服务并清理旧进程..." + cleanup_old_processes true + + # 确保端口完全释放(等待一段时间) + local wait_count=0 + while [ $wait_count -lt 5 ] && lsof -i :8000 >/dev/null 2>&1; do + log_info "等待端口8000完全释放... ($((wait_count+1))/5)" + sleep 1 + wait_count=$((wait_count + 1)) + done + # 创建systemd服务文件 cat > /etc/systemd/system/meal-architect.service << EOF [Unit] @@ -485,11 +879,13 @@ User=root Group=root WorkingDirectory=$BACKEND_DIR Environment=PATH=$VENV_DIR/bin -EnvironmentFile=$BACKEND_DIR/.env -ExecStart=$VENV_DIR/bin/gunicorn meal_architect.wsgi:application --bind 127.0.0.1:8000 --workers 4 --timeout 60 --keep-alive 2 --max-requests 1000 --max-requests-jitter 100 +EnvironmentFile=$ENV_FILE +ExecStart=$VENV_DIR/bin/gunicorn meal_architect.wsgi:application --bind 127.0.0.1:8000 --workers 4 --timeout 60 --keep-alive 2 --max-requests 1000 --max-requests-jitter 100 --access-logfile $LOGS_DIR/gunicorn_access.log --error-logfile $LOGS_DIR/gunicorn_error.log --log-level info ExecReload=/bin/kill -s HUP \$MAINPID -Restart=always -RestartSec=3 +Restart=on-failure +RestartSec=10 +StartLimitBurst=3 +StartLimitInterval=60 StandardOutput=journal StandardError=journal SyslogIdentifier=meal-architect @@ -505,93 +901,151 @@ EOF log_success "systemd服务配置完成" } +# 配置Supervisor(可选,当前使用systemd) +setup_supervisor() { + log_info "配置Supervisor..." + + # 安装Supervisor + apt install -y supervisor + systemctl enable supervisor + + # 检查是否有配置模板 + if [ -f "$SCRIPT_DIR/config/supervisor.conf.template" ]; then + log_info "使用配置模板创建Supervisor配置..." + + # Supervisor配置文件路径 + SUPERVISOR_CONF="/etc/supervisor/conf.d/meal_architect.conf" + + # 使用模板创建配置文件 + cp "$SCRIPT_DIR/config/supervisor.conf.template" "$SUPERVISOR_CONF" + + # 替换模板变量(从统一配置源读取) + sed -i "s|{{VENV_DIR}}|$VENV_DIR|g" "$SUPERVISOR_CONF" + sed -i "s|{{BACKEND_DIR}}|$BACKEND_DIR|g" "$SUPERVISOR_CONF" + sed -i "s|{{PROJECT_DIR}}|$PROJECT_DIR|g" "$SUPERVISOR_CONF" + + # 重新加载Supervisor配置 + supervisorctl reread + supervisorctl update + + log_success "Supervisor配置完成" + else + log_warning "Supervisor配置模板不存在,跳过Supervisor配置" + fi +} + # 启动并验证服务 start_and_verify_service() { log_info "启动并验证Django服务..." + # 启动前简单检查端口状态(在setup_systemd_service中已经清理过了) + # 如果端口仍被占用,可能是残留进程,进行一次性清理 + if lsof -i :8000 >/dev/null 2>&1; then + log_warning "检测到8000端口被占用,清理残留进程..." + + # 停止systemd服务(如果运行) + if systemctl list-units --full -a | grep -q "meal-architect.service"; then + if systemctl is-active --quiet meal-architect 2>/dev/null; then + log_info "停止systemd服务..." + systemctl stop meal-architect 2>/dev/null || true + sleep 2 + fi + fi + + # 清理所有gunicorn进程和占用端口的进程 + pkill -9 -f "gunicorn.*meal_architect" 2>/dev/null || true + sleep 1 + fuser -k 8000/tcp 2>/dev/null || true + sleep 2 + + log_info "清理完成,等待端口释放..." + fi + + # 检查必需的文件和目录 + if [ ! -d "$BACKEND_DIR" ]; then + log_error "后端目录不存在: $BACKEND_DIR" + exit 1 + fi + + if [ ! -f "$ENV_FILE" ]; then + log_error ".env文件不存在: $ENV_FILE" + exit 1 + fi + + if [ ! -f "$VENV_DIR/bin/gunicorn" ]; then + log_error "Gunicorn未安装,请先安装Python依赖" + exit 1 + fi + # 启动systemd服务 + log_info "启动systemd服务..." systemctl start meal-architect check_result "systemd service start" # 等待服务启动 - log_info "等待服务启动..." - sleep 10 + log_info "等待服务启动(最多等待30秒)..." + local max_wait=30 + local waited=0 + while [ $waited -lt $max_wait ]; do + if systemctl is-active --quiet meal-architect; then + if lsof -i :8000 >/dev/null 2>&1; then + log_success "服务已启动并监听端口" + break + fi + fi + sleep 1 + waited=$((waited + 1)) + done # 检查服务状态 - if systemctl is-active --quiet meal-architect; then - log_success "Django服务启动成功" - - # 检查端口是否监听 - if netstat -tlnp | grep -q ":8000"; then - log_success "端口8000监听正常" - else - log_warning "端口8000未监听,检查服务日志" - journalctl -u meal-architect -n 20 --no-pager - fi - - # 测试API连接 - log_info "测试API连接..." - if curl -f -s http://127.0.0.1:8000/api/ >/dev/null; then - log_success "API连接测试成功" - else - log_warning "API连接测试失败,但服务已启动" - fi - - else + if ! systemctl is-active --quiet meal-architect; then log_error "Django服务启动失败" log_info "查看服务状态:" - systemctl status meal-architect --no-pager -l + systemctl status meal-architect --no-pager -l | head -30 log_info "查看服务日志:" journalctl -u meal-architect -n 30 --no-pager exit 1 fi -} - -# 配置Supervisor服务(备用方案) -setup_supervisor() { - log_info "配置Supervisor服务(备用方案)..." - # 安装Supervisor - apt install -y supervisor - systemctl enable supervisor - systemctl start supervisor - - # 停止可能存在的旧进程 - supervisorctl stop meal_architect:* 2>/dev/null || true - pkill -f "gunicorn.*meal_architect" 2>/dev/null || true + log_success "Django服务运行中" - # 检查是否有配置模板 - if [ -f "$SCRIPT_DIR/config/supervisor.conf.template" ]; then - log_info "使用配置模板创建Supervisor配置..." - - cp "$SCRIPT_DIR/config/supervisor.conf.template" /etc/supervisor/conf.d/meal_architect.conf - - # 替换模板变量 - sed -i "s|{{VENV_DIR}}|$VENV_DIR|g" /etc/supervisor/conf.d/meal_architect.conf - sed -i "s|{{BACKEND_DIR}}|$BACKEND_DIR|g" /etc/supervisor/conf.d/meal_architect.conf - sed -i "s|{{PROJECT_DIR}}|$PROJECT_DIR|g" /etc/supervisor/conf.d/meal_architect.conf + # 检查端口是否监听 + if netstat -tlnp | grep -q ":8000"; then + log_success "端口8000监听正常" + # 显示监听进程 + LISTEN_PID=$(lsof -ti :8000 2>/dev/null | head -1) + if [ -n "$LISTEN_PID" ]; then + log_info "监听进程PID: $LISTEN_PID" + fi else - log_info "使用内置配置创建Supervisor配置..." - - cat > /etc/supervisor/conf.d/meal_architect.conf << EOF -[program:meal_architect] -command=$VENV_DIR/bin/gunicorn meal_architect.wsgi:application --bind 127.0.0.1:8000 --workers 4 --timeout 60 --keep-alive 2 --max-requests 1000 --max-requests-jitter 100 -directory=$BACKEND_DIR -user=root -autostart=true -autorestart=true -redirect_stderr=true -stdout_logfile=$PROJECT_DIR/logs/meal_architect.log -stderr_logfile=$PROJECT_DIR/logs/meal_architect_error.log -environment=PATH="$VENV_DIR/bin" -EOF + log_error "端口8000未监听" + log_info "查看服务日志:" + journalctl -u meal-architect -n 20 --no-pager + exit 1 fi - # 重新加载配置 - supervisorctl reread - supervisorctl update + # 测试API连接 + log_info "测试API连接..." + sleep 2 # 再等待2秒确保服务完全就绪 + + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 http://127.0.0.1:8000/api/auth/login/ 2>/dev/null || echo "000") + if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "405" ] || [ "$HTTP_CODE" = "404" ]; then + log_success "API连接测试成功(HTTP状态码: $HTTP_CODE)" + elif [ "$HTTP_CODE" = "000" ]; then + log_warning "API连接超时,但服务已启动(可能是服务还在初始化)" + log_info "等待5秒后重试..." + sleep 5 + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 http://127.0.0.1:8000/api/auth/login/ 2>/dev/null || echo "000") + if [ "$HTTP_CODE" != "000" ]; then + log_success "API连接测试成功(HTTP状态码: $HTTP_CODE)" + else + log_warning "API连接仍然超时,请手动检查服务状态" + fi + else + log_warning "API连接测试返回状态码: $HTTP_CODE(服务可能正常,但端点不存在)" + fi - log_success "Supervisor配置完成" + log_success "服务验证完成" } # 检查防火墙配置 @@ -614,53 +1068,218 @@ setup_firewall() { log_success "防火墙配置提醒完成" } -# 修复权限 +# 修复权限(在安装流程中自动执行) fix_permissions() { log_info "修复文件权限..." + # 安装 ACL 工具(用于更安全的权限管理) + if ! command -v setfacl &> /dev/null; then + log_info "安装 ACL 工具..." + apt install -y acl + check_result "ACL installation" + log_success "ACL 工具安装完成" + else + log_info "ACL 工具已安装" + fi + + # 确保data目录存在 + mkdir -p "$DATA_DIR" + + # 修复data目录权限(nginx需要进入目录) + chown root:www-data "$DATA_DIR" + chmod 755 "$DATA_DIR" + log_info "data目录权限已修复" + # 修复静态文件权限 - if [ -d "$BACKEND_DIR/staticfiles" ]; then - chown -R www-data:www-data "$BACKEND_DIR/staticfiles/" - chmod -R 755 "$BACKEND_DIR/staticfiles/" + if [ -d "$STATIC_DIR" ]; then + chown -R www-data:www-data "$STATIC_DIR" + find "$STATIC_DIR" -type d -exec chmod 755 {} \; 2>/dev/null || true + find "$STATIC_DIR" -type f -exec chmod 644 {} \; 2>/dev/null || true + log_info "静态文件权限已修复" + else + log_warning "静态文件目录不存在: $STATIC_DIR" + mkdir -p "$STATIC_DIR" + chown -R www-data:www-data "$STATIC_DIR" + chmod -R 755 "$STATIC_DIR" fi - # 修复项目目录权限 - chown -R root:root "$PROJECT_DIR" - chmod -R 755 "$PROJECT_DIR" + # 修复媒体文件权限(重要:nginx需要读取) + if [ -d "$MEDIA_DIR" ]; then + log_info "修复媒体文件权限(包括所有子目录和文件)..." + + # ⚠️ 关键修复:确保从 /root 到 media 目录的路径链都可以被 www-data 访问 + # 这样才能让 nginx (www-data) 能够访问文件 + log_info "修复父目录路径权限(确保nginx可以进入目录)..." + + # 获取从根目录到 media 目录的所有父目录 + CURRENT_DIR="$MEDIA_DIR" + PATH_DIRS=() + while [ "$CURRENT_DIR" != "/" ] && [ -n "$CURRENT_DIR" ]; do + if [ -d "$CURRENT_DIR" ]; then + PATH_DIRS+=("$CURRENT_DIR") + fi + PARENT_DIR=$(dirname "$CURRENT_DIR") + if [ "$CURRENT_DIR" = "$PARENT_DIR" ]; then + break + fi + CURRENT_DIR="$PARENT_DIR" + done + + # 从 media 目录开始向上修复权限 + for dir in "${PATH_DIRS[@]}"; do + # 使用 ACL 给 www-data 组添加执行权限(更安全,不改变原权限) + if command -v setfacl &> /dev/null; then + setfacl -m g:www-data:x "$dir" 2>/dev/null || true + # 如果是目录,设置默认 ACL(新创建的子目录也会继承) + if [ -d "$dir" ]; then + setfacl -m d:g:www-data:x "$dir" 2>/dev/null || true + fi + log_info "已使用 ACL 为 www-data 添加访问权限: $dir" + else + # 如果不支持 ACL,尝试添加组执行权限(需要目录在 www-data 组中) + # 或者如果目录所有者是 root,给其他用户添加执行权限 + CURRENT_MODE=$(stat -c "%a" "$dir" 2>/dev/null || echo "755") + CURRENT_OWNER=$(stat -c "%U:%G" "$dir" 2>/dev/null || echo "root:root") + + # 如果是 /root 目录,谨慎处理(安全考虑) + if [ "$dir" = "/root" ]; then + log_info "检测到 /root 目录,使用 ACL 添加访问权限" + if command -v setfacl &> /dev/null; then + setfacl -m g:www-data:x "$dir" 2>/dev/null || true + setfacl -m d:g:www-data:x "$dir" 2>/dev/null || true + log_success "已为 /root 目录添加 ACL 权限(www-data 组可执行)" + else + log_error "ACL 工具未安装,无法修复 /root 权限" + log_error "请运行: apt install acl && setfacl -m g:www-data:x /root" + log_warning "或考虑将项目移到 /var/www/Meal_Architect" + fi + else + # 其他目录:确保有执行权限 + if ! [ -x "$dir" ]; then + chmod +x "$dir" 2>/dev/null || true + fi + fi + fi + done + + # 统计文件数量 + DIR_COUNT=$(find "$MEDIA_DIR" -type d | wc -l) + FILE_COUNT=$(find "$MEDIA_DIR" -type f | wc -l) + log_info "发现 $DIR_COUNT 个目录,$FILE_COUNT 个文件" + + # 递归修复所有目录权限 + # 设置setgid位(2755),确保新创建的文件自动继承www-data组 + log_info "修复目录权限..." + find "$MEDIA_DIR" -type d -exec chown www-data:www-data {} \; 2>/dev/null || true + find "$MEDIA_DIR" -type d -exec chmod 2775 {} \; 2>/dev/null || true # 2775 = setgid + 775 + + # 递归修复所有文件权限 + log_info "修复文件权限..." + find "$MEDIA_DIR" -type f -exec chown www-data:www-data {} \; 2>/dev/null || true + find "$MEDIA_DIR" -type f -exec chmod 664 {} \; 2>/dev/null || true + + log_info "媒体文件权限已修复" + else + log_warning "媒体文件目录不存在,创建中: $MEDIA_DIR" + mkdir -p "$MEDIA_DIR" + chown -R www-data:www-data "$MEDIA_DIR" + chmod -R 2775 "$MEDIA_DIR" # 2775 = setgid + 775,确保新文件自动继承www-data组 + log_info "已创建媒体文件目录并设置权限(包含setgid位)" + fi log_success "权限修复完成" } # 安装生产环境 install_prod() { - log_info "安装生产环境..." + log_info "开始安装生产环境..." # 检查root权限 check_root - # 直接安装,不需要确认 - log_info "开始安装生产环境..." + # 检查必需的工具 + check_required_tools || install_base_deps + # 1. 安装系统依赖 install_base_deps + + # 1.5. 安装中文字体(PDF生成需要) + install_chinese_fonts + + # 2. 配置数据库 setup_database - setup_project_env "prod" - install_python_deps "prod" - setup_env_config "prod" - create_project_directories "prod" - init_django "prod" + + # 3. 设置项目环境 + setup_project_env + + # 4. 安装Python依赖 + install_python_deps + + # 5. 配置环境变量 + setup_env_config + + # 6. 验证环境配置 + verify_environment + + # 7. 创建项目目录结构 + create_project_directories + + # 8. 初始化Django应用 + init_django + + # 9. 配置SSL证书 setup_ssl + + # 10. 配置Nginx setup_nginx + + # 11. 配置systemd服务(会自动清理旧进程) setup_systemd_service - setup_firewall + + # 12. 修复文件权限(确保nginx可以读取所有文件) fix_permissions + systemctl reload nginx 2>/dev/null || true - # 启动并验证服务 + # 13. 启动并验证服务 start_and_verify_service - # 启动nginx + # 14. 启动/重启nginx + log_info "启动Nginx..." systemctl restart nginx + check_result "nginx restart" + + # 15. 验证nginx状态 + if systemctl is-active --quiet nginx; then + log_success "Nginx服务运行正常" + else + log_error "Nginx服务启动失败" + systemctl status nginx --no-pager -l | head -20 + exit 1 + fi - log_success "生产环境安装完成!" + # 16. 最终健康检查 + log_info "执行最终健康检查..." + sleep 2 + + # 检查Django服务 + if ! systemctl is-active --quiet meal-architect; then + log_error "Django服务未运行" + exit 1 + fi + + # 检查nginx服务 + if ! systemctl is-active --quiet nginx; then + log_error "Nginx服务未运行" + exit 1 + fi + + # 检查端口 + if ! lsof -i :8000 >/dev/null 2>&1; then + log_error "Django服务未监听8000端口" + exit 1 + fi + + log_success "生产环境安装完成!所有服务运行正常" echo "" echo "==================================" echo " 生产环境安装完成" @@ -685,117 +1304,42 @@ install_prod() { echo "" } -# 修复配置问题 -fix_config() { - log_info "修复配置问题..." - - if [ ! -f "$BACKEND_DIR/.env" ]; then - log_error ".env 文件不存在,请先运行安装脚本" - exit 1 - fi - - # 备份当前配置 - cp "$BACKEND_DIR/.env" "$BACKEND_DIR/.env.backup.$(date +%Y%m%d_%H%M%S)" - log_info "已备份当前配置" - - # 检查并修复安全配置 - if ! grep -q "SECURE_SSL_REDIRECT.*getenv" "$BACKEND_DIR/meal_architect/settings.py" 2>/dev/null; then - log_info "检测到 settings.py 未读取安全配置,注释掉 .env 中的相关配置" - - # 注释掉未使用的安全配置 - sed -i 's/^SECURE_SSL_REDIRECT=/#SECURE_SSL_REDIRECT=/g' "$BACKEND_DIR/.env" - sed -i 's/^SECURE_HSTS_SECONDS=/#SECURE_HSTS_SECONDS=/g' "$BACKEND_DIR/.env" - sed -i 's/^SECURE_HSTS_INCLUDE_SUBDOMAINS=/#SECURE_HSTS_INCLUDE_SUBDOMAINS=/g' "$BACKEND_DIR/.env" - sed -i 's/^SECURE_HSTS_PRELOAD=/#SECURE_HSTS_PRELOAD=/g' "$BACKEND_DIR/.env" - sed -i 's/^SECURE_CONTENT_TYPE_NOSNIFF=/#SECURE_CONTENT_TYPE_NOSNIFF=/g' "$BACKEND_DIR/.env" - sed -i 's/^SECURE_BROWSER_XSS_FILTER=/#SECURE_BROWSER_XSS_FILTER=/g' "$BACKEND_DIR/.env" - sed -i 's/^SESSION_COOKIE_SECURE=/#SESSION_COOKIE_SECURE=/g' "$BACKEND_DIR/.env" - sed -i 's/^CSRF_COOKIE_SECURE=/#CSRF_COOKIE_SECURE=/g' "$BACKEND_DIR/.env" - - log_success "已注释掉未使用的安全配置" - else - log_info "settings.py 已正确配置安全选项" - fi - - # 重启服务 - if systemctl is-enabled meal-architect &> /dev/null; then - log_info "重启 Django 服务..." - systemctl restart meal-architect - sleep 3 - systemctl status meal-architect --no-pager -l - elif command -v supervisorctl &> /dev/null; then - log_info "重启 Django 服务 (Supervisor)..." - supervisorctl restart meal_architect - sleep 3 - supervisorctl status meal_architect - fi - - log_success "配置修复完成!" -} - -# 显示帮助信息 -show_help() { - echo "Meal Architect 环境安装脚本" - echo "" - echo "用法: $0 {dev|prod|fix} [选项]" - echo "" - echo "环境类型:" - echo " dev 安装开发环境" - echo " prod 安装生产环境" - echo " fix 修复配置问题(修复安全配置冲突)" - echo "" - echo "示例:" - echo " $0 dev 安装开发环境" - echo " $0 prod 安装生产环境" - echo " $0 fix 修复配置问题" - echo "" - echo "开发环境特点:" - echo " - 安装在用户目录下" - echo " - 使用SQLite数据库" - echo " - 不需要root权限" - echo " - 不配置Nginx和SSL" - echo "" - echo "生产环境特点:" - echo " - 安装在/root目录下" - echo " - 使用PostgreSQL数据库" - echo " - 需要root权限" - echo " - 配置Nginx、SSL、防火墙" - echo " - 使用systemd管理服务(自动启动和重启)" - echo "" - echo "修复功能:" - echo " - 自动检测并修复 .env 配置问题" - echo " - 注释掉未在 settings.py 中使用的安全配置" - echo " - 避免配置冲突导致的连接问题" - echo "" -} - # 主函数 main() { echo "==================================" - echo " Meal Architect 环境安装" + echo " Meal Architect 生产环境安装" echo "==================================" echo "" - # 根据参数选择安装类型 - case "$1" in - "dev") - install_dev - ;; - "prod") + # 显示帮助或执行安装 + if [ "$1" = "--help" ] || [ "$1" = "help" ] || [ -z "$1" ]; then + echo "Meal Architect 生产环境安装脚本" + echo "" + echo "用法: sudo $0" + echo "" + echo "功能:" + echo " 一键安装生产环境,包括:" + echo " - 安装系统依赖" + echo " - 配置PostgreSQL数据库" + echo " - 配置Python虚拟环境" + echo " - 安装Python依赖" + echo " - 配置环境变量" + echo " - 初始化Django应用" + echo " - 配置SSL证书" + echo " - 配置Nginx" + echo " - 配置systemd服务" + echo " - 修复文件权限" + echo " - 启动并验证服务" + echo "" + if [ -z "$1" ]; then + echo "开始安装..." + echo "" install_prod - ;; - "fix") - fix_config - ;; - "--help"|"help"|"") - show_help - ;; - *) - log_error "未知参数: $1" - show_help - exit 1 - ;; - esac + fi + else + # 任何参数都执行安装 + install_prod + fi } # 执行主函数 diff --git a/backend/requirements.txt b/deploy/requirements.txt similarity index 88% rename from backend/requirements.txt rename to deploy/requirements.txt index 49acd301e259ab4fd8306e536e7424f13c7c6dbc..32fb3cc1213b376d63190a8b9302eaa993a2be93 100644 --- a/backend/requirements.txt +++ b/deploy/requirements.txt @@ -9,4 +9,7 @@ requests==2.31.0 django-filter==23.3 drf-yasg==1.21.7 setuptools>=65.0.0 +reportlab==4.0.7 +pypdf==3.17.1 + diff --git a/deploy/reset-db.sh b/deploy/reset-db.sh new file mode 100644 index 0000000000000000000000000000000000000000..436b146ceb1a60db98da63b9a18d34881d50e2de --- /dev/null +++ b/deploy/reset-db.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# 生产环境数据库重置脚本 +# ⚠️ 警告:会删除所有数据库数据! + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/config.sh" +DB_NAME="meal_architect_prod" + +[ "$EUID" -ne 0 ] && echo "请以 root 用户运行" && exit 1 + +read -p "确认要删除数据库 $DB_NAME 吗?(输入 YES): " confirm +[ "$confirm" != "YES" ] && echo "已取消" && exit 0 + +# 停止 Django 应用 +systemctl stop meal-architect 2>/dev/null || supervisorctl stop meal_architect 2>/dev/null +sleep 2 + +# 终止数据库连接并删除数据库 +sudo -u postgres psql -d postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='$DB_NAME' AND pid != pg_backend_pid();" >/dev/null 2>&1 +sudo -u postgres psql -c "DROP DATABASE IF EXISTS $DB_NAME;" >/dev/null 2>&1 + +# 清理所有迁移文件(保留 __init__.py) +echo "清理迁移文件..." +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$PROJECT_ROOT/backend" + +# 删除所有迁移文件(除了 __init__.py) +find . -path "*/migrations/000*.py" -delete 2>/dev/null || true +find . -path "*/migrations/__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true +find . -name "*.pyc" -delete 2>/dev/null || true + +echo "迁移文件已清理" + +# 重新运行安装脚本 +cd "$SCRIPT_DIR" && bash install.sh + diff --git a/deploy/start.sh b/deploy/start.sh index 291384e6ff030ffa3183edb1063b3f483b3f5c3c..ed28129ba584b9d4d656972b58cbc53de0e9b66e 100644 --- a/deploy/start.sh +++ b/deploy/start.sh @@ -10,15 +10,28 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' -# 配置变量 +# ============================================ +# 路径配置 - 集中管理所有路径,便于维护 +# ============================================ +# 生产环境路径配置 PROJECT_DIR="/root/Meal_Architect" BACKEND_DIR="$PROJECT_DIR/backend" VENV_DIR="$PROJECT_DIR/venv" +DATA_DIR="$PROJECT_DIR/data" +LOGS_DIR="$PROJECT_DIR/logs" +STATIC_DIR="$DATA_DIR/staticfiles" +MEDIA_DIR="$DATA_DIR/media" +ENV_FILE="$PROJECT_DIR/.env" -# 开发环境配置(当以dev模式运行时会被重新设置) +# 开发环境路径配置(当以dev模式运行时会被重新设置) DEV_PROJECT_DIR="$HOME/Meal_Architect" DEV_BACKEND_DIR="$DEV_PROJECT_DIR/backend" DEV_VENV_DIR="$DEV_PROJECT_DIR/venv" +DEV_DATA_DIR="$DEV_PROJECT_DIR/data" +DEV_LOGS_DIR="$DEV_PROJECT_DIR/logs" +DEV_STATIC_DIR="$DEV_DATA_DIR/staticfiles" +DEV_MEDIA_DIR="$DEV_DATA_DIR/media" +DEV_ENV_FILE="$DEV_PROJECT_DIR/.env" # 日志函数 log_info() { @@ -172,6 +185,11 @@ start_dev() { PROJECT_DIR="$DEV_PROJECT_DIR" BACKEND_DIR="$DEV_BACKEND_DIR" VENV_DIR="$DEV_VENV_DIR" + DATA_DIR="$DEV_DATA_DIR" + LOGS_DIR="$DEV_LOGS_DIR" + STATIC_DIR="$DEV_STATIC_DIR" + MEDIA_DIR="$DEV_MEDIA_DIR" + ENV_FILE="$DEV_ENV_FILE" # 停止现有进程 stop_existing_processes @@ -184,11 +202,11 @@ start_dev() { fi # 检查并创建data目录 - if [ ! -d "$PROJECT_DIR/data" ]; then - log_info "创建数据目录: $PROJECT_DIR/data" - mkdir -p "$PROJECT_DIR/data" - chown -R $USER:$USER "$PROJECT_DIR/data" - chmod -R 755 "$PROJECT_DIR/data" + if [ ! -d "$DATA_DIR" ]; then + log_info "创建数据目录: $DATA_DIR" + mkdir -p "$DATA_DIR" + chown -R $USER:$USER "$DATA_DIR" + chmod -R 755 "$DATA_DIR" fi # 激活虚拟环境 @@ -270,10 +288,10 @@ start_prod() { fi # 确保日志目录存在 - mkdir -p "$PROJECT_DIR/logs" - touch "$PROJECT_DIR/logs/meal_architect.log" - touch "$PROJECT_DIR/logs/meal_architect_error.log" - chmod 644 "$PROJECT_DIR/logs"/*.log + mkdir -p "$LOGS_DIR" + touch "$LOGS_DIR/meal_architect.log" + touch "$LOGS_DIR/meal_architect_error.log" + chmod 644 "$LOGS_DIR"/*.log log_success "日志目录和文件已创建" # 重新加载Supervisor配置 @@ -310,13 +328,13 @@ start_prod() { else log_error "生产环境启动失败" echo "状态: $NEW_STATUS" - log_info "查看错误日志: tail -f /root/Meal_Architect/logs/meal_architect_error.log" + log_info "查看错误日志: tail -f $LOGS_DIR/meal_architect_error.log" exit 1 fi else log_error "生产环境启动失败" echo "状态: $status" - log_info "查看错误日志: tail -f /root/Meal_Architect/logs/meal_architect_error.log" + log_info "查看错误日志: tail -f $LOGS_DIR/meal_architect_error.log" exit 1 fi } diff --git a/deploy/update.sh b/deploy/update.sh index 71ee9b2d1ab181b6b3031e1cdc3b638d01ec3c13..9be6339b40b289453087965b9ffc49b8ef90363c 100644 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -12,10 +12,17 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' -# 配置变量 +# ============================================ +# 路径配置 - 集中管理所有路径,便于维护 +# ============================================ PROJECT_DIR="/root/Meal_Architect" BACKEND_DIR="$PROJECT_DIR/backend" VENV_DIR="$PROJECT_DIR/venv" +DATA_DIR="$PROJECT_DIR/data" +LOGS_DIR="$PROJECT_DIR/logs" +STATIC_DIR="$DATA_DIR/staticfiles" +MEDIA_DIR="$DATA_DIR/media" +ENV_FILE="$PROJECT_DIR/.env" # 日志函数 log_info() { @@ -88,8 +95,10 @@ update_dependencies() { # 升级pip pip install --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple/ - # 更新依赖 - pip install -r "$BACKEND_DIR/requirements.txt" -i https://pypi.tuna.tsinghua.edu.cn/simple/ --upgrade + # 更新依赖(requirements.txt 在 deploy 目录中) + SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" + REQUIREMENTS_FILE="$SCRIPT_DIR/requirements.txt" + pip install -r "$REQUIREMENTS_FILE" -i https://pypi.tuna.tsinghua.edu.cn/simple/ --upgrade log_success "Python依赖更新完成" } @@ -119,9 +128,21 @@ collect_static() { python manage.py collectstatic --noinput - # 修复权限 - chown -R www-data:www-data "$BACKEND_DIR/staticfiles/" - chmod -R 755 "$BACKEND_DIR/staticfiles/" + # 修复权限(使用新的路径) + chown -R www-data:www-data "$STATIC_DIR" + chmod -R 755 "$STATIC_DIR" + + # 修复媒体文件权限(确保nginx可以读取) + if [ -d "$MEDIA_DIR" ]; then + log_info "修复媒体文件权限,文件数量可能较多,请稍候..." + # 修复所有目录权限 + find "$MEDIA_DIR" -type d -exec chown www-data:www-data {} \; 2>/dev/null || true + find "$MEDIA_DIR" -type d -exec chmod 775 {} \; 2>/dev/null || true + # 修复所有文件权限 + find "$MEDIA_DIR" -type f -exec chown www-data:www-data {} \; 2>/dev/null || true + find "$MEDIA_DIR" -type f -exec chmod 664 {} \; 2>/dev/null || true + log_info "媒体文件权限已修复" + fi log_success "静态文件收集完成" } diff --git a/miniprogram/app.js b/miniprogram/app.js index d8ac2a9e654ce4fa1a7caee0a29748b710c04bc1..6425b5403bd3675b0fad687957960586f945d543 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -11,16 +11,20 @@ App({ // 开发环境判断 if (__wxConfig.envVersion === 'develop') { // 检测是否在真机上运行 + // 注意:wx.getSystemInfoSync() 已废弃,但微信尚未提供获取 platform 的新 API + // 这里继续使用以确保兼容性,警告可以忽略 const systemInfo = wx.getSystemInfoSync() const isRealDevice = systemInfo.platform !== 'devtools' if (isRealDevice) { // 真机调试:使用电脑的IP地址 // 可以通过网络接口获取本机IP,或者手动配置 - return 'http://172.17.149.151:8000' // 你的电脑IP地址 + //return 'http://172.16.8.41:8000' + return 'https://b106.xyz' } else { // 模拟器调试:使用localhost - return 'http://localhost:8000' + //return 'http://localhost:8000' + return 'https://b106.xyz' } } else if (__wxConfig.envVersion === 'trial') { // 体验版环境 @@ -28,7 +32,11 @@ App({ } // 生产环境 return 'https://b106.xyz' - })() + })(), + // apiUrl 别名,指向 baseUrl(为了兼容性) + get apiUrl() { + return this.baseUrl + } }, onLaunch() { @@ -37,6 +45,8 @@ App({ console.log('API地址:', this.globalData.baseUrl) // 检测运行环境 + // 注意:wx.getSystemInfoSync() 已废弃,但微信尚未提供获取 platform 的新 API + // 这里继续使用以确保兼容性,警告可以忽略 const systemInfo = wx.getSystemInfoSync() console.log('运行平台:', systemInfo.platform) console.log('是否真机:', systemInfo.platform !== 'devtools') @@ -62,6 +72,15 @@ App({ this.globalData.token = token this.globalData.userInfo = userInfo } + + // 清理过期的图片缓存(启动时执行一次) + try { + const { imageManager } = require('./utils/imageManager') + imageManager.clearExpiredCache() + console.log('图片缓存清理完成') + } catch (err) { + console.error('清理图片缓存失败:', err) + } }, // 设置token和用户信息 diff --git a/miniprogram/app.json b/miniprogram/app.json index 65dcc046ba9c0c8e1d687036facb335a73253144..b51ffb126a228478f51acb87dec13c23e516cf86 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -1,5 +1,4 @@ { - "_comment": "微信小程序主配置文件 - 包含所有页面,tabBar由app.js动态加载", "pages": [ "pages/login/login", "pages/role-select/role-select", @@ -9,6 +8,7 @@ "pages/profile/profile", "pages/profile-edit/profile-edit", "pages/avatar-crop/avatar-crop", + "pages/image-edit/image-edit", "pages/settings/settings", "pages/feedback/feedback", "pages/about/about", @@ -44,14 +44,14 @@ { "pagePath": "pages/home/home", "text": "首页", - "iconPath": "images/dish.png", - "selectedIconPath": "images/dish-active.png" + "iconPath": "images/home.png", + "selectedIconPath": "images/home-active.png" }, { "pagePath": "pages/menu/menu", "text": "菜单", - "iconPath": "images/dish.png", - "selectedIconPath": "images/dish-active.png" + "iconPath": "images/menu.png", + "selectedIconPath": "images/menu-active.png" }, { "pagePath": "pages/plan/plan", @@ -68,6 +68,7 @@ ] }, "style": "v2", - "sitemapLocation": "sitemap.json" + "sitemapLocation": "sitemap.json", + "lazyCodeLoading": "requiredComponents" } diff --git a/miniprogram/images/calendar-active.png b/miniprogram/images/calendar-active.png index 8e19178cd5e6f44e135218bb402853a0ea8610f6..c10c9b9ca95f582794b3713d9c97cb57f7edf542 100644 Binary files a/miniprogram/images/calendar-active.png and b/miniprogram/images/calendar-active.png differ diff --git a/miniprogram/images/calendar.png b/miniprogram/images/calendar.png index f701655d01bb483aa0807fde6c4385a256e9c2ac..4263889e7447fd01c94280a637e24451db0759ca 100644 Binary files a/miniprogram/images/calendar.png and b/miniprogram/images/calendar.png differ diff --git a/miniprogram/images/default-avatar.png b/miniprogram/images/default-avatar.png index 8516f4594b7932d6385727efa95dd16807afa35d..5eac4eb5a64c0c739bf5a3a46a05bf6632b36137 100644 Binary files a/miniprogram/images/default-avatar.png and b/miniprogram/images/default-avatar.png differ diff --git a/miniprogram/images/default-dish.png b/miniprogram/images/default-dish.png index 73b7d49dc20eff685380e53641ed39e37d307675..2de5304b27afd1f65fa6b787b31ac1a3665d09e9 100644 Binary files a/miniprogram/images/default-dish.png and b/miniprogram/images/default-dish.png differ diff --git a/miniprogram/images/dish-active.png b/miniprogram/images/dish-active.png deleted file mode 100644 index 8e19178cd5e6f44e135218bb402853a0ea8610f6..0000000000000000000000000000000000000000 Binary files a/miniprogram/images/dish-active.png and /dev/null differ diff --git a/miniprogram/images/dish.png b/miniprogram/images/dish.png deleted file mode 100644 index f701655d01bb483aa0807fde6c4385a256e9c2ac..0000000000000000000000000000000000000000 Binary files a/miniprogram/images/dish.png and /dev/null differ diff --git a/miniprogram/images/home-active.png b/miniprogram/images/home-active.png new file mode 100644 index 0000000000000000000000000000000000000000..cfe38f7ec960fa2d2b6c4283fb60e6548d5ce52b Binary files /dev/null and b/miniprogram/images/home-active.png differ diff --git a/miniprogram/images/home.png b/miniprogram/images/home.png new file mode 100644 index 0000000000000000000000000000000000000000..4702f99b6283690fcb8cb024c95b0f95aa443c29 Binary files /dev/null and b/miniprogram/images/home.png differ diff --git a/miniprogram/images/logo-cover.png b/miniprogram/images/logo-cover.png new file mode 100644 index 0000000000000000000000000000000000000000..cdabfe06ab8fa4496bead7ce5876d9facb68c74c Binary files /dev/null and b/miniprogram/images/logo-cover.png differ diff --git a/miniprogram/images/logo-small.png b/miniprogram/images/logo-small.png new file mode 100644 index 0000000000000000000000000000000000000000..99204b55baca627119d708a7021e19abd18b6bb4 Binary files /dev/null and b/miniprogram/images/logo-small.png differ diff --git a/miniprogram/images/logo-text.png b/miniprogram/images/logo-text.png new file mode 100644 index 0000000000000000000000000000000000000000..60799c50da706a60fcc08702813dae32b3a167f0 Binary files /dev/null and b/miniprogram/images/logo-text.png differ diff --git a/miniprogram/images/logo.png b/miniprogram/images/logo.png index f6ea6c4486cc2c6dcac4ef30521aaeb0998b6eeb..854eee97843a4d5db2f4b2f65796095ca61dffd0 100644 Binary files a/miniprogram/images/logo.png and b/miniprogram/images/logo.png differ diff --git a/miniprogram/images/menu-active.png b/miniprogram/images/menu-active.png new file mode 100644 index 0000000000000000000000000000000000000000..dfe1cf0b612b2dd25054d6f2ab37ff8018b489de Binary files /dev/null and b/miniprogram/images/menu-active.png differ diff --git a/miniprogram/images/menu.png b/miniprogram/images/menu.png new file mode 100644 index 0000000000000000000000000000000000000000..540ca7238398e01a06b2b403133ddc280a18118d Binary files /dev/null and b/miniprogram/images/menu.png differ diff --git a/miniprogram/images/profile-active.png b/miniprogram/images/profile-active.png index 8e19178cd5e6f44e135218bb402853a0ea8610f6..670afd61d60ba2b586f719ee9cab9c658bb8f270 100644 Binary files a/miniprogram/images/profile-active.png and b/miniprogram/images/profile-active.png differ diff --git a/miniprogram/images/profile.png b/miniprogram/images/profile.png index f701655d01bb483aa0807fde6c4385a256e9c2ac..546d7d149485b92703fe67b34c0b285ba998ad39 100644 Binary files a/miniprogram/images/profile.png and b/miniprogram/images/profile.png differ diff --git a/miniprogram/pages/about/about.js b/miniprogram/pages/about/about.js index 6a77ff1bbe95795d73c6e42d52339f0e193db619..8aeb9dac3618e3d48eb3adba46a540eba8dd09d3 100644 --- a/miniprogram/pages/about/about.js +++ b/miniprogram/pages/about/about.js @@ -1,16 +1,22 @@ // pages/about/about.js +const { getVersionInfo } = require('../../utils/version.js') + Page({ data: { appInfo: { name: '配膳官', version: '1.0.0', description: '专业的健康饮食管理平台', - contact: 'support@mealarchitect.com' + contact: '447083059@qq.com' } }, onLoad() { - // 页面加载时的逻辑 + // 从版本配置文件读取版本号 + const versionInfo = getVersionInfo() + this.setData({ + 'appInfo.version': versionInfo.version + }) }, /** diff --git a/miniprogram/pages/about/about.wxml b/miniprogram/pages/about/about.wxml index 73e82c62702c75f9a7b0a151efbc4cac60ec7c7c..afe96e9232ad4f52bf8ab799a4af35dfcfa7e236 100644 --- a/miniprogram/pages/about/about.wxml +++ b/miniprogram/pages/about/about.wxml @@ -49,6 +49,6 @@ - © 2024 配膳官团队 保留所有权利 + © 2025 配膳官团队 保留所有权利 diff --git a/miniprogram/pages/chef/dish-edit/dish-edit.js b/miniprogram/pages/chef/dish-edit/dish-edit.js index 479a924a9ae3c1f1fa54281d3a5d9db37620bc54..c4a96a58e3e9231a08df543c2652eadc1e761e6b 100644 --- a/miniprogram/pages/chef/dish-edit/dish-edit.js +++ b/miniprogram/pages/chef/dish-edit/dish-edit.js @@ -1,6 +1,7 @@ // pages/chef/dish-edit/dish-edit.js const { get, post, put } = require('../../../utils/request') const { showLoading, hideLoading, showSuccess, showError } = require('../../../utils/util') +const { imageManager } = require('../../../utils/imageManager') const app = getApp() Page({ @@ -9,28 +10,26 @@ Page({ isEdit: false, formData: { name: '', - category: 'vegetable', - status: 'published', + dish_type: '', // 新增:菜品类型(必选) + nutrition_categories: [], // 修改:营养成分分类(多选,可选) cooking_steps: [], // 改为数组结构 ingredients: [], images: [] }, - categories: [ - { value: 'vegetable', label: '蔬菜' }, - { value: 'protein', label: '蛋白质' }, - { value: 'carb', label: '碳水' }, - { value: 'fat', label: '脂肪' } - ], - statusOptions: [ - { value: 'published', label: '已发布' }, - { value: 'draft', label: '草稿' } - ], + dishTypes: [], // 新增:菜品类型选项 + nutritionCategories: [], // 修改:营养成分分类选项(多选) + selectedDishTypeIndex: -1, // 当前选中的菜品类型索引 + selectedDishTypeLabel: '请选择菜品类型', // 当前选中的菜品类型显示文本 commonUnits: ['克', '个', '毫升', '勺', '片', '根', '颗', '包', '袋', '盒'], validationErrors: {}, isSaving: false }, onLoad(options) { + // 加载菜品类型和营养成分分类选项 + this.loadDishTypes() + this.loadNutritionCategories() + if (options.id) { this.setData({ dishId: options.id, @@ -40,6 +39,71 @@ Page({ } }, + /** + * 加载菜品类型选项 + */ + loadDishTypes() { + get('/api/dishes/dish-type-choices/') + .then(res => { + const dishTypes = res.dish_types || [] + this.setData({ dishTypes }) + // 如果有已选择的菜品类型,更新显示 + if (this.data.formData.dish_type) { + this.updateDishTypeDisplay() + } + }) + .catch(err => { + console.error('加载菜品类型失败:', err) + }) + }, + + /** + * 更新菜品类型显示 + */ + updateDishTypeDisplay() { + const { dishTypes, formData } = this.data + if (!dishTypes || dishTypes.length === 0) { + return + } + + const index = dishTypes.findIndex(item => item.value === formData.dish_type) + const selectedItem = dishTypes.find(item => item.value === formData.dish_type) + + this.setData({ + selectedDishTypeIndex: index >= 0 ? index : -1, + selectedDishTypeLabel: selectedItem ? selectedItem.label : '请选择菜品类型' + }) + }, + + /** + * 加载营养成分分类选项 + */ + loadNutritionCategories() { + get('/api/dishes/nutrition-category-choices/') + .then(res => { + const nutritionCategories = res.nutrition_categories || [] + this.setData({ nutritionCategories }) + // 更新选中状态映射 + this.updateNutritionCategoriesSelected() + }) + .catch(err => { + console.error('加载营养成分分类失败:', err) + }) + }, + + /** + * 更新营养成分分类选中状态映射 + */ + updateNutritionCategoriesSelected() { + const selectedMap = {} + this.data.formData.nutrition_categories.forEach(code => { + selectedMap[code] = true + }) + this.setData({ + nutritionCategoriesSelected: selectedMap + }) + }, + /** * 加载菜品详情 */ @@ -50,25 +114,19 @@ Page({ .then(res => { hideLoading() - // 处理图片URL,确保是完整的绝对路径 - const images = (res.images || []).map((image, index) => { - if (image.image_url) { - // 如果图片URL不是完整的HTTP/HTTPS URL,则拼接baseUrl - if (!image.image_url.startsWith('http')) { - const app = getApp() - image.image_url = `${app.globalData.baseUrl}${image.image_url.startsWith('/') ? '' : '/'}${image.image_url}` - } - // 清理URL中的重试参数 - image.image_url = image.image_url.split('?')[0] - - // 预加载图片到本地 + // 使用统一的图片管理器处理图片 + const images = imageManager.processImages(res.images || []) + + // 预加载图片到本地 + images.forEach((image, index) => { + if (image.image_url && !image.image_url.startsWith('/images/')) { this.preloadImage(image.image_url, index) } - return image }) // 处理制作步骤(从API返回的结构转换为前端需要的结构) - const cookingSteps = (res.cooking_steps || []).map((step, index) => ({ + const processedSteps = imageManager.processCookingSteps(res.cooking_steps || []) + const cookingSteps = processedSteps.map((step, index) => ({ step_number: step.step_number || index + 1, description: step.description || '', image_url: step.image_url, @@ -79,23 +137,32 @@ Page({ // 初始化食材的单位选择器索引 const ingredients = (res.ingredients || []).map(ingredient => { const unitIndex = this.data.commonUnits.indexOf(ingredient.unit) - return { - ...ingredient, + return Object.assign({}, ingredient, { unitIndex: unitIndex >= 0 ? unitIndex : 0, customUnit: unitIndex < 0 - } + }) }) + // 处理营养成分分类(从数组格式转换为code数组) + const nutritionCategories = (res.nutrition_categories || []).map(nc => + typeof nc === 'string' ? nc : nc.code + ) + this.setData({ formData: { name: res.name, - category: res.category, - status: res.status, + dish_type: res.dish_type || '', + nutrition_categories: nutritionCategories, description: res.description || '', cooking_steps: cookingSteps, ingredients: ingredients, images: images } + }, () => { + // 更新菜品类型显示 + this.updateDishTypeDisplay() + // 更新营养成分分类选中状态 + this.updateNutritionCategoriesSelected() }) }) .catch(err => { @@ -115,23 +182,120 @@ Page({ }, /** - * 选择分类 + * 选择菜品类型 + */ + handleDishTypeChange(e) { + const index = parseInt(e.detail.value) + const dishTypes = this.data.dishTypes + if (index >= 0 && index < dishTypes.length) { + const dishType = dishTypes[index].value + this.setData({ + 'formData.dish_type': dishType, + selectedDishTypeIndex: index, + selectedDishTypeLabel: dishTypes[index].label + }) + } + }, + + /** + * 切换营养成分分类选择(多选) */ - handleCategoryChange(e) { - const category = this.data.categories[e.detail.value].value + toggleNutritionCategory(e) { + const category = e.currentTarget.dataset.category + const nutritionCategories = this.data.formData.nutrition_categories.slice() + const index = nutritionCategories.indexOf(category) + + if (index > -1) { + // 取消选择 + nutritionCategories.splice(index, 1) + } else { + // 添加到选择列表 + nutritionCategories.push(category) + } + + // 创建一个映射,用于检查是否选中 + const selectedMap = {} + nutritionCategories.forEach(code => { + selectedMap[code] = true + }) + this.setData({ - 'formData.category': category + 'formData.nutrition_categories': nutritionCategories, + nutritionCategoriesSelected: selectedMap }) }, /** - * 选择状态 + * 检查营养成分分类是否选中 */ - handleStatusChange(e) { - const status = this.data.statusOptions[e.detail.value].value - this.setData({ - 'formData.status': status + isNutritionCategorySelected(code) { + return this.data.formData.nutrition_categories.includes(code) + }, + + /** + * 保存为草稿 + */ + saveDishAsDraft() { + if (this.data.isSaving) return + + const { formData, isEdit, dishId } = this.data + + // 草稿模式不需要完整验证,只需要基本验证 + if (!formData.name || !formData.name.trim()) { + showError('请输入菜品名称') + return + } + + this.setData({ + isSaving: true, + validationErrors: {} }) + + showLoading(isEdit ? '保存中...' : '创建中...') + + // 准备数据(设置为草稿) + const dishData = { + name: formData.name.trim(), + dish_type: formData.dish_type || '', // 草稿可以不选菜品类型 + nutrition_categories: formData.nutrition_categories || [], + status: 'draft', // 设置为草稿 + description: formData.description || '' + } + + // 准备食材数据 + dishData.ingredients = (formData.ingredients || []) + .filter(ing => { + const hasName = ing.name && ing.name.trim() + const hasQuantity = ing.quantity && ing.quantity.toString().trim() + const unit = ing.customUnit ? ing.unit : (this.data.commonUnits[ing.unitIndex] || '') + const hasUnit = unit && unit.trim() + return hasName && hasQuantity && hasUnit + }) + .map(ing => ({ + name: ing.name.trim(), + quantity: ing.quantity, + unit: ing.customUnit ? ing.unit : this.data.commonUnits[ing.unitIndex] + })) + + // 准备制作步骤 + const validSteps = (formData.cooking_steps || []).filter(step => step.description && step.description.trim()) + dishData.cooking_steps = validSteps.map((step, index) => { + const stepData = { + step_number: index + 1, + description: step.description.trim() + } + if (step.tempPath) { + stepData.need_upload_image = true + stepData.temp_image_path = step.tempPath + } + return stepData + }) + + if (isEdit) { + this.updateDish(dishId, dishData) + } else { + this.createDishWithImages(dishData) + } }, /** @@ -229,22 +393,36 @@ Page({ wx.chooseImage({ count: 1, - sizeType: ['compressed'], + sizeType: ['original'], // 使用原图,编辑后再处理 sourceType: ['album', 'camera'], success: (res) => { + // 跳转到图片编辑页面 const tempPath = res.tempFilePaths[0] - const steps = this.data.formData.cooking_steps - steps[index].tempPath = tempPath - steps[index].image_url = null - steps[index].image = null - - this.setData({ - 'formData.cooking_steps': steps + wx.navigateTo({ + url: `/pages/image-edit/image-edit?src=${encodeURIComponent(tempPath)}&type=step&stepIndex=${index}` }) } }) }, + /** + * 处理步骤图片编辑后的回调 + */ + handleStepImageEdited(editedImagePath, stepIndex) { + const steps = this.data.formData.cooking_steps + if (stepIndex !== undefined && steps[stepIndex]) { + // 注意:http://tmp/ 是微信小程序内部的临时文件路径,必须保持原样 + // 虽然会有 HTTPS 警告,但这是微信内部的路径格式,功能可以正常工作 + steps[stepIndex].tempPath = editedImagePath + steps[stepIndex].image_url = null + steps[stepIndex].image = null + + this.setData({ + 'formData.cooking_steps': steps + }) + } + }, + /** * 删除步骤图片 */ @@ -334,26 +512,42 @@ Page({ * 选择图片 */ chooseImage() { + const remainingCount = 9 - this.data.formData.images.length + if (remainingCount <= 0) { + showError('最多只能上传9张图片') + return + } + wx.chooseImage({ - count: 9 - this.data.formData.images.length, - sizeType: ['compressed'], + count: 1, // 每次只选择一张,跳转到编辑页面 + sizeType: ['original'], // 使用原图,编辑后再处理 sourceType: ['album', 'camera'], success: res => { - // 直接保存临时文件路径,在保存菜品时一起上传 - const images = this.data.formData.images - res.tempFilePaths.forEach((path, index) => { - images.push({ - tempPath: path, - order: images.length - }) - }) - this.setData({ - 'formData.images': images + // 跳转到图片编辑页面 + const tempPath = res.tempFilePaths[0] + wx.navigateTo({ + url: `/pages/image-edit/image-edit?src=${encodeURIComponent(tempPath)}&type=dish` }) } }) }, + /** + * 处理菜品图片编辑后的回调 + */ + handleDishImageEdited(editedImagePath) { + // 注意:http://tmp/ 是微信小程序内部的临时文件路径,必须保持原样 + // 虽然会有 HTTPS 警告,但这是微信内部的路径格式,功能可以正常工作 + const images = this.data.formData.images + images.push({ + tempPath: editedImagePath, + order: images.length + }) + this.setData({ + 'formData.images': images + }) + }, + /** * 删除图片 */ @@ -363,36 +557,27 @@ Page({ const imageToDelete = images[index] // 如果是服务器上的图片,需要调用删除接口 - if (imageToDelete.id) { + if (imageToDelete.id && this.data.dishId) { showLoading('删除中...') - wx.request({ - url: `${app.globalData.baseUrl}/api/chef/dishes/${this.data.dishId}/delete_image/`, - method: 'DELETE', - data: { - image_id: imageToDelete.id - }, - header: { - 'Authorization': `Bearer ${app.globalData.token}`, - 'Content-Type': 'application/json' - }, - success: (res) => { + // 使用统一的request工具 + const { del } = require('../../../utils/request') + + del(`/api/chef/dishes/${this.data.dishId}/delete_image/`, { + image_id: imageToDelete.id + }) + .then(() => { hideLoading() - if (res.statusCode === 200) { - images.splice(index, 1) - this.setData({ - 'formData.images': images - }) - showSuccess('图片删除成功') - } else { - showError('删除失败') - } - }, - fail: () => { + images.splice(index, 1) + this.setData({ + 'formData.images': images + }) + showSuccess('图片删除成功') + }) + .catch((err) => { hideLoading() - showError('删除失败') - } - }) + showError(err.message || '删除失败') + }) } else { // 如果是本地临时图片,直接删除 images.splice(index, 1) @@ -425,13 +610,14 @@ Page({ validationErrors: {} }) - showLoading(isEdit ? '更新中...' : '创建中...') + showLoading(isEdit ? '发布中...' : '发布中...') - // 准备数据 + // 准备数据(发布模式) const dishData = { name: formData.name.trim(), - category: formData.category, - status: formData.status, + dish_type: formData.dish_type, // 必选:菜品类型 + nutrition_categories: formData.nutrition_categories || [], // 可选:营养成分分类(多选) + status: 'published', // 发布状态 description: formData.description || '' } @@ -490,6 +676,13 @@ Page({ errors.name = '菜品名称至少2个字符' } + // 验证菜品类型(必选) + if (!formData.dish_type) { + errors.dish_type = '请选择菜品类型' + } + + // 营养成分分类是可选的,不需要验证 + // 验证制作步骤:过滤掉空步骤后必须有有效步骤 const validSteps = formData.cooking_steps.filter(step => step.description && step.description.trim()) if (validSteps.length === 0) { @@ -515,7 +708,7 @@ Page({ // 验证食材名称不重复 const ingredientNames = validIngredients.map(ing => ing.name.trim()) - const uniqueNames = [...new Set(ingredientNames)] + const uniqueNames = Array.from(new Set(ingredientNames)) if (ingredientNames.length !== uniqueNames.length) { errors.ingredients = '食材名称不能重复' } @@ -612,50 +805,6 @@ Page({ }) }, - /** - * 保存为草稿 - */ - saveAsDraft() { - this.setData({ - 'formData.status': 'draft' - }) - this.saveDish() - }, - - /** - * 发布菜品 - */ - publishDish() { - this.setData({ - 'formData.status': 'published' - }) - this.saveDish() - }, - - /** - * 重置表单 - */ - resetForm() { - wx.showModal({ - title: '确认重置', - content: '确定要重置表单吗?所有未保存的数据将丢失。', - success: (res) => { - if (res.confirm) { - this.setData({ - formData: { - name: '', - category: 'vegetable', - status: 'published', - cooking_steps: '', - ingredients: [], - images: [] - }, - validationErrors: {} - }) - } - } - }) - }, /** * 页面返回确认 @@ -678,11 +827,12 @@ Page({ * 更新菜品 */ updateDish(dishId, dishData) { + const isDraft = dishData.status === 'draft' put(`/api/chef/dishes/${dishId}/`, dishData) .then(() => { this.setData({ isSaving: false }) hideLoading() - showSuccess('更新成功') + showSuccess(isDraft ? '保存为草稿成功' : '发布成功') // 通知上一页刷新数据 const pages = getCurrentPages() const prevPage = pages[pages.length - 2] @@ -726,10 +876,11 @@ Page({ .filter(item => item.tempPath) // 如果没有图片,直接完成 + const isDraft = dishData.status === 'draft' if (tempImages.length === 0 && tempStepImages.length === 0) { this.setData({ isSaving: false }) hideLoading() - showSuccess('创建成功') + showSuccess(isDraft ? '保存为草稿成功' : '发布成功') this.refreshPrevPage() setTimeout(() => { wx.navigateBack() @@ -744,9 +895,10 @@ Page({ // 上传步骤图片 this.uploadStepImages(dish.id, tempStepImages) .then(() => { + const isDraft = dishData.status === 'draft' this.setData({ isSaving: false }) hideLoading() - showSuccess('创建成功') + showSuccess(isDraft ? '保存为草稿成功' : '发布成功') this.refreshPrevPage() setTimeout(() => { wx.navigateBack() @@ -767,9 +919,10 @@ Page({ // 只上传步骤图片 this.uploadStepImages(dish.id, tempStepImages) .then(() => { + const isDraft = dishData.status === 'draft' this.setData({ isSaving: false }) hideLoading() - showSuccess('创建成功') + showSuccess(isDraft ? '保存为草稿成功' : '发布成功') this.refreshPrevPage() setTimeout(() => { wx.navigateBack() @@ -802,8 +955,9 @@ Page({ const token = app.globalData.token // 逐个上传步骤图片 - const uploadPromises = stepImages.map(item => { + const uploadPromises = stepImages.map((item, index) => { return new Promise((resolve, reject) => { + console.log(`开始上传步骤图片 ${item.stepIndex + 1}:`, item.tempPath) wx.uploadFile({ url: `${baseUrl}/api/dishes/dishes/${dishId}/steps/${item.stepIndex + 1}/`, // step_number 从1开始 filePath: item.tempPath, @@ -812,16 +966,30 @@ Page({ 'Authorization': `Bearer ${token}` }, success: (res) => { + console.log(`步骤图片上传响应 ${item.stepIndex + 1}:`, res.statusCode, res.data) if (res.statusCode === 200 || res.statusCode === 201) { - resolve(JSON.parse(res.data)) + try { + const data = JSON.parse(res.data) + console.log(`步骤图片上传成功 ${item.stepIndex + 1}:`, data) + resolve(data) + } catch (e) { + console.error(`解析响应数据失败 ${item.stepIndex + 1}:`, e, res.data) + reject(new Error('服务器返回数据格式错误')) + } } else { - const errorData = res.data ? JSON.parse(res.data) : {} - reject(new Error(errorData.error || '上传失败')) + try { + const errorData = res.data ? JSON.parse(res.data) : {} + console.error(`步骤图片上传失败 ${item.stepIndex + 1}:`, res.statusCode, errorData) + reject(new Error(errorData.error || `上传失败,状态码: ${res.statusCode}`)) + } catch (e) { + console.error(`步骤图片上传失败 ${item.stepIndex + 1}:`, res.statusCode, res.data) + reject(new Error(`上传失败,状态码: ${res.statusCode}`)) + } } }, fail: (err) => { - console.error('上传步骤图片失败:', err) - reject(err) + console.error(`步骤图片上传失败 ${item.stepIndex + 1}:`, err) + reject(new Error(err.errMsg || '网络错误,请检查网络连接')) } }) }) @@ -876,6 +1044,7 @@ Page({ uploadImagesToDish(dishId, tempImages) { const uploadPromises = tempImages.map((imageData, index) => { return new Promise((resolve, reject) => { + console.log(`开始上传菜品图片 ${index}:`, imageData.tempPath) wx.uploadFile({ url: `${app.globalData.baseUrl}/api/chef/dishes/${dishId}/upload_image/`, filePath: imageData.tempPath, @@ -887,14 +1056,31 @@ Page({ 'Authorization': `Bearer ${app.globalData.token}` }, success: (res) => { + console.log(`菜品图片上传响应 ${index}:`, res.statusCode, res.data) if (res.statusCode === 201) { - const data = JSON.parse(res.data) - resolve(data) + try { + const data = JSON.parse(res.data) + console.log(`菜品图片上传成功 ${index}:`, data) + resolve(data) + } catch (e) { + console.error(`解析响应数据失败 ${index}:`, e, res.data) + reject(new Error('服务器返回数据格式错误')) + } } else { - reject(new Error('上传失败')) + try { + const errorData = JSON.parse(res.data) + console.error(`菜品图片上传失败 ${index}:`, res.statusCode, errorData) + reject(new Error(errorData.error || `上传失败,状态码: ${res.statusCode}`)) + } catch (e) { + console.error(`菜品图片上传失败 ${index}:`, res.statusCode, res.data) + reject(new Error(`上传失败,状态码: ${res.statusCode}`)) + } } }, - fail: reject + fail: (err) => { + console.error(`菜品图片上传失败 ${index}:`, err) + reject(new Error(err.errMsg || '网络错误,请检查网络连接')) + } }) }) }) @@ -989,6 +1175,53 @@ Page({ 'formData.images': images }) } + }, + + /** + * 预览菜品图片 + */ + previewDishImage(e) { + const index = e.currentTarget.dataset.index + const currentUrl = e.currentTarget.dataset.url + const images = this.data.formData.images + + // 收集所有有效的图片URL(包括临时路径) + const urls = images + .map(img => img.image_url || img.tempPath) + .filter(url => url && url !== '/images/default-dish.png') + + if (urls.length === 0) { + showError('没有可预览的图片') + return + } + + // 找到当前图片在列表中的位置 + let currentIndex = urls.indexOf(currentUrl) + if (currentIndex === -1) { + currentIndex = 0 + } + + wx.previewImage({ + current: urls[currentIndex] || urls[0], + urls: urls + }) + }, + + /** + * 预览步骤图片 + */ + previewStepImage(e) { + const url = e.currentTarget.dataset.url + + if (!url) { + showError('图片不存在') + return + } + + wx.previewImage({ + current: url, + urls: [url] + }) } }) diff --git a/miniprogram/pages/chef/dish-edit/dish-edit.wxml b/miniprogram/pages/chef/dish-edit/dish-edit.wxml index b34aca99d0d47bc7e081d9f007f104210fc99680..487bb01cded60b2daaeb8f18e61dc3ca47fc4805 100644 --- a/miniprogram/pages/chef/dish-edit/dish-edit.wxml +++ b/miniprogram/pages/chef/dish-edit/dish-edit.wxml @@ -8,21 +8,28 @@ - *菜品分类 - + *菜品类型 + - {{categories[formData.category === 'vegetable' ? 0 : formData.category === 'protein' ? 1 : formData.category === 'carb' ? 2 : 3].label}} + {{selectedDishTypeLabel}} + {{validationErrors.dish_type}} - 状态 - - - {{statusOptions[formData.status === 'published' ? 0 : 1].label}} + 营养成分分类(多选) + + + {{item.name}} - + @@ -34,11 +41,15 @@ mode="aspectFill" binderror="onImageError" bindload="onImageLoad" - data-index="{{index}}"> - × + bindtap="previewDishImage" + data-index="{{index}}" + data-url="{{item.image_url || item.tempPath}}"> + + × + - + + + @@ -46,7 +57,9 @@ 食材清单 - + + + + @@ -60,7 +73,9 @@ - × + + × + {{validationErrors.ingredients}} @@ -70,7 +85,9 @@ 制作步骤 - + + + + @@ -80,9 +97,13 @@ - × + + × + + + + × - × @@ -90,11 +111,17 @@ - - × + + + × + - + + + @@ -104,7 +131,9 @@ - + + + diff --git a/miniprogram/pages/chef/dish-edit/dish-edit.wxss b/miniprogram/pages/chef/dish-edit/dish-edit.wxss index f4a38ea1f132d3500c3aad14a373292c55be5d72..07359bd3fa6eca802a97a343f5cfca24e5580d45 100644 --- a/miniprogram/pages/chef/dish-edit/dish-edit.wxss +++ b/miniprogram/pages/chef/dish-edit/dish-edit.wxss @@ -37,12 +37,19 @@ right: -10rpx; width: 40rpx; height: 40rpx; - line-height: 40rpx; - text-align: center; + display: flex; + align-items: center; + justify-content: center; background-color: #ff4d4f; color: #fff; border-radius: 50%; +} + +.remove-icon { font-size: 32rpx; + line-height: 1; + margin: 0; + padding: 0; } .add-image-btn { @@ -53,10 +60,16 @@ display: flex; align-items: center; justify-content: center; - font-size: 60rpx; color: #d9d9d9; } +.add-image-icon { + font-size: 60rpx; + line-height: 1; + margin: 0; + padding: 0; +} + .form-label-row { display: flex; justify-content: space-between; @@ -64,15 +77,35 @@ margin-bottom: 20rpx; } -.add-ingredient-btn { - padding: 0 20rpx; +/* 添加按钮(右侧+号) */ +.add-btn { + width: 50rpx; height: 50rpx; - line-height: 50rpx; - font-size: 24rpx; + min-width: 50rpx; + min-height: 50rpx; background-color: #1890ff; color: #fff; - border: none; - border-radius: 4rpx; + border-radius: 50%; + position: relative; + flex-shrink: 0; + padding: 0; + margin: 0; + box-sizing: border-box; +} + +.add-btn-text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 40rpx; + font-weight: normal; + line-height: 1; + margin: 0; + padding: 0; + color: #fff; + text-align: center; + white-space: nowrap; } .ingredient-list { @@ -111,24 +144,67 @@ .remove-ingredient-btn { width: 40rpx; height: 40rpx; - line-height: 40rpx; - text-align: center; + display: flex; + align-items: center; + justify-content: center; background-color: #ff4d4f; color: #fff; border-radius: 50%; +} + +.remove-ingredient-btn .remove-icon { font-size: 28rpx; + line-height: 1; + margin: 0; + padding: 0; +} + +/* 底部操作栏 */ +.bottom-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: #fff; + border-top: 1rpx solid #f0f0f0; + padding: 20rpx 30rpx; + display: flex; + gap: 20rpx; + z-index: 100; + box-shadow: 0 -2rpx 12rpx rgba(0,0,0,0.1); } .save-btn { - width: 100%; + flex: 1; height: 80rpx; line-height: 80rpx; font-size: 32rpx; + font-weight: bold; + border-radius: 25rpx; + border: none; display: flex; align-items: center; justify-content: center; } +.btn-primary { + background: linear-gradient(135deg, #1890ff, #40a9ff); + color: white; +} + +.btn-secondary { + background: #f0f0f0; + color: #666; +} + +.btn-secondary:active { + background: #e0e0e0; +} + +.btn-primary:active { + transform: scale(0.98); +} + /* 错误文本样式 */ .error-text { color: #ff4d4f; @@ -198,23 +274,37 @@ .remove-step-btn { width: 50rpx; height: 50rpx; - line-height: 50rpx; - text-align: center; + display: flex; + align-items: center; + justify-content: center; background: #ff4d4f; color: #fff; border-radius: 50%; +} + +.remove-step-btn .remove-icon { font-size: 32rpx; + line-height: 1; + margin: 0; + padding: 0; } .remove-step-btn-alone { width: 50rpx; height: 50rpx; - line-height: 50rpx; - text-align: center; + display: flex; + align-items: center; + justify-content: center; background: #ff4d4f; color: #fff; border-radius: 50%; +} + +.remove-step-btn-alone .remove-icon { font-size: 32rpx; + line-height: 1; + margin: 0; + padding: 0; } .step-description { @@ -236,12 +326,10 @@ .step-image-item { position: relative; width: 200rpx; - height: 200rpx; } .step-preview-image { width: 100%; - height: 100%; border-radius: 8rpx; } @@ -251,12 +339,19 @@ right: -10rpx; width: 40rpx; height: 40rpx; - line-height: 40rpx; - text-align: center; + display: flex; + align-items: center; + justify-content: center; background-color: #ff4d4f; color: #fff; border-radius: 50%; +} + +.step-image-remove-btn .remove-icon { font-size: 32rpx; + line-height: 1; + margin: 0; + padding: 0; } .step-add-image-btn { @@ -267,10 +362,16 @@ display: flex; align-items: center; justify-content: center; - font-size: 60rpx; color: #d9d9d9; } +.step-add-image-btn .add-image-icon { + font-size: 60rpx; + line-height: 1; + margin: 0; + padding: 0; +} + /* 单位选择器样式 */ .ingredient-input-wrapper { flex: 1; @@ -288,15 +389,31 @@ color: #333; } -/* 添加步骤按钮 */ -.add-step-btn { - padding: 0 20rpx; - height: 50rpx; - line-height: 50rpx; - font-size: 24rpx; - background-color: #52c41a; +/* 营养成分分类按钮样式 */ +.nutrition-categories-list { + display: flex; + flex-wrap: wrap; + gap: 15rpx; + margin-top: 20rpx; +} + +.nutrition-category-btn { + padding: 15rpx 30rpx; + background-color: #f5f5f5; + border: 2rpx solid #d9d9d9; + border-radius: 25rpx; + font-size: 26rpx; + color: #333; + transition: all 0.3s; +} + +.nutrition-category-btn.selected { + background-color: #1890ff; + border-color: #1890ff; color: #fff; - border: none; - border-radius: 4rpx; +} + +.nutrition-category-btn:active { + transform: scale(0.95); } diff --git a/miniprogram/pages/chef/dishes/dishes.js b/miniprogram/pages/chef/dishes/dishes.js index 570df9c2fd14c196c02f38cbbbee92cdb45d117f..c5a11a7d111e341bf00a926c1b06c0363fb07869 100644 --- a/miniprogram/pages/chef/dishes/dishes.js +++ b/miniprogram/pages/chef/dishes/dishes.js @@ -1,6 +1,7 @@ // pages/chef/dishes/dishes.js const { get, del, put } = require('../../../utils/request') const { showLoading, hideLoading, showSuccess, showError, showConfirm } = require('../../../utils/util') +const { imageManager } = require('../../../utils/imageManager') Page({ data: { @@ -70,17 +71,10 @@ Page({ .then(res => { console.log('菜品列表数据:', res) - // 处理图片URL,确保是完整的绝对路径 + // 使用 imageManager 处理图片 const dishes = (res.results || res).map(dish => { if (dish.main_image) { - // 如果图片URL不是完整的HTTP/HTTPS URL,则拼接baseUrl - if (!dish.main_image.startsWith('http')) { - const app = getApp() - dish.main_image = `${app.globalData.baseUrl}${dish.main_image.startsWith('/') ? '' : '/'}${dish.main_image}` - } - // 添加时间戳参数避免缓存 - const timestamp = new Date().getTime() - dish.main_image = `${dish.main_image}?t=${timestamp}` + dish.main_image = imageManager.processImageUrl(dish.main_image) } return dish }) diff --git a/miniprogram/pages/chef/gourmet-plans/gourmet-plans.js b/miniprogram/pages/chef/gourmet-plans/gourmet-plans.js index 971a80d90ec2532bdfd32c98fa192ffdd857b8b5..4ce56cbcacd5b688efce85b2c8de1eeeb5173112 100644 --- a/miniprogram/pages/chef/gourmet-plans/gourmet-plans.js +++ b/miniprogram/pages/chef/gourmet-plans/gourmet-plans.js @@ -1,191 +1,129 @@ -// 食神详细配餐计划页面 +// 厨神查看食神配餐计划页面(只读模式) const { get } = require('../../../utils/request') -const { formatDate, showError, showLoading, hideLoading } = require('../../../utils/util') +const { formatDate, showError } = require('../../../utils/util') Page({ data: { gourmetId: null, gourmet: null, date: formatDate(new Date()), - startDate: formatDate(new Date()), - endDate: formatDate(new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)), - plans: [], - stats: null, - pagination: null, loading: false, - mealType: '', // 餐别筛选 - page: 1, - pageSize: 20, - hasMore: true + + // 配餐计划数据(按餐别组织) + mealTypes: ['breakfast', 'lunch', 'dinner'], + mealTypeNames: { + breakfast: '早餐', + lunch: '午餐', + dinner: '晚餐' + }, + plans: {}, // { breakfast: plan, lunch: plan, dinner: plan } + + // 分类名称映射 + categoryNames: {} }, onLoad(options) { + console.log('厨神配餐计划页面 onLoad,参数:', options) const { gourmet_id, date } = options + if (gourmet_id) { this.setData({ gourmetId: parseInt(gourmet_id) }) } + if (date) { this.setData({ date }) } - this.loadGourmetInfo() - this.loadPlans() + // 加载数据 + this.loadData() }, - + + // 加载数据 + loadData() { + if (!this.data.gourmetId || !this.data.date) { + return + } + + this.setData({ loading: true }) + + // 加载食神信息和配餐计划 + Promise.all([ + this.loadGourmetInfo(), + this.loadPlans() + ]).finally(() => { + this.setData({ loading: false }) + }) + }, + // 加载食神信息 loadGourmetInfo() { - if (!this.data.gourmetId) return + if (!this.data.gourmetId) return Promise.resolve() - // 这里可以从绑定关系API获取食神信息 - // 暂时使用模拟数据 - this.setData({ - gourmet: { - id: this.data.gourmetId, - nickname: '测试食神', - avatar_url: '/images/default-avatar.png' - } + // 从配餐计划API获取食神信息(API会返回gourmet信息) + return get('/api/gourmet/chef/gourmet-plans/', { + gourmet_id: this.data.gourmetId, + start_date: this.data.date, + end_date: this.data.date }) + .then(res => { + if (res.gourmet) { + this.setData({ gourmet: res.gourmet }) + } + }) + .catch(err => { + console.error('加载食神信息失败:', err) + // 即使失败也继续 + }) }, - + // 加载配餐计划 - loadPlans(refresh = false) { - if (!this.data.gourmetId) return - - if (refresh) { - this.setData({ page: 1, hasMore: true }) + loadPlans() { + if (!this.data.gourmetId || !this.data.date) { + return Promise.resolve() } - - this.setData({ loading: true }) - const params = { + return get('/api/gourmet/chef/gourmet-plans/', { gourmet_id: this.data.gourmetId, - start_date: this.data.startDate, - end_date: this.data.endDate, - page: this.data.page, - page_size: this.data.pageSize - } - - if (this.data.mealType) { - params.meal_type = this.data.mealType - } - - get('/api/gourmet/chef/gourmet-plans/', params) + start_date: this.data.date, + end_date: this.data.date + }) .then(res => { - const newPlans = refresh ? res.plans : [...this.data.plans, ...res.plans] + console.log('配餐计划数据:', res) + + // 按餐别组织计划数据 + const plans = {} + const mealTypes = ['breakfast', 'lunch', 'dinner'] - this.setData({ - plans: newPlans, - stats: res.stats, - pagination: res.pagination, - loading: false, - hasMore: res.pagination.has_next + mealTypes.forEach(mealType => { + // 从返回的计划列表中找到对应餐别的计划 + const plan = (res.plans || []).find(p => p.meal_type === mealType) + if (plan) { + plans[mealType] = plan + } }) + + this.setData({ plans }) }) .catch(err => { - this.setData({ loading: false }) - showError(err.message || '加载失败') + console.error('加载配餐计划失败:', err) + showError(err.message || '加载配餐计划失败') }) }, - - // 加载更多 - loadMore() { - if (!this.data.hasMore || this.data.loading) return - - this.setData({ page: this.data.page + 1 }) - this.loadPlans() - }, - - // 刷新数据 - refresh() { - this.loadPlans(true) - }, - - // 切换餐别筛选 - changeMealType(e) { - const mealType = e.detail.value - this.setData({ - mealType: mealType === 'all' ? '' : mealType - }) - this.loadPlans(true) - }, - - // 日期范围改变 - changeDateRange(e) { - const { type } = e.currentTarget.dataset - const value = e.detail.value - - if (type === 'start') { - this.setData({ startDate: value }) - } else { - this.setData({ endDate: value }) + + // 查看菜品详情 + viewDishDetail(e) { + const dishId = e.currentTarget.dataset.id + if (dishId) { + wx.navigateTo({ + url: `/pages/gourmet/dish-detail/dish-detail?id=${dishId}` + }) } - - this.loadPlans(true) }, - // 查看计划详情 - viewPlanDetail(e) { - const planId = e.currentTarget.dataset.id - wx.navigateTo({ - url: `/pages/chef/plan-detail/plan-detail?id=${planId}` + // 下拉刷新 + onPullDownRefresh() { + this.loadData().finally(() => { + wx.stopPullDownRefresh() }) - }, - - // 复制计划 - copyPlan(e) { - const planId = e.currentTarget.dataset.id - wx.showModal({ - title: '复制计划', - content: '是否复制此配餐计划?', - success: (res) => { - if (res.confirm) { - // 这里可以实现复制计划的功能 - wx.showToast({ - title: '复制成功', - icon: 'success' - }) - } - } - }) - }, - - // 导出计划 - exportPlans() { - if (this.data.plans.length === 0) { - return showError('暂无计划可导出') - } - - const data = { - gourmet: this.data.gourmet, - date_range: { - start_date: this.data.startDate, - end_date: this.data.endDate - }, - stats: this.data.stats, - plans: this.data.plans - } - - wx.showModal({ - title: '导出计划', - content: '是否将配餐计划数据复制到剪贴板?', - success: (res) => { - if (res.confirm) { - wx.setClipboardData({ - data: JSON.stringify(data, null, 2), - success: () => { - wx.showToast({ - title: '已复制到剪贴板', - icon: 'success' - }) - } - }) - } - } - }) - }, - - // 返回上一页 - goBack() { - wx.navigateBack() } }) diff --git a/miniprogram/pages/chef/gourmet-plans/gourmet-plans.json b/miniprogram/pages/chef/gourmet-plans/gourmet-plans.json index 414700cf7b807e839936aa926b9c618eb81c8181..23322fd3d5a46a59d4d5f84f89175d2cc926450d 100644 --- a/miniprogram/pages/chef/gourmet-plans/gourmet-plans.json +++ b/miniprogram/pages/chef/gourmet-plans/gourmet-plans.json @@ -1,5 +1,5 @@ { - "navigationBarTitleText": "食神配餐计划", + "navigationBarTitleText": "配餐计划", "enablePullDownRefresh": true, "backgroundTextStyle": "dark" } diff --git a/miniprogram/pages/chef/gourmet-plans/gourmet-plans.wxml b/miniprogram/pages/chef/gourmet-plans/gourmet-plans.wxml index 6654e50d9c05ab18b0116f1b140e8e8bfb766078..dc60ebe5f0192a864cf2697a8478cba968f140bc 100644 --- a/miniprogram/pages/chef/gourmet-plans/gourmet-plans.wxml +++ b/miniprogram/pages/chef/gourmet-plans/gourmet-plans.wxml @@ -2,137 +2,61 @@ - - - + + 配餐计划 + {{date}} + + + + + - {{gourmet.nickname}} - 配餐计划详情 + {{gourmet.nickname || '食神' + gourmet.id}} + 的配餐计划 - - - - - - - 开始日期 - {{startDate}} - - - - - - 结束日期 - {{endDate}} - - - - - - - - {{mealType === '' ? '全部餐别' : mealType === 'breakfast' ? '早餐' : mealType === 'lunch' ? '午餐' : '晚餐'}} - > - - - + + + 加载中... - - - - - {{stats.total_plans}} - 总计划 - - - {{stats.breakfast_count}} - 早餐 - - - {{stats.lunch_count}} - 午餐 - - - {{stats.dinner_count}} - 晚餐 + + + + + + {{mealTypeNames[mealType]}} + - - - - - - 配餐计划 - - - - - - - - {{item.date}} - {{item.meal_type === 'breakfast' ? '早餐' : item.meal_type === 'lunch' ? '午餐' : '晚餐'}} - - - - - + + + + 📦 {{plans[mealType].meal_set.name}} + {{plans[mealType].meal_set.dishes.length}}道菜 - - - - 菜品 ({{item.dishes.length}}) - - - - - {{dish.name}} - {{dish.category === 'vegetable' ? '蔬菜' : dish.category === 'protein' ? '蛋白质' : dish.category === 'carb' ? '碳水' : '脂肪'}} - - - - - - - 套餐 - - {{item.meal_set.name}} - {{item.meal_set.dishes.length}}个菜品 + + + + + + + + + {{dish.name}} + {{dish.dish_type_display || dish.category_display || '未分类'}} - - - 备注 - {{item.notes}} - - - - - {{loading ? '加载中...' : '加载更多'}} - - - - - 📅 - 该时间段内暂无配餐计划 + + + 暂无配餐计划 + diff --git a/miniprogram/pages/chef/gourmet-plans/gourmet-plans.wxss b/miniprogram/pages/chef/gourmet-plans/gourmet-plans.wxss index d7d689087fb43a43e3279fda8a5842d83491e017..df48276b0f3157fbdbebaed98693980472de3645 100644 --- a/miniprogram/pages/chef/gourmet-plans/gourmet-plans.wxss +++ b/miniprogram/pages/chef/gourmet-plans/gourmet-plans.wxss @@ -1,398 +1,197 @@ -/* 食神详细配餐计划页面样式 */ .container { - background-color: #f5f5f5; + padding: 20rpx; + background: #f5f5f5; min-height: 100vh; } -/* 头部 */ +/* 头部样式 */ .header { - background: white; - padding: 20rpx; - box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1); + background: #fff; + border-radius: 16rpx; + padding: 30rpx; + margin-bottom: 20rpx; + box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); } .header-content { display: flex; - align-items: center; - justify-content: space-between; + flex-direction: column; + gap: 20rpx; } -.btn-back { - width: 60rpx; - height: 60rpx; - background: #f8f9fa; - border: none; - border-radius: 50%; +.header-top { display: flex; align-items: center; - justify-content: center; + justify-content: space-between; } -.btn-back .icon { - font-size: 32rpx; +.title { + font-size: 36rpx; + font-weight: bold; + color: #333; +} + +.date { + font-size: 28rpx; color: #666; } .gourmet-info { display: flex; align-items: center; - flex: 1; - margin: 0 20rpx; + gap: 16rpx; + padding-top: 20rpx; + border-top: 1rpx solid #f0f0f0; } .gourmet-avatar { width: 80rpx; height: 80rpx; border-radius: 50%; - margin-right: 20rpx; + border: 2rpx solid #e9ecef; } .gourmet-details { display: flex; flex-direction: column; + gap: 4rpx; } .gourmet-name { - font-size: 32rpx; + font-size: 30rpx; font-weight: bold; color: #333; - margin-bottom: 4rpx; } .gourmet-subtitle { font-size: 24rpx; - color: #666; -} - -.btn-export { - width: 60rpx; - height: 60rpx; - background: #f8f9fa; - border: none; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; -} - -.btn-export .icon { - font-size: 32rpx; - color: #666; -} - -/* 筛选器 */ -.filters { - background: white; - padding: 20rpx; - margin: 20rpx; - border-radius: 12rpx; - box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1); -} - -.date-range { - display: flex; - align-items: center; - margin-bottom: 20rpx; -} - -.date-picker { - display: flex; - flex-direction: column; - align-items: center; - padding: 16rpx; - background: #f8f9fa; - border-radius: 8rpx; - flex: 1; -} - -.date-label { - font-size: 24rpx; - color: #666; - margin-bottom: 8rpx; -} - -.date-value { - font-size: 28rpx; - font-weight: bold; - color: #333; -} - -.date-separator { - font-size: 24rpx; - color: #666; - margin: 0 16rpx; -} - -.meal-type-filter { - margin-top: 16rpx; -} - -.picker-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 20rpx; - background: #f8f9fa; - border-radius: 8rpx; - font-size: 28rpx; - color: #333; -} - -.picker-arrow { color: #999; - font-size: 24rpx; } -/* 统计信息 */ -.stats-section { - margin: 20rpx; - background: white; - padding: 20rpx; - border-radius: 12rpx; - box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1); -} - -.stats-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 16rpx; -} - -.stat-item { +/* 餐次列表 */ +.meal-list { display: flex; flex-direction: column; - align-items: center; - padding: 20rpx; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - border-radius: 12rpx; - color: white; -} - -.stat-number { - font-size: 36rpx; - font-weight: bold; - margin-bottom: 8rpx; -} - -.stat-label { - font-size: 24rpx; - opacity: 0.9; + gap: 20rpx; } -/* 配餐计划列表 */ -.plans-section { - margin: 20rpx; +.meal-card { + background: #fff; + border-radius: 16rpx; + padding: 24rpx; + box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); } -.section-header { +.meal-header { display: flex; - justify-content: space-between; align-items: center; + justify-content: space-between; margin-bottom: 20rpx; + padding-bottom: 16rpx; + border-bottom: 1rpx solid #f0f0f0; } -.section-title { - font-size: 32rpx; - font-weight: bold; - color: #333; -} - -.btn-refresh { - width: 60rpx; - height: 60rpx; - background: #f8f9fa; - border: none; - border-radius: 50%; +.meal-info { display: flex; align-items: center; - justify-content: center; + gap: 12rpx; } -.btn-refresh .icon { +.meal-name { font-size: 32rpx; - color: #666; + font-weight: bold; + color: #333; } -.plans-list { - display: flex; - flex-direction: column; - gap: 20rpx; +/* 套餐信息 */ +.meal-set-info { + margin-bottom: 20rpx; } -.plan-card { - background: white; +.meal-set-card { + background: linear-gradient(135deg, #e6f7ff, #bae7ff); border-radius: 12rpx; padding: 20rpx; - box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1); -} - -.plan-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 16rpx; - padding-bottom: 16rpx; - border-bottom: 1rpx solid #f0f0f0; -} - -.plan-date { display: flex; flex-direction: column; + gap: 8rpx; } -.date { +.meal-set-name { font-size: 28rpx; font-weight: bold; - color: #333; - margin-bottom: 4rpx; -} - -.meal-type { - font-size: 24rpx; - color: #666; + color: #1890ff; } -.plan-actions { - display: flex; - gap: 12rpx; -} - -.btn-action { - width: 48rpx; - height: 48rpx; - background: #f8f9fa; - border: none; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; -} - -.btn-action .icon { +.meal-set-dishes { font-size: 24rpx; color: #666; } -.plan-content { +/* 菜品列表 */ +.dishes-list { display: flex; flex-direction: column; gap: 16rpx; } -.dishes-section, .meal-set-section, .notes-section { - display: flex; - flex-direction: column; -} - -.dishes-title, .meal-set-title, .notes-title { - font-size: 26rpx; - font-weight: bold; - color: #333; - margin-bottom: 12rpx; -} - -.dishes-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200rpx, 1fr)); - gap: 12rpx; +.dish-item { + background: #fafafa; + border-radius: 12rpx; + overflow: hidden; } -.dish-item { +.dish-content { display: flex; - flex-direction: column; align-items: center; + gap: 16rpx; padding: 16rpx; - background: #f8f9fa; - border-radius: 8rpx; } .dish-image { - width: 80rpx; - height: 80rpx; - border-radius: 8rpx; - margin-bottom: 8rpx; + width: 120rpx; + height: 120rpx; + border-radius: 12rpx; + flex-shrink: 0; } .dish-info { + flex: 1; display: flex; flex-direction: column; - align-items: center; + gap: 8rpx; } .dish-name { - font-size: 24rpx; + font-size: 30rpx; + font-weight: bold; color: #333; - margin-bottom: 4rpx; - text-align: center; } .dish-category { - font-size: 20rpx; - color: #666; -} - -.meal-set-item { - padding: 16rpx; - background: #f8f9fa; - border-radius: 8rpx; -} - -.meal-set-name { - font-size: 26rpx; - color: #333; - margin-bottom: 4rpx; - display: block; -} - -.meal-set-dishes { - font-size: 22rpx; - color: #666; -} - -.notes-content { font-size: 24rpx; color: #666; - line-height: 1.5; - padding: 16rpx; - background: #f8f9fa; + background: #e6f7ff; + padding: 4rpx 12rpx; border-radius: 8rpx; -} - -/* 加载更多 */ -.load-more { - display: flex; - justify-content: center; - align-items: center; - padding: 40rpx; - background: white; - border-radius: 12rpx; - margin-top: 20rpx; - box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1); -} - -.load-more-text { - font-size: 28rpx; - color: #666; + align-self: flex-start; } /* 空状态 */ .empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 100rpx 20rpx; - background: white; - border-radius: 12rpx; - box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1); -} - -.empty-icon { - font-size: 80rpx; - margin-bottom: 20rpx; + padding: 60rpx 20rpx; + text-align: center; } .empty-text { font-size: 28rpx; + color: #999; +} + +/* 加载状态 */ +.loading { + text-align: center; + padding: 60rpx 20rpx; color: #666; + font-size: 28rpx; } diff --git a/miniprogram/pages/chef/gourmets/gourmets.js b/miniprogram/pages/chef/gourmets/gourmets.js index 81f6f25131731eaf76261fee1132c5b421c39ac1..abb28948a6928041fd0e3ae81304668724ca2e75 100644 --- a/miniprogram/pages/chef/gourmets/gourmets.js +++ b/miniprogram/pages/chef/gourmets/gourmets.js @@ -20,9 +20,19 @@ Page({ loadBindings() { this.setData({ loading: true }) + // 厨神端默认获取所有状态的绑定关系(包括待处理、已同意等) get('/api/users/bindings/') .then(res => { - this.setData({ bindings: res.results || res, loading: false }) + const bindings = res.results || res.bindings || res || [] + // 按状态排序:待处理优先,然后按申请时间倒序 + const sortedBindings = bindings.sort((a, b) => { + if (a.status === 'pending' && b.status !== 'pending') return -1 + if (a.status !== 'pending' && b.status === 'pending') return 1 + const timeA = new Date(a.applied_at || a.created_at || 0) + const timeB = new Date(b.applied_at || b.created_at || 0) + return timeB - timeA + }) + this.setData({ bindings: sortedBindings, loading: false }) }) .catch(() => { this.setData({ loading: false }) diff --git a/miniprogram/pages/chef/gourmets/gourmets.wxml b/miniprogram/pages/chef/gourmets/gourmets.wxml index f3ef61cea7909fbf08b1a00e1dd9ddfc71bd40d5..d8d50262d1f7e29b660985f87a9429749e771b84 100644 --- a/miniprogram/pages/chef/gourmets/gourmets.wxml +++ b/miniprogram/pages/chef/gourmets/gourmets.wxml @@ -10,7 +10,7 @@ {{item.gourmet.nickname || '未设置昵称'}} {{statusMap[item.status]}} - {{item.created_at}} + {{item.applied_at || item.created_at}} diff --git a/miniprogram/pages/chef/meal-set-edit/meal-set-edit.js b/miniprogram/pages/chef/meal-set-edit/meal-set-edit.js index 752110c136320d9a198980a610603908728c642c..8c5a0df54749fdb2866952071ad8fe14c6d01c8e 100644 --- a/miniprogram/pages/chef/meal-set-edit/meal-set-edit.js +++ b/miniprogram/pages/chef/meal-set-edit/meal-set-edit.js @@ -96,7 +96,7 @@ Page({ const dish = this.data.dishes.find(d => d.id === dishId) if (!dish) return - let dishIds = [...this.data.formData.dish_ids] + let dishIds = this.data.formData.dish_ids.slice() const index = dishIds.indexOf(dishId) if (index > -1) { @@ -132,7 +132,7 @@ Page({ const selectedDishes = this.data.dishes.filter(dish => formData.dish_ids.includes(dish.id) ) - const selectedCategories = [...new Set(selectedDishes.map(dish => dish.category))] + const selectedCategories = Array.from(new Set(selectedDishes.map(dish => dish.category))) // 检查是否包含所有必需分类 const missingCategories = requiredCategories.filter(cat => diff --git a/miniprogram/pages/chef/schedule/schedule.js b/miniprogram/pages/chef/schedule/schedule.js index 72d9eaffe2332087431db4b4f475ab02acf20e88..8da99fbbf26f935f4b9a72803c24068619d46245 100644 --- a/miniprogram/pages/chef/schedule/schedule.js +++ b/miniprogram/pages/chef/schedule/schedule.js @@ -84,7 +84,7 @@ Page({ // 切换食神选择 toggleGourmet(e) { const gourmetId = e.currentTarget.dataset.id - let selectedIds = [...this.data.selectedGourmetIds] + let selectedIds = this.data.selectedGourmetIds.slice() const index = selectedIds.indexOf(gourmetId) if (index > -1) { diff --git a/miniprogram/pages/chef/shopping-list/shopping-list.js b/miniprogram/pages/chef/shopping-list/shopping-list.js index 02ce0e5040fa057c033c59df820b53c6be624444..dd4d4b5b901c8b06f533893d0f1df77fdfc1181a 100644 --- a/miniprogram/pages/chef/shopping-list/shopping-list.js +++ b/miniprogram/pages/chef/shopping-list/shopping-list.js @@ -1,6 +1,6 @@ // 采购清单 - 增强版 const { get } = require('../../../utils/request') -const { formatDate, showError, showLoading, hideLoading } = require('../../../utils/util') +const { formatDate, showError, showSuccess, showLoading, hideLoading } = require('../../../utils/util') Page({ data: { @@ -15,7 +15,44 @@ Page({ includeNutritionInfo: false, // 是否包含营养信息 mealTypes: [], // 餐别筛选 showFilters: false, // 是否显示筛选器 - exportFormat: 'list' // 导出格式:list, categorized + exportFormat: 'list', // 导出格式:list, categorized + dates: '', // 从计划页面传入的日期列表(逗号分隔) + datesCount: 0, // 日期数量 + fromPlanPage: false // 是否从计划页面跳转过来 + }, + + onLoad(options) { + console.log('[ShoppingList] 页面加载', { + options, + timestamp: new Date().toISOString() + }) + + // 从URL参数中获取日期和食神ID + if (options.gourmetId) { + console.log('[ShoppingList] 从参数获取食神ID', { gourmetId: options.gourmetId }) + this.setData({ + selectedGourmetIds: [parseInt(options.gourmetId)], + selectAll: false + }) + } + if (options.startDate) { + console.log('[ShoppingList] 从参数获取开始日期', { startDate: options.startDate }) + this.setData({ startDate: options.startDate }) + } + if (options.endDate) { + console.log('[ShoppingList] 从参数获取结束日期', { endDate: options.endDate }) + this.setData({ endDate: options.endDate }) + } + // 从计划页面传入的日期列表 + if (options.dates) { + console.log('[ShoppingList] 从计划页面传入日期列表', { dates: options.dates }) + const datesArray = options.dates.split(',').filter(d => d) // 过滤空字符串 + this.setData({ + dates: options.dates, + datesCount: datesArray.length, // 计算日期数量 + fromPlanPage: true // 标记为从计划页面跳转 + }) + } }, onShow() { @@ -24,44 +61,81 @@ Page({ // 加载绑定的食神列表 loadGourmets() { - get('/api/chef/bindings/', { status: 'approved' }) + console.log('[ShoppingList] 开始加载食神列表', { timestamp: new Date().toISOString() }) + + get('/api/users/bindings/', { status: 'accepted' }) .then(res => { - const gourmets = (res.results || res || []).filter(binding => - binding.status === 'approved' - ).map(binding => ({ - id: binding.gourmet.id, - name: binding.gourmet.nickname || binding.gourmet.username, - avatar: binding.gourmet.avatar - })) + console.log('[ShoppingList] 加载食神列表成功', { + bindingsCount: (res.results || res || []).length, + timestamp: new Date().toISOString() + }) + const bindings = res.results || res || [] + const gourmets = bindings + .filter(binding => binding.status === 'accepted') + .map(binding => ({ + id: parseInt(binding.gourmet.id), // 确保 ID 为数字类型 + name: binding.gourmet.nickname || binding.gourmet.username || '食神' + binding.gourmet.id, + avatar: binding.gourmet.avatar_url || binding.gourmet.avatar + })) + + // 如果没有从URL参数传入选中的食神,则默认全选 + const selectedIds = this.data.selectedGourmetIds.length > 0 + ? this.data.selectedGourmetIds.filter(id => gourmets.some(g => g.id === id)) + : gourmets.map(g => g.id) + + // 为每个 gourmet 添加 checked 属性 + gourmets.forEach(gourmet => { + gourmet.checked = selectedIds.includes(gourmet.id) + }) this.setData({ gourmets, - selectedGourmetIds: gourmets.map(g => g.id), // 默认全选 - selectAll: true + selectedGourmetIds: selectedIds, + selectAll: selectedIds.length === gourmets.length && gourmets.length > 0 }) - if (gourmets.length > 0) { + // 如果从计划页面跳转过来,自动生成清单 + if (this.data.fromPlanPage && selectedIds.length > 0 && gourmets.length > 0) { + console.log('[ShoppingList] 从计划页面跳转,自动生成采购清单', { + selectedIds, + dates: this.data.dates, + gourmetsCount: gourmets.length, + timestamp: new Date().toISOString() + }) this.generate() } }) .catch(err => { - console.error('加载食神列表失败:', err) + console.error('[ShoppingList] 加载食神列表失败', { + error: err.message || err, + stack: err.stack, + timestamp: new Date().toISOString() + }) + showError(err.message || '加载食神列表失败') }) }, // 切换全选 toggleSelectAll() { const selectAll = !this.data.selectAll + const selectedIds = selectAll ? this.data.gourmets.map(g => g.id) : [] + + // 更新每个 gourmet 的 checked 状态 + const gourmets = this.data.gourmets.map(gourmet => Object.assign({}, gourmet, { + checked: selectedIds.includes(gourmet.id) + })) + this.setData({ selectAll, - selectedGourmetIds: selectAll ? this.data.gourmets.map(g => g.id) : [] + selectedGourmetIds: selectedIds, + gourmets }) }, // 切换食神选择 toggleGourmet(e) { - const gourmetId = e.currentTarget.dataset.id - let selectedIds = [...this.data.selectedGourmetIds] + const gourmetId = parseInt(e.currentTarget.dataset.id) // 确保类型一致 + let selectedIds = this.data.selectedGourmetIds.slice() const index = selectedIds.indexOf(gourmetId) if (index > -1) { @@ -70,9 +144,15 @@ Page({ selectedIds.push(gourmetId) } + // 更新每个 gourmet 的 checked 状态 + const gourmets = this.data.gourmets.map(gourmet => Object.assign({}, gourmet, { + checked: selectedIds.includes(gourmet.id) + })) + this.setData({ selectedGourmetIds: selectedIds, - selectAll: selectedIds.length === this.data.gourmets.length + selectAll: selectedIds.length === this.data.gourmets.length, + gourmets }) }, @@ -100,7 +180,7 @@ Page({ // 切换餐别选择 toggleMealType(e) { const mealType = e.currentTarget.dataset.type - let mealTypes = [...this.data.mealTypes] + let mealTypes = this.data.mealTypes.slice() const index = mealTypes.indexOf(mealType) if (index > -1) { @@ -114,11 +194,26 @@ Page({ // 生成采购清单 generate() { + console.log('[ShoppingList] 开始生成采购清单', { + selectedGourmetIds: this.data.selectedGourmetIds, + startDate: this.data.startDate, + endDate: this.data.endDate, + dates: this.data.dates, + fromPlanPage: this.data.fromPlanPage, + gourmetCount: this.data.selectedGourmetIds.length, + timestamp: new Date().toISOString() + }) + if (this.data.selectedGourmetIds.length === 0) { + console.warn('[ShoppingList] 生成失败:未选择食神') return showError('请选择至少一位食神') } if (this.data.startDate > this.data.endDate) { + console.warn('[ShoppingList] 生成失败:日期范围无效', { + startDate: this.data.startDate, + endDate: this.data.endDate + }) return showError('开始日期不能晚于结束日期') } @@ -133,16 +228,38 @@ Page({ include_nutrition_info: this.data.includeNutritionInfo } + // 如果从计划页面传入日期列表,使用指定的日期 + if (this.data.dates && this.data.dates.length > 0) { + params.dates = this.data.dates // 传递日期列表给后端 + } + if (this.data.mealTypes.length > 0) { params.meal_types = this.data.mealTypes } - get('/api/chef/shopping-list/', params) + console.log('[ShoppingList] 请求参数', Object.assign({}, params, { + timestamp: new Date().toISOString() + })) + + get('/api/gourmet/chef/shopping-list/', params) .then(res => { + console.log('[ShoppingList] 生成采购清单成功', { + shoppingListCount: res.shopping_list?.length || 0, + gourmetCount: res.stats?.gourmet_count || 0, + totalIngredients: res.stats?.total_ingredients || 0, + uniqueIngredients: res.stats?.unique_ingredients || 0, + timestamp: new Date().toISOString() + }) hideLoading() this.setData({ list: res, loading: false }) }) .catch(err => { + console.error('[ShoppingList] 生成采购清单失败', { + params, + error: err.message || err, + stack: err.stack, + timestamp: new Date().toISOString() + }) hideLoading() this.setData({ loading: false }) showError(err.message || '生成失败') @@ -199,10 +316,260 @@ Page({ return showError('暂无采购清单可分享') } + // 生成分享文本 + const shareText = this.generateShareText() + wx.showShareMenu({ withShareTicket: true, menus: ['shareAppMessage', 'shareTimeline'] }) + + // 提示用户可以通过右上角菜单分享 + wx.showToast({ + title: '请点击右上角分享', + icon: 'none', + duration: 2000 + }) + }, + + // 保存为图片 + saveAsImage() { + if (!this.data.list) { + return showError('暂无采购清单可保存') + } + + showLoading('生成图片中...') + + // 使用Canvas绘制采购清单 + const list = this.data.list + const ctx = wx.createCanvasContext('shoppingListCanvas') + + // 设置画布大小 + const canvasWidth = 750 + const canvasHeight = 1200 + let currentY = 40 + + // 绘制背景 + ctx.setFillStyle('#FFFFFF') + ctx.fillRect(0, 0, canvasWidth, canvasHeight) + + // 绘制标题 + ctx.setFillStyle('#333333') + ctx.setFontSize(32) + ctx.setTextAlign('center') + ctx.fillText('📋 采购清单', canvasWidth / 2, currentY) + currentY += 60 + + // 绘制基本信息 + ctx.setFillStyle('#666666') + ctx.setFontSize(24) + ctx.setTextAlign('left') + ctx.fillText(`日期范围:${list.date_range || (this.data.startDate + ' 至 ' + this.data.endDate)}`, 40, currentY) + currentY += 40 + ctx.fillText(`食神数量:${this.data.selectedGourmetIds.length}位`, 40, currentY) + currentY += 60 + + // 绘制分隔线 + ctx.setStrokeStyle('#E0E0E0') + ctx.setLineWidth(2) + ctx.beginPath() + ctx.moveTo(40, currentY) + ctx.lineTo(canvasWidth - 40, currentY) + ctx.stroke() + currentY += 40 + + // 绘制食材清单标题 + ctx.setFillStyle('#333333') + ctx.setFontSize(28) + ctx.fillText('📦 食材清单', 40, currentY) + currentY += 50 + + // 绘制食材列表 + if (list.shopping_list && list.shopping_list.length > 0) { + ctx.setFillStyle('#333333') + ctx.setFontSize(22) + + list.shopping_list.forEach((item, index) => { + if (currentY > canvasHeight - 100) { + return // 防止超出画布 + } + ctx.fillText(`${index + 1}. ${item.name} - ${item.quantity} ${item.unit}`, 60, currentY) + currentY += 40 + }) + } else { + ctx.setFillStyle('#999999') + ctx.setFontSize(20) + ctx.fillText('暂无食材', 60, currentY) + currentY += 40 + } + + currentY += 40 + + // 绘制底部信息 + ctx.setFillStyle('#999999') + ctx.setFontSize(18) + ctx.setTextAlign('center') + ctx.fillText('来自:配膳官小程序', canvasWidth / 2, currentY) + + // 绘制完成 + ctx.draw(false, () => { + // 导出为图片 + wx.canvasToTempFilePath({ + canvasId: 'shoppingListCanvas', + success: (res) => { + hideLoading() + // 保存到相册 + wx.saveImageToPhotosAlbum({ + filePath: res.tempFilePath, + success: () => { + showSuccess('图片已保存到相册') + }, + fail: (err) => { + if (err.errMsg.includes('auth deny')) { + wx.showModal({ + title: '需要授权', + content: '需要您授权保存图片到相册', + success: (modalRes) => { + if (modalRes.confirm) { + wx.openSetting() + } + } + }) + } else { + showError('保存失败') + } + } + }) + }, + fail: () => { + hideLoading() + showError('生成图片失败') + } + }) + }) + }, + + // 保存为PDF + saveAsPDF() { + if (!this.data.list) { + return showError('暂无采购清单可保存') + } + + showLoading('生成PDF中...') + + const app = getApp() + // 获取API地址(注意:app.js中使用的是baseUrl) + const apiUrl = app.globalData.baseUrl || app.globalData.apiUrl || 'https://b106.xyz' + const token = wx.getStorageSync('token') + + console.log('[ShoppingList] API URL:', apiUrl) + console.log('[ShoppingList] Token存在:', !!token) + + if (!token) { + hideLoading() + return showError('请先登录') + } + + // 手动构建查询参数(小程序不支持URLSearchParams) + let params = `start_date=${this.data.startDate}&end_date=${this.data.endDate}` + + // 添加食神ID + this.data.selectedGourmetIds.forEach(id => { + params += `&gourmet_ids=${id}` + }) + + const url = `${apiUrl}/api/gourmet/chef/shopping-list/pdf/?${params}` + + console.log('[ShoppingList] 下载PDF URL:', url) + + // 下载PDF文件 + wx.downloadFile({ + url: url, + header: { + 'Authorization': `Bearer ${token}` + }, + success: (res) => { + console.log('[ShoppingList] PDF下载成功', res) + hideLoading() + if (res.statusCode === 200) { + // 打开文档 + wx.openDocument({ + filePath: res.tempFilePath, + fileType: 'pdf', + showMenu: true, + success: () => { + showSuccess('PDF已生成,可以通过右上角菜单保存或分享') + }, + fail: (err) => { + console.error('打开PDF失败', err) + showError('打开PDF失败') + } + }) + } else { + showError('生成PDF失败') + } + }, + fail: (err) => { + hideLoading() + console.error('[ShoppingList] 下载PDF失败', err) + + // 详细的错误提示 + if (err.errMsg && err.errMsg.includes('url scheme is invalid')) { + wx.showModal({ + title: 'PDF下载失败', + content: '请在小程序后台配置downloadFile合法域名,或在开发工具中勾选"不校验合法域名"选项。\n\n域名: ' + apiUrl, + showCancel: false + }) + } else { + showError('下载PDF失败: ' + (err.errMsg || '未知错误')) + } + } + }) + }, + + // 复制到剪贴板 + copyToClipboard() { + if (!this.data.list) { + return showError('暂无采购清单可复制') + } + + const content = this.generateShareText() + + wx.setClipboardData({ + data: content, + success: () => { + showSuccess('已复制到剪贴板') + }, + fail: () => { + showError('复制失败') + } + }) + }, + + // 生成分享文本 + generateShareText() { + const list = this.data.list + if (!list) return '' + + let text = `📋 采购清单\n\n` + text += `日期范围:${list.date_range || (this.data.startDate + ' 至 ' + this.data.endDate)}\n` + text += `食神数量:${this.data.selectedGourmetIds.length}位\n` + text += `生成时间:${list.generated_at || new Date().toLocaleString()}\n\n` + text += `━━━━━━━━━━━━━━━━━━━━\n\n` + text += `📦 食材清单:\n\n` + + if (list.shopping_list && list.shopping_list.length > 0) { + list.shopping_list.forEach((item, index) => { + text += `${index + 1}. ${item.name} - ${item.quantity} ${item.unit}\n` + }) + } else { + text += `暂无食材\n` + } + + text += `\n━━━━━━━━━━━━━━━━━━━━\n` + text += `来自:配膳官小程序` + + return text }, // 查看食材详情 @@ -250,6 +617,38 @@ Page({ } } }) + }, + + // 页面分享功能 + onShareAppMessage() { + if (!this.data.list) { + return { + title: '采购清单', + path: '/pages/chef/shopping-list/shopping-list' + } + } + + const shareText = this.generateShareText() + return { + title: `采购清单 - ${this.data.list.date_range || '配餐计划'}`, + path: '/pages/chef/shopping-list/shopping-list', + imageUrl: '' // 可以设置分享图片 + } + }, + + onShareTimeline() { + if (!this.data.list) { + return { + title: '采购清单', + query: '' + } + } + + return { + title: `采购清单 - ${this.data.list.date_range || '配餐计划'}`, + query: '', + imageUrl: '' // 可以设置分享图片 + } } }) diff --git a/miniprogram/pages/chef/shopping-list/shopping-list.wxml b/miniprogram/pages/chef/shopping-list/shopping-list.wxml index fb28c38e24c15f1f9b5f91e0e4e995679655471a..cf4b91943650dae7c3d7d2e058774fd492e42cf4 100644 --- a/miniprogram/pages/chef/shopping-list/shopping-list.wxml +++ b/miniprogram/pages/chef/shopping-list/shopping-list.wxml @@ -1,6 +1,6 @@ - - + + 选择日期范围 @@ -19,38 +19,46 @@ - - + + 选择食神 - + + + 全选 ({{selectedGourmetIds.length}}/{{gourmets.length}}) - + + + {{item.name}} - - 暂无绑定的食神 +暂无绑定的食神 - - + + + 已选择 {{selectedGourmetIds.length}} 位食神 · {{datesCount}} 天的计划 + + + + @@ -73,6 +81,29 @@ 该时间段内没有配餐计划 + + + + + + + + + + + diff --git a/miniprogram/pages/chef/shopping-list/shopping-list.wxss b/miniprogram/pages/chef/shopping-list/shopping-list.wxss index 1ba1df0fc8662752922aec89e811f902d30eda99..b2690009c3cb0fef2078642da811fb4d2f6c0342 100644 --- a/miniprogram/pages/chef/shopping-list/shopping-list.wxss +++ b/miniprogram/pages/chef/shopping-list/shopping-list.wxss @@ -8,6 +8,19 @@ } /* 页面容器 */ +.info-section { + background: #e6f7ff; + border-radius: 12rpx; + padding: 20rpx; + margin: 20rpx; + text-align: center; +} + +.info-text { + font-size: 28rpx; + color: #1890ff; +} + .container { padding: 20rpx; min-height: 100vh; @@ -92,10 +105,42 @@ margin-left: 10rpx; } +.custom-checkbox { + width: 36rpx; + height: 36rpx; + border: 2rpx solid #d9d9d9; + border-radius: 6rpx; + display: flex; + align-items: center; + justify-content: center; + margin-right: 20rpx; + background: #fff; + transition: all 0.3s; +} + +.custom-checkbox.checked { + background: #1890ff; + border-color: #1890ff; +} + +.custom-checkbox text { + color: #fff; + font-size: 24rpx; + font-weight: bold; + line-height: 1; +} + .gourmet-list { display: flex; flex-direction: column; - gap: 15rpx; +} + +.gourmet-list .gourmet-item { + margin-bottom: 15rpx; +} + +.gourmet-list .gourmet-item:last-child { + margin-bottom: 0; } .gourmet-item { @@ -127,27 +172,6 @@ color: #333; } -.gourmet-check { - position: absolute; - top: -8rpx; - right: -8rpx; - width: 30rpx; - height: 30rpx; - background: #1890ff; - color: white; - border-radius: 50%; - font-size: 18rpx; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - transition: opacity 0.3s; -} - -.gourmet-item.selected .gourmet-check { - opacity: 1; -} - .no-gourmets { color: #999; font-size: 26rpx; @@ -239,3 +263,37 @@ text-align: center; padding: 60rpx 40rpx; } + +.action-buttons { + display: flex; + padding: 30rpx; + border-top: 1rpx solid #f0f0f0; + background: #fafafa; +} + +.action-buttons .action-btn { + margin-right: 20rpx; +} + +.action-buttons .action-btn:last-child { + margin-right: 0; +} + +.action-btn { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20rpx; + background: #fff; + border: 1rpx solid #e8e8e8; + border-radius: 12rpx; + font-size: 24rpx; + color: #333; +} + +.action-btn .icon { + font-size: 36rpx; + margin-bottom: 8rpx; +} diff --git a/miniprogram/pages/feedback/feedback.wxml b/miniprogram/pages/feedback/feedback.wxml index f5c5be15752842b5f1f538d195366b02fac01b5c..68110cb4184c2dabf970b4ccafd2ae1a3db31ceb 100644 --- a/miniprogram/pages/feedback/feedback.wxml +++ b/miniprogram/pages/feedback/feedback.wxml @@ -67,12 +67,11 @@ - + diff --git a/miniprogram/pages/feedback/feedback.wxss b/miniprogram/pages/feedback/feedback.wxss index 08455b1992d9f156449c0b51682bf402163b8680..573eb8c6013c72fe56051453e6ca947664d49dcd 100644 --- a/miniprogram/pages/feedback/feedback.wxss +++ b/miniprogram/pages/feedback/feedback.wxss @@ -43,6 +43,9 @@ font-size: 28rpx; color: #333; background-color: #fff; + display: flex; + align-items: center; + box-sizing: border-box; } .form-input:focus { @@ -100,6 +103,10 @@ font-size: 32rpx; font-weight: 500; border: none; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; } .btn-primary { @@ -107,7 +114,8 @@ color: #fff; } -.btn-primary:disabled { +.btn-primary.disabled { background-color: #d9d9d9; color: #999; + pointer-events: none; } diff --git a/miniprogram/pages/gourmet/chefs/chefs.js b/miniprogram/pages/gourmet/chefs/chefs.js index 7ebec39b582ee2b13c1002b4b6f5cc10f94c6e33..dee221b529af61e9a165ea1bd51b9c85e85ad7e7 100644 --- a/miniprogram/pages/gourmet/chefs/chefs.js +++ b/miniprogram/pages/gourmet/chefs/chefs.js @@ -72,7 +72,7 @@ Page({ // 添加搜索历史 addSearchHistory(keyword) { - let history = [...this.data.searchHistory] + let history = this.data.searchHistory.slice() const index = history.indexOf(keyword) if (index > -1) { @@ -155,7 +155,7 @@ Page({ updateChefStatus(chefId) { const chefs = this.data.chefs.map(chef => { if (chef.id === chefId) { - return { ...chef, binding_status: 'pending' } + return Object.assign({}, chef, { binding_status: 'pending' }) } return chef }) @@ -166,6 +166,7 @@ Page({ unbind(e) { const bindingId = e.currentTarget.dataset.id const chefName = e.currentTarget.dataset.name + const chefId = e.currentTarget.dataset.chefId || (this.data.myBindings.find(b => b.id === bindingId)?.chef?.id) showConfirm(`确定要解除与厨神"${chefName}"的绑定关系吗?`) .then(() => { @@ -176,6 +177,16 @@ Page({ hideLoading() showSuccess('已解除绑定') this.loadMyBindings() + // 如果在搜索结果中有这个厨神,更新其状态为可申请 + if (chefId) { + const chefs = this.data.chefs.map(chef => { + if (chef.id === chefId) { + return Object.assign({}, chef, { binding_status: null }) + } + return chef + }) + this.setData({ chefs }) + } }) .catch(err => { if (err) { diff --git a/miniprogram/pages/gourmet/chefs/chefs.wxml b/miniprogram/pages/gourmet/chefs/chefs.wxml index a6fb8571c3e5c5bfb859502bcf1e8b7e076a8833..6bcc8d3829dec93de484f9705a772456f64b7bd0 100644 --- a/miniprogram/pages/gourmet/chefs/chefs.wxml +++ b/miniprogram/pages/gourmet/chefs/chefs.wxml @@ -35,7 +35,7 @@ 清空 - {{item}} - + @@ -61,14 +61,16 @@ bindtap="viewChefDetail" data-id="{{item.id}}" > - + - {{item.nickname || item.username}} + + {{item.nickname || item.username}} + ID: {{item.id}} + - {{item.dish_count || 0}}道菜品 - {{item.meal_set_count || 0}}个套餐 + {{item.dish_count || 0}}道菜 + {{item.meal_set_count || 0}}个套餐 - ID: {{item.id}} - - - - - - - 营养分析 - - - {{mealTypeNames[item]}} - - {{nutritionSummary.nutrition_status[item] && nutritionSummary.nutrition_status[item].is_valid ? '✓' : '✗'}} - - - - - 建议: - {{item}} + + 配餐计划 + {{date}} - + {{mealTypeNames[item]}} - - {{nutritionSummary.nutrition_status[item] && nutritionSummary.nutrition_status[item].is_valid ? '✓' : '✗'}} - - - - - - + @@ -58,22 +27,19 @@ - - - - {{dish.name}} - {{dish.category_display}} - - - 查看详情 + + + + + {{dish.name}} + {{dish.category_display}} + - 未设置{{mealTypeNames[item]}} - @@ -111,7 +77,7 @@ 从 {{copyFromDate}} 复制到: - + {{copyTargetDate || '选择日期'}} @@ -124,6 +90,20 @@ + + + 营养分析 + + 功能待开发 + + + + + + + + + 加载中... diff --git a/miniprogram/pages/gourmet/daily-plan/daily-plan.wxss b/miniprogram/pages/gourmet/daily-plan/daily-plan.wxss index da46c16405843c8d1e7ed76662f59711d17138af..2d69ae0ac003a6fd904e17f1ad9c891c09be573e 100644 --- a/miniprogram/pages/gourmet/daily-plan/daily-plan.wxss +++ b/miniprogram/pages/gourmet/daily-plan/daily-plan.wxss @@ -11,46 +11,77 @@ align-items: center; background: #fff; border-radius: 16rpx; - padding: 30rpx; + padding: 24rpx 30rpx; margin-bottom: 20rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); } +.header-left { + display: flex; + flex-direction: row; + align-items: center; + gap: 16rpx; + flex: 1; +} + .title { - font-size: 48rpx; + font-size: 28rpx; font-weight: bold; color: #333; } -.actions { +.date-text { + font-size: 28rpx; + color: #999; +} + +/* 底部操作按钮 */ +.actions-row-bottom { display: flex; gap: 16rpx; + padding: 30rpx 20rpx; + margin-top: 20rpx; + justify-content: center; } -.action-btn { - padding: 16rpx 24rpx; - font-size: 28rpx; - background: #1890ff; - color: #fff; +.action-btn-bottom { + padding: 12rpx 24rpx; + font-size: 26rpx; + background: #f5f5f5; + color: #999; border-radius: 8rpx; - border: none; + border: 1rpx solid #e8e8e8; + line-height: 1.2; } -.action-btn.danger { - background: #ff4d4f; +.action-btn-bottom.danger { + background: #f5f5f5; + color: #999; + border: 1rpx solid #e8e8e8; } -/* 营养汇总样式 */ -.nutrition-summary { +/* 营养汇总样式(底部) */ +.nutrition-summary-bottom { background: #fff; border-radius: 16rpx; padding: 30rpx; + margin-top: 20rpx; margin-bottom: 20rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); } +.summary-placeholder { + text-align: center; + padding: 40rpx 0; +} + +.placeholder-text { + font-size: 28rpx; + color: #999; +} + .summary-title { - font-size: 32rpx; + font-size: 28rpx; font-weight: bold; color: #333; margin-bottom: 20rpx; @@ -123,6 +154,19 @@ justify-content: space-between; align-items: center; margin-bottom: 20rpx; + padding: 12rpx; + border-radius: 8rpx; + transition: background 0.3s; +} + +.meal-header:active { + background: #f5f5f5; +} + +.meal-arrow { + font-size: 40rpx; + color: #999; + line-height: 1; } .meal-info { @@ -132,7 +176,7 @@ } .meal-name { - font-size: 36rpx; + font-size: 28rpx; font-weight: bold; color: #333; } @@ -214,6 +258,33 @@ gap: 16rpx; } +.dish-content { + display: flex; + align-items: center; + flex: 1; + gap: 16rpx; +} + +.dish-swipe-actions { + display: flex; + align-items: center; + gap: 8rpx; + margin-left: auto; +} + +.dish-action-btn { + padding: 8rpx 16rpx; + font-size: 24rpx; + color: #fff; + border-radius: 6rpx; + white-space: nowrap; + line-height: 1.2; +} + +.delete-btn { + background: #ff4d4f; +} + .dish-image { width: 80rpx; height: 80rpx; @@ -250,15 +321,9 @@ /* 空状态样式 */ .empty-state { - text-align: center; - padding: 40rpx; - color: #8c8c8c; -} - -.empty-text { - font-size: 28rpx; - margin-bottom: 20rpx; - display: block; + /* 空状态不显示文字 */ + min-height: 0; + padding: 0; } .btn-primary { diff --git a/miniprogram/pages/gourmet/dish-detail/dish-detail.js b/miniprogram/pages/gourmet/dish-detail/dish-detail.js index e9877f09a0518166add5e53dc91f2ac3c46114f5..6b50b63827fd6872ef0c20f467e9c837e125308c 100644 --- a/miniprogram/pages/gourmet/dish-detail/dish-detail.js +++ b/miniprogram/pages/gourmet/dish-detail/dish-detail.js @@ -1,6 +1,7 @@ // 菜品详情页面 const { get, del } = require('../../../utils/request') const { showError, showLoading, hideLoading, showConfirm } = require('../../../utils/util') +const { imageManager } = require('../../../utils/imageManager') Page({ data: { @@ -8,15 +9,40 @@ Page({ dish: null, loading: true, currentImageIndex: 0, - isChef: false, // 是否为厨神(可以编辑) + isChef: false, // 是否为厨神 + isDishOwner: false, // 是否为菜品创建者 showShareMenu: false, // 是否显示分享菜单 + user: null, // 当前用户信息 + + // 添加配餐相关 + showAddMealModal: false, + selectedDate: '', + selectedMealTypeIndex: -1, + minDate: '', // 最小日期(今天) + mealTypeList: [ + { value: 'breakfast', label: '早餐' }, + { value: 'lunch', label: '午餐' }, + { value: 'dinner', label: '晚餐' } + ], categoryNames: { - 'vegetable': '蔬菜', - 'protein': '蛋白质', - 'carb': '碳水', - 'fat': '脂肪' + 'stir_fry': '炒菜', + 'steam': '蒸菜', + 'braise': '烧菜', + 'cold': '凉菜', + 'bbq': '烧烤', + 'boiled': '水煮菜', + 'hotpot': '火锅配餐', + 'bbq_side': '烧烤配菜', + 'dessert': '甜点', + 'staple': '主食', + 'noodle': '面', + 'soup': '汤类', + 'stew': '炖菜', + 'braised_food': '卤菜', + 'fried': '煎炸' }, + dishTypeNames: {}, // 从后端获取的菜品类型名称映射 statusMap: { 'draft': '草稿', 'published': '已发布' @@ -26,6 +52,7 @@ Page({ onLoad(options) { if (options.id) { this.setData({ dishId: options.id }) + this.loadDishTypes() this.loadDishDetail() } else { showError('参数错误') @@ -33,6 +60,30 @@ Page({ } }, + // 加载菜品类型列表 + loadDishTypes() { + const { get } = require('../../../utils/request') + get('/api/dishes/dish-type-choices/') + .then(res => { + const dishTypeNames = {} + const categoryNames = {} + + ;(res.dish_types || []).forEach(item => { + dishTypeNames[item.value] = item.label + categoryNames[item.value] = item.label + }) + + this.setData({ + dishTypeNames, + categoryNames + }) + }) + .catch(err => { + console.error('加载菜品类型列表失败:', err) + // 使用默认分类作为fallback + }) + }, + // 加载菜品详情 loadDishDetail() { showLoading('加载中...') @@ -42,64 +93,83 @@ Page({ .then(res => { hideLoading() - // 检查当前用户是否为该菜品的厨神 + // 检查当前用户是否为厨神,以及是否为菜品创建者 const app = getApp() - const currentUser = app.globalData.user - const isChef = currentUser && currentUser.role === 'chef' && res.chef && currentUser.id === res.chef.id + const currentUser = app.globalData.userInfo + // 只要是厨神都可以看到编辑按钮 + const isChef = currentUser && currentUser.role === 'chef' + // 是否为菜品创建者 + const isDishOwner = isChef && res.chef && currentUser.id === res.chef.id - // 处理图片URL,确保是完整的绝对路径 + // 使用统一的图片管理器处理所有图片 + // 处理菜品图片 if (res.images && res.images.length > 0) { - res.images = res.images.map(image => { - if (image.image_url) { - // 如果图片URL不是完整的HTTP/HTTPS URL,则拼接baseUrl - if (!image.image_url.startsWith('http')) { - image.image_url = `${app.globalData.baseUrl}${image.image_url.startsWith('/') ? '' : '/'}${image.image_url}` - } - // 添加时间戳避免缓存 - const timestamp = new Date().getTime() - image.image_url = `${image.image_url}?t=${timestamp}` - } - return image - }) + res.images = imageManager.processImages(res.images) } else if (res.main_image) { - // 如果没有images数组,使用main_image - if (!res.main_image.startsWith('http')) { - res.main_image = `${app.globalData.baseUrl}${res.main_image.startsWith('/') ? '' : '/'}${res.main_image}` - } - const timestamp = new Date().getTime() - res.images = [{ image_url: `${res.main_image}?t=${timestamp}`, id: 1 }] + res.images = imageManager.processImages([{ image_url: res.main_image }]) + } else { + res.images = imageManager.processImages([]) } // 处理制作步骤图片 if (res.cooking_steps && res.cooking_steps.length > 0) { - res.cooking_steps = res.cooking_steps.map(step => { - if (step.image_url) { - if (!step.image_url.startsWith('http')) { - step.image_url = `${app.globalData.baseUrl}${step.image_url.startsWith('/') ? '' : '/'}${step.image_url}` - } - } - return step - }) + res.cooking_steps = imageManager.processCookingSteps(res.cooking_steps) + } + + // 处理厨神头像 + if (res.chef) { + res.chef = imageManager.processUserAvatar(res.chef) } // 格式化时间,只保留年月日 if (res.updated_at) { - res.updated_at = res.updated_at.split(' ')[0] // 取年月日部分 + // 处理ISO格式或标准格式 + const date = new Date(res.updated_at) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + res.updated_at = `${year}-${month}-${day}` } if (res.created_at) { - res.created_at = res.created_at.split(' ')[0] // 取年月日部分 + const date = new Date(res.created_at) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + res.created_at = `${year}-${month}-${day}` } this.setData({ dish: res, loading: false, - isChef: !!isChef // 确保不为 undefined + isChef: !!isChef, // 是否是厨神 + isDishOwner: !!isDishOwner, // 是否是菜品创建者 + user: currentUser // 保存当前用户信息 }) // 设置页面标题 wx.setNavigationBarTitle({ title: res.name || '菜品详情' }) + + // 异步预加载图片到本地(使用缓存) + if (res.images && res.images.length > 0) { + res.images.forEach((image, index) => { + if (image.image_url && !image.image_url.startsWith('/images/') && !image.image_url.startsWith('wxfile://')) { + imageManager.preloadImage(image.image_url) + .then(localPath => { + console.log(`菜品图片预加载成功 ${index}:`, localPath) + const dish = this.data.dish + if (dish && dish.images[index]) { + dish.images[index].image_url = localPath + this.setData({ dish }) + } + }) + .catch(err => { + console.error(`菜品图片预加载失败 ${index}:`, err) + }) + } + }) + } }) .catch(err => { hideLoading() @@ -113,7 +183,7 @@ Page({ }) }, - // 预览图片 + // 预览图片(菜品图片) previewImage(e) { const current = e.currentTarget.dataset.url const urls = this.data.dish.images ? this.data.dish.images.map(img => img.image_url) : [] @@ -124,6 +194,45 @@ Page({ }) }, + // 预览步骤图片 + previewStepImage(e) { + // 阻止事件冒泡 + if (e && e.stopPropagation) { + e.stopPropagation() + } + + const url = e.currentTarget.dataset.url + + if (!url) { + wx.showToast({ + title: '图片不存在', + icon: 'none' + }) + return + } + + // 收集所有步骤图片URL + const stepImages = this.data.dish.cooking_steps || [] + const allUrls = stepImages + .map(step => step.image_url) + .filter(imgUrl => imgUrl) // 过滤空值 + + // 找到当前图片在列表中的位置 + let currentIndex = allUrls.indexOf(url) + if (currentIndex === -1) { + currentIndex = 0 + } + + // 将当前图片放在第一位 + const filteredUrls = allUrls.filter(u => u !== url) + const urls = [url].concat(filteredUrls) + + wx.previewImage({ + current: url, + urls: urls + }) + }, + // 图片轮播变化 onSwiperChange(e) { this.setData({ @@ -131,35 +240,80 @@ Page({ }) }, - // 选择这个菜品进行配餐 - selectForMeal() { - // 检查是否从菜品选择器页面跳转过来 - const pages = getCurrentPages() - let targetPage = null + // 显示添加配餐弹窗 + showAddMealModal() { + const { formatDate } = require('../../../utils/util') + const today = formatDate(new Date()) + this.setData({ + showAddMealModal: true, + selectedDate: today, + selectedMealTypeIndex: 0, // 默认选择早餐 + minDate: today // 设置最小日期为今天 + }) + }, + + // 隐藏添加配餐弹窗 + hideAddMealModal() { + this.setData({ + showAddMealModal: false + }) + }, + + // 日期选择变化 + onDateChange(e) { + this.setData({ + selectedDate: e.detail.value + }) + }, + + // 餐次选择变化 + onMealTypeChange(e) { + this.setData({ + selectedMealTypeIndex: parseInt(e.detail.value) + }) + }, + + // 确认添加配餐 + confirmAddMeal() { + const { formatDate } = require('../../../utils/util') + const { selectedDate, selectedMealTypeIndex, mealTypeList, dishId } = this.data - // 查找菜品选择器页面 - for (let i = pages.length - 2; i >= 0; i--) { - if (pages[i].route.includes('dish-selector')) { - targetPage = pages[i] - break - } + if (!selectedDate) { + wx.showToast({ + title: '请选择日期', + icon: 'none' + }) + return } - if (targetPage) { - // 回到菜品选择器并选择这个菜品 - const dish = this.data.dish - if (targetPage.selectDishFromDetail) { - targetPage.selectDishFromDetail(dish) - } - - // 返回到菜品选择器页面 - wx.navigateBack({ - delta: pages.length - 1 - pages.indexOf(targetPage) + if (selectedMealTypeIndex < 0 || selectedMealTypeIndex >= mealTypeList.length) { + wx.showToast({ + title: '请选择餐次', + icon: 'none' }) - } else { - // 直接返回上一页 - wx.navigateBack() + return } + + // 验证日期不能是今天之前的日期 + const today = formatDate(new Date()) + // 直接比较字符串,因为 'YYYY-MM-DD' 格式可以按字典序比较 + if (selectedDate < today) { + wx.showToast({ + title: '不能选择今天之前的日期', + icon: 'none' + }) + return + } + + const mealType = mealTypeList[selectedMealTypeIndex].value + + // 跳转到菜品选择器页面,并传递参数 + wx.navigateTo({ + url: `/pages/gourmet/dish-selector/dish-selector?date=${selectedDate}&mealType=${mealType}&dishId=${dishId}` + }) + + // 关闭弹窗 + this.hideAddMealModal() }, // 编辑菜品 @@ -169,18 +323,29 @@ Page({ return } + // 厨神都可以跳转到编辑页面 wx.navigateTo({ url: `/pages/chef/dish-edit/dish-edit?id=${this.data.dishId}` }) }, - // 删除菜品 + // 删除菜品(所有厨神可见,但只有创建者可以删除) deleteDish() { if (!this.data.isChef) { showError('只有厨神可以删除菜品') return } + // 如果不是菜品创建者,提示无法删除 + if (!this.data.isDishOwner) { + wx.showToast({ + title: '您不是该菜品的创建者,无法删除', + icon: 'none', + duration: 2000 + }) + return + } + const dishName = this.data.dish ? this.data.dish.name : '' showConfirm('确认删除', `删除后无法恢复,确定要删除菜品"${dishName}"吗?`) @@ -251,11 +416,22 @@ Page({ this.setData({ showShareMenu: false }) }, + // 阻止事件冒泡(用于分享菜单) + stopPropagation() { + // 空方法,仅用于阻止事件冒泡 + }, + // 分享到微信 shareToWechat() { this.hideShareMenu() + // 显示分享菜单(微信原生分享) + wx.showShareMenu({ + withShareTicket: true, + menus: ['shareAppMessage', 'shareTimeline'] + }) + // 提示用户使用右上角分享 wx.showToast({ - title: '请点击右上角分享到微信', + title: '请点击右上角分享按钮', icon: 'none', duration: 2000 }) @@ -265,19 +441,113 @@ Page({ // 保存为PDF saveAsPDF() { this.hideShareMenu() + + if (!this.data.dish || !this.data.dishId) { + wx.showToast({ + title: '菜品信息不完整', + icon: 'none' + }) + return + } + wx.showLoading({ title: '生成PDF中...' }) - // TODO: 实现PDF生成逻辑 - // 这里需要将页面内容转换为PDF - // 可以使用canvas + html2canvas类似的逻辑 + const app = getApp() + // 获取API地址(注意:app.js中使用的是baseUrl) + const apiUrl = app.globalData.baseUrl || app.globalData.apiUrl || 'https://b106.xyz' + const token = wx.getStorageSync('token') - setTimeout(() => { + if (!token) { wx.hideLoading() wx.showToast({ - title: 'PDF保存成功', - icon: 'success' + title: '请先登录', + icon: 'none' }) - }, 2000) + return + } + + // 询问是否包含图片 + wx.showModal({ + title: '生成选项', + content: '是否在PDF中包含图片?(包含图片文件会更大)', + confirmText: '包含图片', + cancelText: '仅文字', + success: (modalRes) => { + const includeImages = modalRes.confirm ? 'true' : 'false' + const url = `${apiUrl}/api/dishes/dishes/${this.data.dishId}/pdf/?include_images=${includeImages}` + + // 下载PDF文件 + wx.downloadFile({ + url: url, + header: { + 'Authorization': `Bearer ${token}` + }, + success: (res) => { + wx.hideLoading() + if (res.statusCode === 200) { + // 保存文件路径供后续使用 + const tempFilePath = res.tempFilePath + + // 打开文档 + wx.openDocument({ + filePath: tempFilePath, + fileType: 'pdf', + showMenu: true, + success: () => { + wx.showToast({ + title: 'PDF已打开,可通过右上角菜单保存或分享', + icon: 'none', + duration: 3000 + }) + }, + fail: (err) => { + console.error('打开PDF失败', err) + + // 如果打开失败,尝试保存到本地 + const fs = wx.getFileSystemManager() + const fileName = `菜品详情_${this.data.dish.name}_${Date.now()}.pdf` + const savedPath = `${wx.env.USER_DATA_PATH}/${fileName}` + + fs.saveFile({ + tempFilePath: tempFilePath, + filePath: savedPath, + success: () => { + wx.showModal({ + title: 'PDF已保存', + content: `文件已保存到本地:${savedPath}`, + showCancel: false + }) + }, + fail: () => { + wx.showToast({ + title: '保存PDF失败', + icon: 'none' + }) + } + }) + } + }) + } else { + wx.showToast({ + title: '生成PDF失败', + icon: 'none' + }) + } + }, + fail: (err) => { + wx.hideLoading() + console.error('下载PDF失败', err) + wx.showToast({ + title: '下载PDF失败', + icon: 'none' + }) + } + }) + }, + fail: () => { + wx.hideLoading() + } + }) }, /** @@ -299,10 +569,11 @@ Page({ imageUrl: dish && dish.images && dish.images[index] ? dish.images[index].image_url : 'unknown' }) - // 设置默认图片 + // 使用默认图片 if (dish && dish.images && dish.images[index]) { dish.images[index].image_url = '/images/default-dish.png' this.setData({ dish }) } } }) + diff --git a/miniprogram/pages/gourmet/dish-detail/dish-detail.json b/miniprogram/pages/gourmet/dish-detail/dish-detail.json new file mode 100644 index 0000000000000000000000000000000000000000..6bec38cbf49a957142ea32ab18e6dfa5b1d4b8ab --- /dev/null +++ b/miniprogram/pages/gourmet/dish-detail/dish-detail.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "菜品详情", + "usingComponents": {} +} + diff --git a/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml b/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml index 1fea30e5d92bec177f9dbe8870bdb43e0d281896..b27b68c4b9be99ae1b3900492f115bc4b9980ec8 100644 --- a/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml +++ b/miniprogram/pages/gourmet/dish-detail/dish-detail.wxml @@ -13,7 +13,8 @@ - {{categoryNames[dish.category]}} + {{categoryNames[dish.dish_type] || dish.dish_type_display || categoryNames[dish.category] || '未分类'}} @@ -71,8 +72,8 @@ - {{item.name}} - {{item.quantity}} {{item.unit}} + {{item.name}} + {{item.quantity}} {{item.unit}} @@ -98,8 +99,8 @@ @@ -124,7 +125,7 @@ @@ -140,33 +141,65 @@ diff --git a/miniprogram/pages/gourmet/dish-selector/dish-selector.wxss b/miniprogram/pages/gourmet/dish-selector/dish-selector.wxss index 7047ba0f6df23fe1b04466c09ad2b69ff6c4d6ec..870edd18675dcbb1c0472ef4b0538ad49f24e2e1 100644 --- a/miniprogram/pages/gourmet/dish-selector/dish-selector.wxss +++ b/miniprogram/pages/gourmet/dish-selector/dish-selector.wxss @@ -14,12 +14,16 @@ box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); } -.title { - font-size: 48rpx; +.header-row { + display: flex; + align-items: center; + gap: 16rpx; +} + +.title-small { + font-size: 32rpx; font-weight: bold; color: #333; - display: block; - margin-bottom: 8rpx; } .date { @@ -48,343 +52,241 @@ flex: 1; } -/* 标签页样式 */ -.tabs { - display: flex; - background: #fff; - margin-bottom: 20rpx; - border-radius: 16rpx; - overflow: hidden; -} - -.tab { - flex: 1; - text-align: center; - padding: 30rpx; - font-size: 30rpx; - color: #666; - transition: all 0.3s; -} - -.tab.active { - color: #1890ff; - background: #e6f7ff; - font-weight: bold; -} - -/* 筛选条件 */ -.filters { +/* 已绑定厨师列表样式 */ +.chefs-section { background: #fff; border-radius: 16rpx; - padding: 20rpx; + padding: 24rpx 0 24rpx 24rpx; margin-bottom: 20rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); } -.filter-group { - display: flex; - gap: 20rpx; +.section-title { + font-size: 28rpx; + font-weight: bold; + color: #333; margin-bottom: 16rpx; + padding-right: 24rpx; } -.filter-item { - flex: 1; - display: flex; - justify-content: space-between; - align-items: center; - padding: 16rpx 20rpx; - background: #fafafa; - border-radius: 8rpx; -} - -.filter-label { - font-size: 26rpx; - color: #666; +.chefs-scroll { + width: 100%; } -.filter-value { - font-size: 26rpx; - color: #333; +.chefs-list { + display: flex; + gap: 16rpx; + padding-right: 24rpx; + flex-wrap: nowrap; } -.search-box { +.chef-card { display: flex; + flex-direction: column; align-items: center; + gap: 8rpx; + padding: 12rpx; + border-radius: 12rpx; background: #fafafa; - border-radius: 8rpx; - padding: 16rpx 20rpx; -} - -.search-input { - flex: 1; - font-size: 28rpx; - color: #333; + border: 2rpx solid transparent; + min-width: 120rpx; + transition: all 0.3s; } -.search-btn { - font-size: 28rpx; - color: #666; - margin-left: 12rpx; +.chef-card.active { + background: #e6f7ff; + border-color: #1890ff; } -/* 配餐公式头部 */ -.formula-header { +.chef-avatar-wrapper { + width: 80rpx; + height: 80rpx; + border-radius: 50%; background: linear-gradient(135deg, #1890ff, #40a9ff); - color: white; - padding: 30rpx; - margin-bottom: 20rpx; - border-radius: 16rpx; -} - -.formula-title { - font-size: 32rpx; - font-weight: bold; - display: block; - margin-bottom: 20rpx; -} - -.formula-tags { display: flex; - flex-wrap: wrap; - gap: 15rpx; -} - -.formula-tag { - background: rgba(255, 255, 255, 0.2); - padding: 10rpx 20rpx; - border-radius: 20rpx; - font-size: 26rpx; - backdrop-filter: blur(10rpx); + align-items: center; + justify-content: center; } -/* 营养搭配状态 */ -.nutrition-status { - background: #fff; - border-radius: 16rpx; - padding: 20rpx; - margin-bottom: 20rpx; - display: flex; - justify-content: space-around; - box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); +.chef-avatar-text { + font-size: 28rpx; + color: #fff; + font-weight: bold; } -.status-item { - display: flex; - flex-direction: column; - align-items: center; - gap: 8rpx; +.chef-avatar { + width: 80rpx; + height: 80rpx; + border-radius: 50%; + border: 2rpx solid #e9ecef; } -.status-label { +.chef-name { font-size: 24rpx; color: #666; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120rpx; } -.status-value { - font-size: 32rpx; - font-weight: bold; +.chef-card.active .chef-name { color: #1890ff; + font-weight: bold; } -/* 菜品分类样式 */ -.dish-categories { - display: flex; - flex-direction: column; - gap: 20rpx; -} -.category-section { +/* 筛选条件 */ +.filters { background: #fff; border-radius: 16rpx; padding: 20rpx; + margin-bottom: 20rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); } -.category-title { +.search-box { display: flex; - justify-content: space-between; align-items: center; - margin-bottom: 20rpx; - padding-bottom: 15rpx; - border-bottom: 2rpx solid #f0f0f0; + background: #fafafa; + border-radius: 8rpx; + padding: 16rpx 20rpx; + margin-bottom: 16rpx; } -.category-name { - font-size: 32rpx; - font-weight: bold; +.search-input { + flex: 1; + font-size: 28rpx; color: #333; } -.required { - background: #ff4d4f; - color: white; - padding: 4rpx 12rpx; - border-radius: 12rpx; - font-size: 20rpx; -} - -.required.completed { - background: #52c41a; +.search-btn { + font-size: 28rpx; + color: #666; + margin-left: 12rpx; } -.category-dishes { +.category-buttons { display: flex; flex-wrap: wrap; - gap: 16rpx; + gap: 12rpx; } -.dish-option { - position: relative; +.category-btn { + padding: 12rpx 24rpx; background: #fafafa; + border-radius: 20rpx; border: 2rpx solid #e9ecef; - border-radius: 12rpx; - padding: 16rpx; - width: calc(50% - 8rpx); - display: flex; - align-items: center; - gap: 12rpx; - transition: all 0.3s; -} - -.dish-option.selected { - background: #e6f7ff; - border-color: #1890ff; - color: #1890ff; -} - -.dish-image { - width: 60rpx; - height: 60rpx; - border-radius: 8rpx; -} - -.dish-info { - flex: 1; - display: flex; - flex-direction: column; - gap: 4rpx; -} - -.dish-name { font-size: 26rpx; - font-weight: 500; - color: #333; -} - -.dish-chef { - font-size: 22rpx; color: #666; + transition: all 0.3s; } -.dish-check { - position: absolute; - top: -8rpx; - right: -8rpx; - width: 24rpx; - height: 24rpx; +.category-btn.active { background: #1890ff; - color: white; - border-radius: 50%; - font-size: 16rpx; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - transition: opacity 0.3s; -} - -.dish-option.selected .dish-check { - opacity: 1; + border-color: #1890ff; + color: #fff; } -.no-dishes { - color: #999; +.category-btn text { font-size: 26rpx; - text-align: center; - padding: 40rpx; - background: #f8f9fa; - border-radius: 12rpx; - border: 2rpx dashed #ddd; - width: 100%; } -/* 套餐列表样式 */ -.meal-set-list { + +/* 菜品列表样式 */ +.dish-list { display: flex; flex-direction: column; gap: 16rpx; } -.meal-set-card { +.dish-card { + position: relative; background: #fff; border-radius: 16rpx; - padding: 24rpx; + overflow: hidden; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); border: 2rpx solid transparent; transition: all 0.3s; + display: flex; + align-items: center; + padding: 20rpx; + gap: 20rpx; } -.meal-set-card.selected { +.dish-card.selected { border-color: #1890ff; background: #e6f7ff; } -.meal-set-header { +.dish-card-image { + width: 120rpx; + height: 120rpx; + border-radius: 12rpx; + flex-shrink: 0; +} + +.dish-card-info { + flex: 1; display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 12rpx; + flex-direction: column; + gap: 8rpx; } -.meal-set-name { - font-size: 32rpx; +.dish-card-name { + font-size: 30rpx; font-weight: bold; color: #333; + display: block; } -.nutrition-badge { - width: 32rpx; - height: 32rpx; - border-radius: 50%; +.dish-card-meta { display: flex; align-items: center; - justify-content: center; - font-size: 20rpx; - font-weight: bold; -} - -.nutrition-badge.valid { - background: #f6ffed; - color: #52c41a; + gap: 16rpx; } -.nutrition-badge.invalid { - background: #fff2f0; - color: #ff4d4f; +.dish-card-chef { + font-size: 24rpx; + color: #666; } -.meal-set-dishes { - font-size: 26rpx; - color: #666; - margin-bottom: 8rpx; - display: block; +.dish-card-category { + font-size: 24rpx; + color: #1890ff; + background: #e6f7ff; + padding: 4rpx 12rpx; + border-radius: 8rpx; } -.meal-set-chef { +.dish-card-check { + position: absolute; + top: 12rpx; + right: 12rpx; + width: 40rpx; + height: 40rpx; + background: #1890ff; + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; font-size: 24rpx; - color: #999; + font-weight: bold; } -.no-meal-sets { +.no-dishes { + color: #999; + font-size: 26rpx; text-align: center; padding: 60rpx; - color: #999; - font-size: 28rpx; background: #fff; border-radius: 16rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.1); } + /* 底部操作栏 */ .bottom-bar { position: fixed; @@ -399,19 +301,6 @@ gap: 16rpx; } -.quick-actions { - display: flex; - gap: 12rpx; -} - -.quick-btn { - padding: 16rpx 24rpx; - font-size: 24rpx; - background: #f0f0f0; - color: #666; - border-radius: 8rpx; - border: none; -} .btn-primary { flex: 1; @@ -424,6 +313,7 @@ font-weight: bold; } + /* 加载状态 */ .loading { text-align: center; diff --git a/miniprogram/pages/gourmet/menu-browser/menu-browser.js b/miniprogram/pages/gourmet/menu-browser/menu-browser.js index a9bf5412cd7fa70fdd3bdfe67bc25cf0b4810c85..69b32fbd95a6a5d975cc21209e397fa3788b0177 100644 --- a/miniprogram/pages/gourmet/menu-browser/menu-browser.js +++ b/miniprogram/pages/gourmet/menu-browser/menu-browser.js @@ -83,7 +83,7 @@ Page({ const chefs = [{ id: '', nickname: '全部厨神' }] // 这里需要从绑定关系获取厨神信息 this.loadChefs().then(chefList => { - chefs.push(...chefList) + chefs.push.apply(chefs, chefList) this.setData({ dishes, mealSets, @@ -344,7 +344,7 @@ Page({ // 切换收藏状态 toggleFavorite(e) { const { id, type } = e.currentTarget.dataset - const favorites = [...this.data.favorites] + const favorites = this.data.favorites.slice() const index = favorites.indexOf(id) if (index > -1) { diff --git a/miniprogram/pages/home/home.js b/miniprogram/pages/home/home.js index 1e418916404c41effcb8fac6a8cf295ff049a45a..6088a2f315400fed6cc9931679bd2c23bf6cf5f6 100644 --- a/miniprogram/pages/home/home.js +++ b/miniprogram/pages/home/home.js @@ -1,6 +1,7 @@ // 首页 - 根据角色显示不同内容 const { get } = require('../../utils/request') const { showError } = require('../../utils/util') +const { imageManager } = require('../../utils/imageManager') Page({ data: { @@ -35,14 +36,26 @@ Page({ const userInfo = app.globalData.userInfo console.log('当前用户信息:', userInfo) - if (!userInfo || !userInfo.role) { - // 未登录或未选择角色,跳转到登录页 + // 判断是否有用户信息 + if (!userInfo || !userInfo.id) { + // 未登录,跳转到登录页 + console.log('用户未登录,跳转到登录页') wx.reLaunch({ url: '/pages/login/login' }) return } + // 判断是否有角色 + if (!userInfo.role || userInfo.role.trim() === '') { + // 已登录但未选择角色,跳转到角色选择页 + console.log('用户已登录但未选择角色,跳转到角色选择页') + wx.reLaunch({ + url: '/pages/role-select/role-select' + }) + return + } + this.setData({ role: userInfo.role, loading: false @@ -66,19 +79,32 @@ Page({ .then(res => { console.log('首页菜品数据:', res) - // 处理图片URL,确保是完整的绝对路径 + // 使用统一的图片管理器处理图片 const dishes = (res.results || res).map((dish, index) => { if (dish.main_image) { - // 如果图片URL不是完整的HTTP/HTTPS URL,则拼接baseUrl - if (!dish.main_image.startsWith('http')) { - const app = getApp() - dish.main_image = `${app.globalData.baseUrl}${dish.main_image.startsWith('/') ? '' : '/'}${dish.main_image}` - } - // 清理URL中的重试参数 - dish.main_image = dish.main_image.split('?')[0] + const originalUrl = dish.main_image + dish.main_image = imageManager.processImageUrl(dish.main_image) - // 预加载图片到本地 - this.preloadImage(dish.main_image, index) + // 先设置为默认图,避免加载失败 + const tempImage = dish.main_image + if (!tempImage.startsWith('/images/')) { + dish.main_image = '/images/default-dish.png' + + // 异步预加载,成功后替换 + imageManager.preloadImage(tempImage) + .then(localPath => { + console.log(`首页图片预加载成功 ${index}:`, localPath) + const currentDishes = this.data.dishes + if (currentDishes[index]) { + currentDishes[index].main_image = localPath + this.setData({ dishes: currentDishes }) + } + }) + .catch(err => { + console.error(`首页图片预加载失败 ${index}:`, err) + // 保持默认图片 + }) + } } return dish }) diff --git a/miniprogram/pages/home/home.wxml b/miniprogram/pages/home/home.wxml index 64e7aef7f98af605a3e6315681c1304321e685a1..3d0ed989e58a17e2b5ef9599fa0579d720fd14e5 100644 --- a/miniprogram/pages/home/home.wxml +++ b/miniprogram/pages/home/home.wxml @@ -13,6 +13,7 @@ { + wx.navigateBack() + }, 1500) + } + }, + + /** + * 加载图片信息 + */ + loadImage(src) { + console.log('开始加载图片:', src) + + wx.getImageInfo({ + src: src, + success: (res) => { + console.log('图片信息:', JSON.stringify(res, null, 2)) + this.calculateImageLayout(res) + }, + fail: (err) => { + console.error('获取图片信息失败:', JSON.stringify(err, null, 2)) + showError('图片加载失败') + setTimeout(() => { + wx.navigateBack() + }, 1500) + } + }) + }, + /** + * 计算图片布局 + */ + calculateImageLayout(imageInfo) { + let { width, height } = imageInfo + const { cropWidth, cropHeight } = this.data + if (this.data.rotation === 90 || this.data.rotation === 270) { + const temp = width; + width = height; + height = temp; + } + // 获取屏幕信息 + const windowInfo = wx.getWindowInfo() + const screenWidth = windowInfo.windowWidth + const screenHeight = windowInfo.windowHeight + + // 图片的宽高比是固定的,旋转只是CSS变换,不影响布局计算 + // 始终使用原始图片的宽高比 + const imageRatio = width / height + + // 计算裁剪框在屏幕上的显示尺寸(保持比例) + const cropRatio = cropWidth / cropHeight + let cropDisplayWidth, cropDisplayHeight + if (screenWidth / screenHeight > cropRatio) { + // 屏幕更宽,以高度为基准 + cropDisplayHeight = screenHeight * 0.7 // 占用70%的屏幕高度 + cropDisplayWidth = cropDisplayHeight * cropRatio + } else { + // 屏幕更高,以宽度为基准 + cropDisplayWidth = screenWidth * 0.9 // 占用90%的屏幕宽度 + cropDisplayHeight = cropDisplayWidth / cropRatio + } + + // 确保裁剪框不会太大 + if (cropDisplayWidth > screenWidth * 0.9) { + cropDisplayWidth = screenWidth * 0.9 + cropDisplayHeight = cropDisplayWidth / cropRatio + } + if (cropDisplayHeight > screenHeight * 0.7) { + cropDisplayHeight = screenHeight * 0.7 + cropDisplayWidth = cropDisplayHeight * cropRatio + } + + // 计算图片的显示尺寸 + // 需求:短边与裁剪框一致(不限高度还是宽度) + // 比较图片和裁剪框的宽高比,决定以哪一边为基准 + let imageDisplayWidth, imageDisplayHeight + + // 判断图片宽高和裁剪框宽高的比例 + const widthRatio = width / cropDisplayWidth; + const heightRatio = height / cropDisplayHeight; + if (widthRatio > 1 && heightRatio > 1) { + // 图片比裁剪框大,哪个比例小哪个贴边 + if (widthRatio < heightRatio) { + // 宽度贴边 + imageDisplayWidth = cropDisplayWidth; + imageDisplayHeight = imageDisplayWidth / imageRatio; + } else { + // 高度贴边 + imageDisplayHeight = cropDisplayHeight; + imageDisplayWidth = imageDisplayHeight * imageRatio; + } + } else if (widthRatio > 1 || heightRatio > 1) { + // 至少有一个 > 1,取大的那边贴边(另一个超出裁剪框) + if (widthRatio > heightRatio) { + // 宽大于裁剪框,宽度贴边 + imageDisplayWidth = cropDisplayWidth; + imageDisplayHeight = imageDisplayWidth / imageRatio; + } else { + // 高大于裁剪框,高度贴边 + imageDisplayHeight = cropDisplayHeight; + imageDisplayWidth = imageDisplayHeight * imageRatio; + } + } else { + // 两边都 <= 1,原图显示,不缩放 + imageDisplayWidth = cropDisplayWidth < width ? cropDisplayWidth : width; + imageDisplayHeight = cropDisplayHeight < height ? cropDisplayHeight : height; + } + if (this.data.rotation === 90 || this.data.rotation === 270) { + const temp = imageDisplayWidth; + imageDisplayWidth = imageDisplayHeight; + imageDisplayHeight = temp; + } + // 计算裁剪框在屏幕上的位置(居中) + const cropTop = (screenHeight - cropDisplayHeight) / 2 + const cropLeft = (screenWidth - cropDisplayWidth) / 2 + + // 计算图片位置(居中显示) + const imageLeft = (screenWidth - imageDisplayWidth) / 2 + const imageTop = (screenHeight - imageDisplayHeight) / 2 + + // 计算最小缩放比例 + // 确保图片缩放后能够完全覆盖裁剪框 + // 需要同时考虑宽度和高度方向 + const scaleX = cropDisplayWidth / imageDisplayWidth + const scaleY = cropDisplayHeight / imageDisplayHeight + const minScale = Math.max(scaleX, scaleY, 1.0) // 至少为1.0,确保能覆盖裁剪框 + this.setData({ + originalWidth: width, + originalHeight: height, + imageWidth: imageDisplayWidth, + imageHeight: imageDisplayHeight, + imageTop: imageTop, + imageLeft: imageLeft, + cropDisplayWidth: cropDisplayWidth, + cropDisplayHeight: cropDisplayHeight, + cropTop: cropTop, + cropLeft: cropLeft, + scale: 1, + minScale: minScale, + maxScale: 3, + translateX: 0, + translateY: 0 + }) + console.log('步骤图片布局计算完成:', JSON.stringify({ + 原始尺寸: { width, height }, + 显示尺寸: { imageDisplayWidth, imageDisplayHeight }, + 裁剪框显示尺寸: { cropDisplayWidth, cropDisplayHeight }, + 屏幕尺寸: { screenWidth, screenHeight } + }, null, 2)) + }, + + /** + * 触摸开始 + */ + onTouchStart(e) { + const touches = e.touches + + if (touches.length === 1) { + // 单指拖拽 + const touch = touches[0] + this.setData({ + startX: touch.clientX, + startY: touch.clientY, + isMoving: true, + isScaling: false + }) + } else if (touches.length === 2) { + // 双指缩放 + const distance = this.getDistance(touches[0], touches[1]) + this.setData({ + startDistance: distance, + startScale: this.data.scale, + isMoving: false, + isScaling: true + }) + } + }, + + /** + * 触摸移动 + */ + onTouchMove(e) { + const touches = e.touches + + if (touches.length === 1 && this.data.isMoving) { + // 单指拖拽 + const touch = touches[0] + const deltaX = touch.clientX - this.data.startX + const deltaY = touch.clientY - this.data.startY + + // 计算新的位移值 + let newTranslateX = this.data.translateX + deltaX + let newTranslateY = this.data.translateY + deltaY + + // 限制拖动范围,确保图片不超出裁剪框边界 + const bounds = this.calculateDragBounds() + + // 计算限制后的位移值 + const clampedX = Math.max(bounds.minX, Math.min(bounds.maxX, newTranslateX)) + const clampedY = Math.max(bounds.minY, Math.min(bounds.maxY, newTranslateY)) + + // 只有当位移值发生变化时才更新(避免不必要的setData) + // 同时检查是否有实际的移动意图(deltaX或deltaY不为0) + if (clampedX !== this.data.translateX || clampedY !== this.data.translateY || + Math.abs(deltaX) > 0.5 || Math.abs(deltaY) > 0.5) { + this.setData({ + translateX: clampedX, + translateY: clampedY, + startX: touch.clientX, + startY: touch.clientY + }) + } + } else if (touches.length === 2 && this.data.isScaling) { + // 双指缩放 + const distance = this.getDistance(touches[0], touches[1]) + const scale = (distance / this.data.startDistance) * this.data.startScale + + // 限制缩放范围 + const clampedScale = Math.max(this.data.minScale, Math.min(this.data.maxScale, scale)) + + this.setData({ + scale: clampedScale + }) + + // 缩放后重新限制拖动范围 + const bounds = this.calculateDragBounds() + const currentTranslateX = Math.max(bounds.minX, Math.min(bounds.maxX, this.data.translateX)) + const currentTranslateY = Math.max(bounds.minY, Math.min(bounds.maxY, this.data.translateY)) + + if (currentTranslateX !== this.data.translateX || currentTranslateY !== this.data.translateY) { + this.setData({ + translateX: currentTranslateX, + translateY: currentTranslateY + }) + } + } + }, + + /** + * 触摸结束 + */ + onTouchEnd(e) { + this.setData({ + isMoving: false, + isScaling: false + }) + }, + + /** + * 计算两点间距离 + */ + getDistance(touch1, touch2) { + const dx = touch1.clientX - touch2.clientX + const dy = touch1.clientY - touch2.clientY + return Math.sqrt(dx * dx + dy * dy) + }, + + /** + * 计算拖动边界 + * 确保图片在缩放和位移后,不会超出裁剪框可视区域 + */ + calculateDragBounds() { + const { + imageWidth, imageHeight, imageLeft, imageTop, + cropDisplayWidth, cropDisplayHeight, cropLeft, cropTop, + scale, rotation + } = this.data + + // 计算缩放后的图片实际尺寸(考虑旋转后的视觉尺寸) + // 旋转90/270度时,视觉上的宽高会互换 + let visualWidth, visualHeight + if (rotation === 90 || rotation === 270) { + // 旋转90/270度:视觉宽度 = 实际高度,视觉高度 = 实际宽度 + visualWidth = imageHeight * scale + visualHeight = imageWidth * scale + } else { + // 不旋转或旋转180度:视觉尺寸 = 实际尺寸 + visualWidth = imageWidth * scale + visualHeight = imageHeight * scale + } + + // 计算图片中心点位置 + const imageCenterX = imageLeft + imageWidth / 2 + const imageCenterY = imageTop + imageHeight / 2 + + // 计算裁剪框中心点位置 + const cropCenterX = cropLeft + cropDisplayWidth / 2 + const cropCenterY = cropTop + cropDisplayHeight / 2 + + // 计算图片中心相对于裁剪框中心的最大允许偏移 + // 图片边缘不能超出裁剪框边缘(基于视觉尺寸) + // 如果视觉尺寸小于等于裁剪框,最大偏移为0(不允许移动) + // 使用一个很小的容差值(0.1px),避免浮点数精度问题 + const maxOffsetX = visualWidth > cropDisplayWidth + 0.1 + ? (visualWidth - cropDisplayWidth) / 2 + : 0 + const maxOffsetY = visualHeight > cropDisplayHeight + 0.1 + ? (visualHeight - cropDisplayHeight) / 2 + : 0 + + // 分别计算X和Y方向的边界 + // 如果某个方向的视觉尺寸小于等于裁剪框,则该方向不允许移动(保持居中) + // 如果大于裁剪框,则允许在该方向移动,但不能超出边界 + const centerOffsetX = cropCenterX - imageCenterX + const centerOffsetY = cropCenterY - imageCenterY + + const bounds = { + minX: centerOffsetX - maxOffsetX, + maxX: centerOffsetX + maxOffsetX, + minY: centerOffsetY - maxOffsetY, + maxY: centerOffsetY + maxOffsetY + } + + return bounds + }, + + /** + * 旋转图片 + */ + rotateImage() { + const rotation = (this.data.rotation + 90) % 360 + this.setData({ + rotation, + translateX: 0, // 重置位移 + translateY: 0, + scale: 1 // 重置缩放 + }) + + // 重新计算布局(考虑旋转后的尺寸变化) + wx.getImageInfo({ + src: this.data.src, + success: (res) => { + this.calculateImageLayout(res) + } + }) + }, + + /** + * 跳过编辑,直接使用原图 + */ + skipEdit() { + // 直接返回原图路径,让后端处理成固定大小 + const pages = getCurrentPages() + const prevPage = pages[pages.length - 2] + if (prevPage) { + if (this.data.type === 'step') { + // 步骤图片 + if (prevPage.handleStepImageEdited) { + prevPage.handleStepImageEdited(this.data.src, this.data.stepIndex) + } + } else { + // 菜品图片 + if (prevPage.handleDishImageEdited) { + prevPage.handleDishImageEdited(this.data.src) + } + } + } + wx.navigateBack() + }, + /** + * 确认编辑 + */ + confirmEdit() { + console.log('开始处理步骤图片') + showLoading('处理中...') + + const { src, originalWidth, originalHeight, imageWidth, imageHeight, imageTop, imageLeft, + cropWidth, cropHeight, cropDisplayWidth, cropDisplayHeight, cropTop, cropLeft, + scale, translateX, translateY, rotation } = this.data + + // 获取屏幕尺寸(用于计算旋转后的坐标转换) + const windowInfo = wx.getWindowInfo() + const deviceInfo = wx.getDeviceInfo() + const screenWidth = windowInfo.windowWidth + const screenHeight = windowInfo.windowHeight + const dpr = deviceInfo.pixelRatio || 1 + + // 使用 Canvas 2D API 获取画布上下文 + // 注意:可能会在控制台看到 SharedArrayBuffer 的弃用警告,这是浏览器/开发者工具的警告, + // 不影响微信小程序的实际功能,可以安全忽略 + wx.createSelectorQuery() + .select('#finalCanvas') + .fields({ node: true, size: true }) + .exec((res) => { + const canvas = res[0].node + const ctx = canvas.getContext('2d') + + // 设置 canvas 实际像素尺寸 + const canvasWidth = cropWidth + const canvasHeight = cropHeight + canvas.width = canvasWidth * dpr + canvas.height = canvasHeight * dpr + + // 缩放上下文以匹配设备像素比 + ctx.scale(dpr, dpr) + + // 计算图片中心点(在屏幕上的位置) + const imageCenterX = imageLeft + imageWidth / 2 + const imageCenterY = imageTop + imageHeight / 2 + + // 计算裁剪框中心点(在屏幕上的位置) + const cropCenterX = cropLeft + cropDisplayWidth / 2 + const cropCenterY = cropTop + cropDisplayHeight / 2 + + // 将裁剪框中心点相对于图片中心点的偏移(在屏幕坐标系下) + let screenOffsetX = cropCenterX - imageCenterX - translateX + let screenOffsetY = cropCenterY - imageCenterY - translateY + + // 将屏幕坐标系的偏移转换为原始图片坐标系的偏移 + let offsetX, offsetY + if (rotation === 90) { + offsetX = -screenOffsetX + offsetY = screenOffsetY + } else if (rotation === 180) { + offsetX = -screenOffsetX + offsetY = -screenOffsetY + } else if (rotation === 270) { + offsetX = screenOffsetX + offsetY = -screenOffsetY + } else { + offsetX = screenOffsetX + offsetY = screenOffsetY + } + + let displayToOriginalRatioX, displayToOriginalRatioY + // 计算比例 + if (rotation === 90 || rotation === 270 ) { + displayToOriginalRatioX = originalHeight / imageWidth + displayToOriginalRatioY = originalWidth / imageHeight + } else { + displayToOriginalRatioX = originalWidth / imageWidth + displayToOriginalRatioY = originalHeight / imageHeight + } + if (displayToOriginalRatioX !== displayToOriginalRatioY) { + console.log('缩放比例不一致,无法裁切', JSON.stringify({ + displayToOriginalRatioX, + displayToOriginalRatioY + }, null, 2)) + console.error('缩放比例不一致,无法裁切') + } + const offsetInOriginalX = (offsetX / scale) * displayToOriginalRatioX + const offsetInOriginalY = (offsetY / scale) * displayToOriginalRatioY + + const sourceSizeX = (cropDisplayWidth / scale) * displayToOriginalRatioX + const sourceSizeY = (cropDisplayHeight / scale) * displayToOriginalRatioY + + // 步骤图片保持矩形比例,不强制为正方形 + const sourceWidth = sourceSizeX + const sourceHeight = sourceSizeY + + // 计算裁剪区域的起始位置 + let sourceX = originalWidth / 2 + offsetInOriginalX - sourceWidth / 2 + let sourceY = originalHeight / 2 + offsetInOriginalY - sourceHeight / 2 + console.log('步骤图片布局计算完成:', JSON.stringify({ + 屏幕尺寸: { screenWidth, screenHeight }, + 图片原始尺寸: { originalWidth, originalHeight }, + 显示尺寸: { imageWidth, imageHeight }, + 比例:{ scale }, + 显示比例:{displayToOriginalRatioX, displayToOriginalRatioY }, + 裁切起始位置: { sourceX, sourceY }, + 裁切尺寸: { sourceSizeX, sourceSizeY } + }, null, 2)) + // 确保裁剪区域在图片范围内 + sourceX = Math.max(0, Math.min(sourceX, originalWidth - sourceWidth)) + sourceY = Math.max(0, Math.min(sourceY, originalHeight - sourceHeight)) + + // 加载图片并绘制 + const img = canvas.createImage() + img.onload = () => { + // 绘制裁剪后的图片 + if (rotation === 0) { + ctx.drawImage( + img, + sourceX, sourceY, sourceWidth, sourceHeight, + 0, 0, cropWidth, cropHeight + ) + } else { + ctx.save() + ctx.translate(cropWidth / 2, cropHeight / 2) + ctx.rotate(rotation * Math.PI / 180) + ctx.drawImage( + img, + sourceY, sourceX, sourceHeight, sourceWidth, + -cropHeight / 2, -cropWidth / 2,cropHeight, cropWidth + ) + ctx.restore() + } + + // Canvas 2D API 不需要调用 draw,直接导出 + this.exportCanvas(canvas) + } + img.onerror = (err) => { + console.error('图片加载失败:', err) + hideLoading() + showError('图片加载失败') + } + img.src = src + }) + }, + + /** + * 导出画布 + */ + exportCanvas(canvas) { + const { cropWidth, cropHeight } = this.data + + wx.canvasToTempFilePath({ + canvas: canvas, // Canvas 2D API 使用 canvas 节点 + fileType: 'jpg', // 使用JPEG格式,文件更小 + quality: 0.85, // 压缩质量 0-1,0.85是较好的平衡 + destWidth: cropWidth, // 指定输出宽度 + destHeight: cropHeight, // 指定输出高度 + success: (res) => { + console.log('图片处理成功:', res.tempFilePath) + + // 进一步压缩图片(如果文件仍然很大) + const fileSystemManager = wx.getFileSystemManager() + fileSystemManager.getFileInfo({ + filePath: res.tempFilePath, + success: (fileInfo) => { + console.log('导出图片大小:', fileInfo.size, '字节') + + // 如果文件大于1MB,再次压缩 + if (fileInfo.size > 1024 * 1024) { + console.log('图片较大,进行二次压缩') + wx.compressImage({ + src: res.tempFilePath, + quality: 80, // 压缩质量 + success: (compressRes) => { + console.log('图片压缩成功:', compressRes.tempFilePath) + this.returnEditedImage(compressRes.tempFilePath) + }, + fail: (err) => { + console.error('图片压缩失败:', JSON.stringify(err, null, 2)) + // 压缩失败,使用原图 + this.returnEditedImage(res.tempFilePath) + } + }) + } else { + this.returnEditedImage(res.tempFilePath) + } + }, + fail: () => { + // 获取文件信息失败,直接返回 + this.returnEditedImage(res.tempFilePath) + } + }) + }, + fail: (err) => { + console.error('图片处理失败:', JSON.stringify(err, null, 2)) + hideLoading() + showError('图片处理失败') + } + }, this) + }, + + /** + * 返回编辑后的图片 + */ + returnEditedImage(imagePath) { + hideLoading() + + // 注意:http://tmp/ 是微信小程序内部的临时文件路径,必须保持原样 + // 虽然会有 HTTPS 警告,但这是微信内部的路径格式,功能可以正常工作 + + // 返回处理后的图片路径 + const pages = getCurrentPages() + const prevPage = pages[pages.length - 2] + if (prevPage) { + if (this.data.type === 'step') { + // 步骤图片 + if (prevPage.handleStepImageEdited) { + prevPage.handleStepImageEdited(imagePath, this.data.stepIndex) + } + } else { + // 菜品图片 + if (prevPage.handleDishImageEdited) { + prevPage.handleDishImageEdited(imagePath) + } + } + } + + wx.navigateBack() + }, + + /** + * 取消编辑 + */ + cancelEdit() { + wx.navigateBack() + } +}) + diff --git a/miniprogram/pages/image-edit/image-edit.json b/miniprogram/pages/image-edit/image-edit.json new file mode 100644 index 0000000000000000000000000000000000000000..c50b175b3762189808af08d2e5d43ecc024e9ea2 --- /dev/null +++ b/miniprogram/pages/image-edit/image-edit.json @@ -0,0 +1,7 @@ +{ + "navigationBarTitleText": "编辑图片", + "navigationBarBackgroundColor": "#000000", + "navigationBarTextStyle": "white", + "backgroundColor": "#000000" +} + diff --git a/miniprogram/pages/image-edit/image-edit.wxml b/miniprogram/pages/image-edit/image-edit.wxml new file mode 100644 index 0000000000000000000000000000000000000000..b56e7fddf9956a0b4fe3ae2114b7c6d0fee604f1 --- /dev/null +++ b/miniprogram/pages/image-edit/image-edit.wxml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + 取消 + 跳过 + 旋转 + 完成 + + + + + + + diff --git a/miniprogram/pages/image-edit/image-edit.wxss b/miniprogram/pages/image-edit/image-edit.wxss new file mode 100644 index 0000000000000000000000000000000000000000..12b2711f458cb63af5901795ddd0bc7fcb1cb5dd --- /dev/null +++ b/miniprogram/pages/image-edit/image-edit.wxss @@ -0,0 +1,84 @@ +/* 图片编辑页面样式 */ +.edit-container { + position: relative; + width: 100%; + height: 100vh; + background: #000; + overflow: hidden; +} + +/* 图片显示区域 */ +.image-container { + position: relative; + width: 100%; + height: 100%; +} + +.edit-image { + position: absolute; + transition: transform 0.1s ease; + transform-origin: center center; +} + +/* 裁剪框遮罩 */ +.crop-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; +} + +.crop-frame { + position: absolute; + border: 2px solid #1890ff; + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5); +} + +/* 底部操作栏 */ +.bottom-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 120rpx; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 40rpx; + background: rgba(0, 0, 0, 0.8); + z-index: 1000; +} + +.action-btn { + font-size: 36rpx; + padding: 20rpx 40rpx; + border-radius: 8rpx; +} + +.action-btn.cancel { + color: #999; +} + +.action-btn.skip { + color: #fff; +} + +.action-btn.rotate { + color: #fff; +} + +.action-btn.confirm { + color: #1890ff; + font-weight: bold; +} + +/* 隐藏的画布 */ +.hidden-canvas { + position: absolute; + top: -9999px; + left: -9999px; + visibility: hidden; +} + diff --git a/miniprogram/pages/menu/menu.js b/miniprogram/pages/menu/menu.js index 2daba6bed039c77a46a9ac0b5a0b0748590e08b3..90b24a4d8ac09c9801772556cdbb7299a3cf3bd1 100644 --- a/miniprogram/pages/menu/menu.js +++ b/miniprogram/pages/menu/menu.js @@ -1,11 +1,13 @@ // pages/menu/menu.js const { get } = require('../../utils/request') const { showError } = require('../../utils/util') +const { imageManager } = require('../../utils/imageManager') Page({ data: { role: '', loading: true, + refreshing: false, // 厨神数据 mealSets: [], @@ -17,16 +19,29 @@ Page({ // 食神数据 dishes: [], + filteredDishes: [], // 过滤后的菜品列表 chefs: [], selectedChef: 'all', selectedCategory: 'all', categories: { 'all': '全部', - 'vegetable': '蔬菜', - 'protein': '蛋白质', - 'carb': '碳水', - 'fat': '脂肪' - } + 'stir_fry': '炒菜', + 'steam': '蒸菜', + 'braise': '烧菜', + 'cold': '凉菜', + 'bbq': '烧烤', + 'boiled': '水煮菜', + 'hotpot': '火锅配餐', + 'bbq_side': '烧烤配菜', + 'dessert': '甜点', + 'staple': '主食', + 'noodle': '面', + 'soup': '汤类', + 'stew': '炖菜', + 'braised_food': '卤菜', + 'fried': '煎炸' + }, + dishTypeNames: {} // 从后端获取的菜品类型名称映射 }, onShow() { @@ -45,44 +60,92 @@ Page({ if (userInfo.role === 'chef') { this.loadMealSets() } else if (userInfo.role === 'gourmet') { + this.loadDishTypes() this.loadMenus() } }, // 厨神:加载套餐 - loadMealSets() { - this.setData({ loading: true }) - get('/api/chef/meal-sets/') + loadMealSets(isRefresh = false) { + if (isRefresh) { + this.setData({ refreshing: true }) + } else { + this.setData({ loading: true }) + } + + return get('/api/chef/meal-sets/') .then(res => { this.setData({ mealSets: res.results || res, - loading: false + loading: false, + refreshing: false }) }) .catch(err => { - this.setData({ loading: false }) + this.setData({ + loading: false, + refreshing: false + }) showError(err.message || '加载失败') + throw err + }) + }, + + // 食神:加载菜品类型列表 + loadDishTypes() { + const { get } = require('../../utils/request') + get('/api/dishes/dish-type-choices/') + .then(res => { + const dishTypeNames = {} + const categories = { 'all': '全部' } + + ;(res.dish_types || []).forEach(item => { + dishTypeNames[item.value] = item.label + categories[item.value] = item.label + }) + + this.setData({ + dishTypeNames, + categories + }) + }) + .catch(err => { + console.error('加载菜品类型列表失败:', err) + // 使用默认分类作为fallback + const defaultCategories = { + 'all': '全部', + 'stir_fry': '炒菜', + 'steam': '蒸菜', + 'braise': '烧菜', + 'cold': '凉菜', + 'soup': '汤类' + } + this.setData({ + categories: defaultCategories + }) }) }, // 食神:加载菜单 - loadMenus() { - this.setData({ loading: true }) - get('/api/gourmet/menus/') + loadMenus(isRefresh = false) { + if (isRefresh) { + this.setData({ refreshing: true }) + } else { + this.setData({ loading: true }) + } + + return get('/api/gourmet/menus/') .then(res => { console.log('菜单数据:', res) - // 处理图片URL,确保是完整的绝对路径 + // 使用 imageManager 处理图片(移除时间戳,确保URL稳定便于缓存) const dishes = (res.dishes || []).map(dish => { if (dish.main_image) { - // 如果图片URL不是完整的HTTP/HTTPS URL,则拼接baseUrl - if (!dish.main_image.startsWith('http')) { - const app = getApp() - dish.main_image = `${app.globalData.baseUrl}${dish.main_image.startsWith('/') ? '' : '/'}${dish.main_image}` - } - // 添加时间戳参数避免缓存 - const timestamp = new Date().getTime() - dish.main_image = `${dish.main_image}?t=${timestamp}` + // 处理图片URL,确保稳定(移除查询参数,便于缓存) + let imageUrl = imageManager.processImageUrl(dish.main_image) + // 确保URL中没有时间戳参数,便于小程序缓存 + imageUrl = imageUrl.split('?')[0] + dish.main_image = imageUrl } return dish }) @@ -90,14 +153,45 @@ Page({ this.setData({ dishes: dishes, chefs: res.chefs || [], - loading: false + loading: false, + refreshing: false }) + + // 应用筛选 + this.applyFilters() }) .catch(err => { - this.setData({ loading: false }) + this.setData({ + loading: false, + refreshing: false + }) showError(err.message || '加载失败') + throw err }) }, + + // 下拉刷新 + onPullDownRefresh() { + const app = getApp() + const userInfo = app.globalData.userInfo + + if (!userInfo || !userInfo.role) { + wx.stopPullDownRefresh() + return + } + + const stopRefresh = () => { + wx.stopPullDownRefresh() + } + + if (userInfo.role === 'chef') { + this.loadMealSets(true).finally(stopRefresh) + } else if (userInfo.role === 'gourmet') { + this.loadMenus(true).finally(stopRefresh) + } else { + stopRefresh() + } + }, // 厨神:添加套餐 addMealSet() { @@ -125,28 +219,45 @@ Page({ // 食神:筛选厨神 filterChef(e) { const chef = e.currentTarget.dataset.chef - this.setData({ selectedChef: chef }) + // 确保ID是数字类型,'all'保持字符串 + this.setData({ + selectedChef: chef === 'all' ? 'all' : parseInt(chef) || chef + }) + // 应用筛选 + this.applyFilters() }, // 食神:筛选分类 filterCategory(e) { const category = e.currentTarget.dataset.category this.setData({ selectedCategory: category }) + // 应用筛选 + this.applyFilters() }, - // 获取过滤后的菜品 - getFilteredDishes() { - let dishes = this.data.dishes + // 应用筛选条件 + applyFilters() { + let dishes = this.data.dishes || [] + // 按厨神筛选 if (this.data.selectedChef !== 'all') { - dishes = dishes.filter(d => d.chef_id === this.data.selectedChef) + const chefId = typeof this.data.selectedChef === 'string' + ? parseInt(this.data.selectedChef) + : this.data.selectedChef + dishes = dishes.filter(d => { + const dishChefId = typeof d.chef_id === 'string' + ? parseInt(d.chef_id) + : (d.chef_id || d.chef?.id) + return dishChefId === chefId + }) } + // 按分类筛选(使用菜品类型 dish_type) if (this.data.selectedCategory !== 'all') { - dishes = dishes.filter(d => d.category === this.data.selectedCategory) + dishes = dishes.filter(d => (d.dish_type || d.category) === this.data.selectedCategory) } - return dishes + this.setData({ filteredDishes: dishes }) }, selectRole() { diff --git a/miniprogram/pages/menu/menu.json b/miniprogram/pages/menu/menu.json index 06c4144008c0c6b3376a2aa0b3f791d3a4d21100..b8690b10d72d49c9a8d487e277c4789890e28440 100644 --- a/miniprogram/pages/menu/menu.json +++ b/miniprogram/pages/menu/menu.json @@ -1,2 +1,6 @@ -{"navigationBarTitleText": "菜单"} +{ + "navigationBarTitleText": "菜单", + "enablePullDownRefresh": true, + "backgroundTextStyle": "dark" +} diff --git a/miniprogram/pages/menu/menu.wxml b/miniprogram/pages/menu/menu.wxml index 7ae39cdd6cb0ddaf8a7861b57f0daac0128a74e9..10daecde69c0dc6b29d1f349ea5b179e8493ee46 100644 --- a/miniprogram/pages/menu/menu.wxml +++ b/miniprogram/pages/menu/menu.wxml @@ -59,23 +59,27 @@ 加载中... - - + + + {{item.name}} - {{categories[item.category]}} + {{categories[item.dish_type] || categories[item.category] || '未分类'}} by {{item.chef_name}} - - 还没有绑定厨神,去"计划"页面添加吧~ + + 还没有绑定厨神,去"计划"页面添加吧~ + 没有符合条件的菜品 diff --git a/miniprogram/pages/menu/menu.wxss b/miniprogram/pages/menu/menu.wxss index 721f65e9166e16b8208f4ac92ce474a5994c2f4d..dd21b2af504122fbd92475bcdb9f492dbe74fc4e 100644 --- a/miniprogram/pages/menu/menu.wxss +++ b/miniprogram/pages/menu/menu.wxss @@ -126,9 +126,20 @@ box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.05); } +.dish-image-wrapper { + width: 100%; + padding-bottom: 100%; /* 保持1:1的宽高比,实现正方形 */ + position: relative; + overflow: hidden; + background: #f5f5f5; +} + .dish-image { + position: absolute; + top: 0; + left: 0; width: 100%; - height: 200rpx; + height: 100%; } .dish-info { diff --git a/miniprogram/pages/plan/plan.js b/miniprogram/pages/plan/plan.js index 876d01193231dc800ebb5ffca76f1bbd533ec957..4497ba1aa30115a24725851bf0c0ab913305d311 100644 --- a/miniprogram/pages/plan/plan.js +++ b/miniprogram/pages/plan/plan.js @@ -11,6 +11,10 @@ Page({ gourmets: [], selectedGourmet: null, plans: [], + plansData: [], // 保存原始计划数据(包含完整信息) + selectedStartDate: '', // 选择的开始日期 + selectedEndDate: '', // 选择的结束日期 + selectedDates: [], // 选中的日期列表(用于生成采购清单) // 食神数据:我的厨神列表 chefs: [], @@ -21,7 +25,14 @@ Page({ const app = getApp() const userInfo = app.globalData.userInfo + console.log('[Plan] 页面显示', { + userId: userInfo?.id, + role: userInfo?.role, + timestamp: new Date().toISOString() + }) + if (!userInfo || !userInfo.role) { + console.warn('[Plan] 用户信息缺失,跳转到角色选择页面') wx.redirectTo({ url: '/pages/role-select/role-select' }) @@ -31,88 +42,392 @@ Page({ this.setData({ role: userInfo.role }) if (userInfo.role === 'chef') { + console.log('[Plan] 厨神角色,加载食神列表') this.loadGourmets() } else if (userInfo.role === 'gourmet') { + console.log('[Plan] 食神角色,加载厨神列表') this.loadChefs() } }, // 厨神:加载绑定的食神列表 loadGourmets() { + console.log('[Plan] 开始加载绑定的食神列表', { timestamp: new Date().toISOString() }) this.setData({ loading: true }) - get('/api/chef/gourmets/') + return get('/api/chef/gourmets/') .then(res => { - const bindings = res.results || res - const gourmets = bindings - .filter(b => b.status === 'approved') - .map(b => b.gourmet) + // /api/chef/gourmets/ 返回的是食神用户数组(已过滤为accepted状态) + const gourmets = res.results || res || [] + + console.log('[Plan] 加载食神列表成功', { + gourmetsCount: gourmets.length, + gourmetIds: gourmets.map(g => g.id), + timestamp: new Date().toISOString() + }) this.setData({ gourmets, loading: false }) + return gourmets }) .catch(err => { + console.error('[Plan] 加载食神列表失败', { + error: err.message || err, + stack: err.stack, + timestamp: new Date().toISOString() + }) this.setData({ loading: false }) showError(err.message || '加载失败') + return Promise.reject(err) }) }, // 厨神:选择食神查看计划 selectGourmet(e) { const gourmetId = e.currentTarget.dataset.id + console.log('[Plan] 选择食神查看计划', { gourmetId, timestamp: new Date().toISOString() }) this.setData({ selectedGourmet: gourmetId }) this.loadGourmetPlans(gourmetId) }, // 厨神:加载食神的配餐计划 loadGourmetPlans(gourmetId) { + console.log('[Plan] 开始加载食神配餐计划', { + gourmetId, + startDate: this.data.selectedStartDate, + endDate: this.data.selectedEndDate, + timestamp: new Date().toISOString() + }) + this.setData({ loading: true }) - const startDate = new Date().toISOString().split('T')[0] - const endDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + const startDate = this.data.selectedStartDate || new Date().toISOString().split('T')[0] + const endDate = this.data.selectedEndDate || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] - get('/api/chef/schedule/summary/', { + // 如果没有设置日期范围,设置默认值 + if (!this.data.selectedStartDate) { + this.setData({ selectedStartDate: startDate }) + } + if (!this.data.selectedEndDate) { + this.setData({ selectedEndDate: endDate }) + } + + console.log('[Plan] 请求参数', { gourmet_id: gourmetId, start_date: startDate, end_date: endDate }) + + get('/api/gourmet/chef/gourmet-plans/', { gourmet_id: gourmetId, start_date: startDate, end_date: endDate }) .then(res => { + console.log('[Plan] 加载配餐计划成功', { + plansCount: res.plans?.length || 0, + hasStats: !!res.stats, + timestamp: new Date().toISOString() + }) + // 将后端返回的计划数据转换为前端期望的格式(按日期分组) + const plansData = res.plans || [] + const groupedByDate = {} + + plansData.forEach(plan => { + const date = plan.date + if (!groupedByDate[date]) { + groupedByDate[date] = { + date: date, + weekday: this.getWeekday(date), + meals: [], + plans: [] // 保存该日期下的所有计划对象 + } + } + + // 保存完整的计划对象 + groupedByDate[date].plans.push(plan) + + // 添加餐次信息 + const dishes = plan.dishes || [] + const dishNames = dishes.length > 0 + ? dishes.map(d => d.name).join('、') + : '无' + groupedByDate[date].meals.push({ + meal_type: plan.meal_type, + meal_type_name: plan.meal_type_display || this.getMealTypeName(plan.meal_type), + dish_names: dishNames, + plan_id: plan.id, + dishes: dishes // 保存菜品信息 + }) + }) + + // 转换为数组并按日期排序 + const plans = Object.values(groupedByDate).sort((a, b) => { + return new Date(a.date) - new Date(b.date) + }) + + // 对每天的餐次进行排序(早餐、午餐、晚餐) + plans.forEach(plan => { + plan.meals.sort((a, b) => { + const order = { breakfast: 1, lunch: 2, dinner: 3 } + return (order[a.meal_type] || 99) - (order[b.meal_type] || 99) + }) + // 初始化选中状态 + plan.selected = this.data.selectedDates.includes(plan.date) + }) + + console.log('[Plan] 计划数据转换完成', { + groupedPlansCount: plans.length, + totalMeals: plans.reduce((sum, p) => sum + p.meals.length, 0), + timestamp: new Date().toISOString() + }) + this.setData({ - plans: res.plans || [], + plans: plans, + plansData: plansData, // 保存原始数据 loading: false }) }) .catch(err => { + console.error('[Plan] 加载配餐计划失败', { + gourmetId, + startDate, + endDate, + error: err.message || err, + stack: err.stack, + timestamp: new Date().toISOString() + }) this.setData({ loading: false }) showError(err.message || '加载失败') }) }, + // 获取星期几 + getWeekday(dateStr) { + const date = new Date(dateStr + 'T00:00:00') + const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] + return weekdays[date.getDay()] + }, + + // 获取餐别名称 + getMealTypeName(mealType) { + const names = { + breakfast: '早餐', + lunch: '午餐', + dinner: '晚餐' + } + return names[mealType] || mealType + }, + + // 切换日期选择 + toggleDateSelect(e) { + const date = e.currentTarget.dataset.date + const selectedDates = this.data.selectedDates.slice() + const index = selectedDates.indexOf(date) + + if (index > -1) { + selectedDates.splice(index, 1) + } else { + selectedDates.push(date) + } + + // 更新计划项的选中状态 + const plans = this.data.plans.map(plan => { + if (plan.date === date) { + return Object.assign({}, plan, { selected: !plan.selected }) + } + return plan + }) + + this.setData({ + selectedDates, + plans + }) + }, + // 厨神:生成采购清单 generateShoppingList() { + console.log('[Plan] 点击生成采购清单', { + selectedGourmet: this.data.selectedGourmet, + selectedDates: this.data.selectedDates, + timestamp: new Date().toISOString() + }) + + if (!this.data.selectedGourmet) { + console.warn('[Plan] 生成采购清单失败:未选择食神') + showError('请先选择食神') + return + } + + if (this.data.selectedDates.length === 0) { + console.warn('[Plan] 生成采购清单失败:未选择日期') + showError('请至少选择一个日期') + return + } + + // 计算日期范围(最小和最大日期) + const sortedDates = this.data.selectedDates.slice().sort() + const startDate = sortedDates[0] + const endDate = sortedDates[sortedDates.length - 1] + + // 将选中的日期列表转换为逗号分隔的字符串 + const datesStr = this.data.selectedDates.join(',') + + console.log('[Plan] 跳转到采购清单页面', { + gourmetId: this.data.selectedGourmet, + startDate, + endDate, + dates: datesStr, + timestamp: new Date().toISOString() + }) + + wx.navigateTo({ + url: `/pages/chef/shopping-list/shopping-list?gourmetId=${this.data.selectedGourmet}&startDate=${startDate}&endDate=${endDate}&dates=${datesStr}` + }) + }, + + // 查看单日计划详情 + viewDayPlan(e) { + const date = e.currentTarget.dataset.date + console.log('[Plan] 点击查看单日计划详情', { + date, + selectedGourmet: this.data.selectedGourmet, + timestamp: new Date().toISOString() + }) + if (!this.data.selectedGourmet) { + console.warn('[Plan] 查看计划详情失败:未选择食神') showError('请先选择食神') return } + // 跳转到食神详细计划页面,并筛选到指定日期 + console.log('[Plan] 跳转到计划详情页面', { + gourmet_id: this.data.selectedGourmet, + date, + timestamp: new Date().toISOString() + }) + wx.navigateTo({ - url: `/pages/chef/shopping-list/shopping-list?gourmetId=${this.data.selectedGourmet}` + url: `/pages/chef/gourmet-plans/gourmet-plans?gourmet_id=${this.data.selectedGourmet}&date=${date}` + }) + }, + + // 选择开始日期 + selectStartDate(e) { + const startDate = e.detail.value + console.log('[Plan] 选择开始日期', { startDate, timestamp: new Date().toISOString() }) + this.setData({ + selectedStartDate: startDate + }) + }, + + // 选择结束日期 + selectEndDate(e) { + const endDate = e.detail.value + console.log('[Plan] 选择结束日期', { endDate, timestamp: new Date().toISOString() }) + this.setData({ + selectedEndDate: endDate + }) + }, + + // 重新加载指定日期范围的计划 + reloadPlansWithDateRange() { + if (!this.data.selectedGourmet) { + console.warn('[Plan] 刷新计划失败:未选择食神') + return + } + + const startDate = this.data.selectedStartDate || new Date().toISOString().split('T')[0] + const endDate = this.data.selectedEndDate || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + + console.log('[Plan] 刷新计划(按日期范围)', { + gourmetId: this.data.selectedGourmet, + startDate, + endDate, + timestamp: new Date().toISOString() + }) + + this.setData({ loading: true }) + + get('/api/gourmet/chef/gourmet-plans/', { + gourmet_id: this.data.selectedGourmet, + start_date: startDate, + end_date: endDate + }) + .then(res => { + const plansData = res.plans || [] + const groupedByDate = {} + + plansData.forEach(plan => { + const date = plan.date + if (!groupedByDate[date]) { + groupedByDate[date] = { + date: date, + weekday: this.getWeekday(date), + meals: [], + plans: [] + } + } + + groupedByDate[date].plans.push(plan) + + const dishes = plan.dishes || [] + const dishNames = dishes.length > 0 + ? dishes.map(d => d.name).join('、') + : '无' + groupedByDate[date].meals.push({ + meal_type: plan.meal_type, + meal_type_name: plan.meal_type_display || this.getMealTypeName(plan.meal_type), + dish_names: dishNames, + plan_id: plan.id, + dishes: dishes + }) + }) + + const plans = Object.values(groupedByDate).sort((a, b) => { + return new Date(a.date) - new Date(b.date) + }) + + plans.forEach(plan => { + plan.meals.sort((a, b) => { + const order = { breakfast: 1, lunch: 2, dinner: 3 } + return (order[a.meal_type] || 99) - (order[b.meal_type] || 99) + }) + // 初始化选中状态 + plan.selected = this.data.selectedDates.includes(plan.date) + }) + + this.setData({ + plans: plans, + plansData: plansData, + loading: false + }) + }) + .catch(err => { + this.setData({ loading: false }) + showError(err.message || '加载失败') + }) + }, + + // 厨神:进入食神管理页面 + manageGourmets() { + wx.navigateTo({ + url: '/pages/chef/gourmets/gourmets' }) }, // 食神:加载绑定的厨神列表 loadChefs() { this.setData({ loading: true }) - get('/api/gourmet/bindings/') + return get('/api/gourmet/bindings/') .then(res => { this.setData({ bindings: res.results || res, loading: false }) + return res.results || res }) .catch(err => { this.setData({ loading: false }) showError(err.message || '加载失败') + return Promise.reject(err) }) }, @@ -127,6 +442,32 @@ Page({ wx.redirectTo({ url: '/pages/role-select/role-select' }) + }, + + // 下拉刷新 + onPullDownRefresh() { + console.log('[Plan] 下拉刷新') + + if (this.data.role === 'chef' && this.data.selectedGourmet) { + // 重新加载食神列表和计划 + this.loadGourmets() + .then(() => { + if (this.data.selectedGourmet) { + return this.loadGourmetPlans(this.data.selectedGourmet) + } + }) + .finally(() => { + wx.stopPullDownRefresh() + }) + } else if (this.data.role === 'gourmet') { + // 重新加载厨神列表 + this.loadChefs() + .finally(() => { + wx.stopPullDownRefresh() + }) + } else { + wx.stopPullDownRefresh() + } } }) diff --git a/miniprogram/pages/plan/plan.json b/miniprogram/pages/plan/plan.json index 5ce9f1a94f738f3d54107ea95362e31df0c0302c..5ee625aec559bad66862fb83b8cfb90c21508fdc 100644 --- a/miniprogram/pages/plan/plan.json +++ b/miniprogram/pages/plan/plan.json @@ -1,2 +1,6 @@ -{"navigationBarTitleText": "计划"} +{ + "navigationBarTitleText": "计划", + "enablePullDownRefresh": true, + "backgroundTextStyle": "dark" +} diff --git a/miniprogram/pages/plan/plan.wxml b/miniprogram/pages/plan/plan.wxml index cb776cae966b9c092633e47c2e99f59cf51a563c..1cc8d44f3ceca806eedde3bdf8cf1b095ef1791e 100644 --- a/miniprogram/pages/plan/plan.wxml +++ b/miniprogram/pages/plan/plan.wxml @@ -3,6 +3,7 @@ 食神配餐计划 + 加载中... @@ -25,24 +26,62 @@ - 未来7天配餐 - + 配餐计划 + + + + + + + + 开始日期 + {{selectedStartDate || '未选择'}} + + + + + + 结束日期 + {{selectedEndDate || '未选择'}} + + + - - - {{item.date}} - {{item.weekday}} + + + + + + - - - {{meal.meal_type_name}} - {{meal.dish_names}} + + + + + {{item.date}} + {{item.weekday}} + + + + + {{meal.meal_type_name}} + {{meal.dish_names}} + + + + + + @@ -61,7 +100,7 @@ {{item.chef.nickname || '厨神' + item.chef.id}} - {{item.status === 'approved' ? '已绑定' : item.status === 'pending' ? '待审核' : '已拒绝'}} + {{item.status === 'accepted' ? '已绑定' : item.status === 'pending' ? '待审核' : item.status === 'rejected' ? '已拒绝' : item.status === 'cancelled' ? '已取消' : item.status}} diff --git a/miniprogram/pages/plan/plan.wxss b/miniprogram/pages/plan/plan.wxss index 7c0f30b28425f25c2b966eebca0b146334ad786b..f2e2e0f34cccad13dd6460873542c750e6cd9b38 100644 --- a/miniprogram/pages/plan/plan.wxss +++ b/miniprogram/pages/plan/plan.wxss @@ -19,7 +19,7 @@ font-weight: bold; } -.add-btn, .action-btn { +.add-btn, .action-btn, .manage-btn { padding: 10rpx 20rpx; background: #1890ff; color: white; @@ -76,6 +76,7 @@ background: white; border-radius: 10rpx; padding: 20rpx; + padding-bottom: 120rpx; /* 为底部按钮留出空间 */ } .section-header { @@ -93,13 +94,78 @@ .plan-list { display: flex; flex-direction: column; - gap: 20rpx; +} + +.plan-list .plan-item { + margin-bottom: 20rpx; +} + +.plan-list .plan-item:last-child { + margin-bottom: 0; } .plan-item { + position: relative; border: 1rpx solid #f0f0f0; border-radius: 10rpx; padding: 20rpx; + background: #fafafa; + transition: all 0.3s; + display: flex; + align-items: center; + gap: 20rpx; +} + +.plan-item.selected { + border-color: #1890ff; + background: #e6f7ff; +} + +.plan-item:active { + background: #f0f0f0; +} + +.plan-checkbox { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.checkbox { + width: 40rpx; + height: 40rpx; + border: 2rpx solid #d9d9d9; + border-radius: 6rpx; + display: flex; + align-items: center; + justify-content: center; + background: #fff; + transition: all 0.3s; +} + +.checkbox.checked { + background: #1890ff; + border-color: #1890ff; +} + +.checkmark { + color: #fff; + font-size: 24rpx; + font-weight: bold; +} + +.plan-content { + flex: 1; + position: relative; +} + +.plan-arrow { + font-size: 36rpx; + color: #999; + line-height: 1; + letter-spacing: 2rpx; + margin-left: auto; } .plan-date { @@ -162,6 +228,10 @@ .chef-info { flex: 1; margin-left: 20rpx; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; } .chef-name { @@ -169,6 +239,7 @@ font-weight: bold; display: block; margin-bottom: 8rpx; + text-align: center; } .status { @@ -176,9 +247,11 @@ padding: 5rpx 15rpx; border-radius: 15rpx; display: inline-block; + text-align: center; } -.status.approved { +.status.approved, +.status.accepted { background: #d4edda; color: #155724; } @@ -188,6 +261,16 @@ color: #856404; } +.status.rejected { + background: #f8d7da; + color: #721c24; +} + +.status.cancelled { + background: #e2e3e5; + color: #383d41; +} + .empty, .no-role { text-align: center; padding: 80rpx 40rpx; @@ -202,3 +285,74 @@ color: white; } +/* 日期范围选择 */ +.date-range-section { + margin-bottom: 20rpx; + padding: 20rpx; + background: #f8f9fa; + border-radius: 10rpx; +} + +.date-picker-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20rpx; +} + +.date-picker-item { + flex: 1; + min-width: 0; + background: white; + padding: 24rpx 20rpx; + border-radius: 8rpx; + flex-basis: 0; + min-width: 200rpx; +} + +.date-label { + font-size: 30rpx; + color: #999; + display: block; + margin-bottom: 12rpx; + text-align: center; +} + +.date-value { + font-size: 32rpx; + color: #333; + font-weight: 500; + text-align: center; + display: block; +} + +.date-separator { + color: #666; + font-size: 28rpx; + flex-shrink: 0; + min-width: 40rpx; + text-align: center; +} + +.action-section { + position: fixed; + bottom: 0; + left: 0; + right: 0; + padding: 20rpx; + background: #fff; + box-shadow: 0 -2rpx 12rpx rgba(0,0,0,0.1); + z-index: 100; +} + +.action-btn { + width: 100%; + padding: 25rpx; + background: linear-gradient(135deg, #52c41a, #73d13d); + color: white; + border-radius: 10rpx; + font-size: 30rpx; + font-weight: bold; + border: none; +} + diff --git a/miniprogram/pages/privacy-policy/privacy-policy.js b/miniprogram/pages/privacy-policy/privacy-policy.js index 42f71d0eb0df546627263f71c0937c697c3f0e71..c09fba53c35db68cf53aed0603e9a715cd489dd7 100644 --- a/miniprogram/pages/privacy-policy/privacy-policy.js +++ b/miniprogram/pages/privacy-policy/privacy-policy.js @@ -44,12 +44,12 @@ Page({ 八、联系我们 8.1 如果您对本隐私政策有任何疑问,请通过以下方式联系我们: - 邮箱:privacy@mealarchitect.com + 邮箱:447083059@qq.com 本隐私政策自您使用本服务之日起生效。 配膳官团队 -2024年 +2025年 ` }, diff --git a/miniprogram/pages/profile-edit/profile-edit.js b/miniprogram/pages/profile-edit/profile-edit.js index 2852a05db63dc165ae1c944be30351cc989bb201..e51ad80664605d7c80a9d2c476dc4e762309069a 100644 --- a/miniprogram/pages/profile-edit/profile-edit.js +++ b/miniprogram/pages/profile-edit/profile-edit.js @@ -2,6 +2,7 @@ const app = getApp() const { showLoading, hideLoading, showSuccess, showError } = require('../../utils/util') const { get, put } = require('../../utils/request') +const { imageManager } = require('../../utils/imageManager') Page({ data: { @@ -21,9 +22,11 @@ Page({ /** * 预加载头像到本地(带压缩功能) + * 注意:预加载的头像仅用于页面显示优化,不会更新formData.avatar_url + * 这样可以避免误将临时文件路径当作需要上传的新头像 */ preloadAvatar(url) { - console.log('Profile-Edit 开始预加载头像:', url) + console.log('Profile-Edit 开始预加载头像(仅用于显示):', url) wx.downloadFile({ url: url, @@ -33,8 +36,12 @@ Page({ if (res.statusCode === 200) { console.log('Profile-Edit 头像下载成功:', res.tempFilePath) - // 压缩头像 + // 压缩头像(仅用于显示,不更新formData) this.compressAvatar(res.tempFilePath) + + // 将压缩后的本地路径用于显示(但不保存到formData.avatar_url) + // 可以通过在image组件中使用一个独立的显示路径 + // 这里我们保持formData.avatar_url不变,image组件会直接使用服务器URL } else { console.error('Profile-Edit 头像预加载失败, 状态码:', res.statusCode) console.error('响应详情:', res) @@ -43,6 +50,7 @@ Page({ fail: (err) => { console.error('Profile-Edit 头像预加载失败:', err) console.error('错误详情:', err) + // 预加载失败不影响功能,image组件会直接使用服务器URL } }) }, @@ -59,25 +67,15 @@ Page({ success: (res) => { console.log('Profile-Edit 头像压缩成功:', res.tempFilePath) - // 更新头像URL为压缩后的本地路径 - const formData = this.data.formData - if (formData) { - formData.avatar_url = res.tempFilePath - this.setData({ formData }) - console.log('Profile-Edit 头像已更新为压缩后的本地路径:', res.tempFilePath) - console.log('Profile-Edit 更新后的表单数据:', formData) - } + // 预加载的头像仅用于显示,不更新formData.avatar_url + // formData.avatar_url应该保持原来的服务器URL,这样不会误触发上传 + // 临时文件路径仅用于页面显示,通过image组件的src直接使用 + console.log('Profile-Edit 头像压缩完成,仅用于显示,不更新formData') }, fail: (err) => { console.error('Profile-Edit 头像压缩失败:', err) - - // 压缩失败,使用原始文件 - const formData = this.data.formData - if (formData) { - formData.avatar_url = tempFilePath - this.setData({ formData }) - console.log('Profile-Edit 压缩失败,使用原始文件:', tempFilePath) - } + // 压缩失败不影响显示,使用原始下载的文件 + console.log('Profile-Edit 压缩失败,使用原始文件显示') } }) }, @@ -349,10 +347,20 @@ Page({ /** * 上传头像到服务器 + * 注意:此方法只能由用户主动选择头像时调用,不能由预加载等自动操作触发 */ uploadAvatar(filePath) { - console.log('=== 开始上传头像 ===') + console.log('=== 开始上传头像(用户主动操作) ===') console.log('文件路径:', filePath) + + // 安全检查:确保文件路径是临时文件(wxfile://或tmp_开头) + // 如果路径是HTTP URL,说明这是服务器上的头像,不应该重新上传 + if (filePath && (filePath.startsWith('http://') || filePath.startsWith('https://'))) { + console.warn('警告:尝试上传服务器URL作为头像,这是不允许的操作') + console.warn('文件路径:', filePath) + return + } + showLoading('上传中...') // 获取用户token diff --git a/miniprogram/pages/profile/profile.js b/miniprogram/pages/profile/profile.js index c6ce50925c67a40563d351dce145b5d05a380e46..e3fb35b2e93cc353dc25681808276faa07e1dfe6 100644 --- a/miniprogram/pages/profile/profile.js +++ b/miniprogram/pages/profile/profile.js @@ -2,6 +2,7 @@ const app = getApp() const { showConfirm, showLoading, hideLoading, showSuccess, showError } = require('../../utils/util') const { del } = require('../../utils/request') +const { imageManager } = require('../../utils/imageManager') Page({ data: { @@ -28,47 +29,32 @@ Page({ // 更新全局用户信息 app.globalData.userInfo = res - // 处理头像URL - if (res && res.avatar_url) { - console.log('开始处理头像URL:', res.avatar_url) + // 使用 imageManager 处理头像 + if (res) { + res = imageManager.processUserAvatar(res) + console.log('处理后的用户信息:', res) - // 确保URL是完整的绝对路径 - if (!res.avatar_url.startsWith('http')) { - console.log('URL不是完整路径,拼接baseUrl:', app.globalData.baseUrl) - res.avatar_url = `${app.globalData.baseUrl}${res.avatar_url.startsWith('/') ? '' : '/'}${res.avatar_url}` - console.log('拼接后的URL:', res.avatar_url) - } - - // 清理URL中的重试参数 - const cleanUrl = res.avatar_url.split('?')[0] - console.log('清理参数后的URL:', cleanUrl) - - // 检查是否与全局缓存中的头像URL相同 - if (app.globalData.cachedAvatarUrl === cleanUrl && app.globalData.cachedAvatarPath) { - console.log('使用全局缓存头像,URL未变化:', app.globalData.cachedAvatarPath) - res.avatar_url = app.globalData.cachedAvatarPath - } - - // 先设置用户信息,再处理头像 this.setData({ userInfo: res }) - // 只有在URL变化时才预加载头像 - if (app.globalData.cachedAvatarUrl !== cleanUrl) { - console.log('头像URL已变化,开始预加载:', cleanUrl) - this.preloadAvatar(cleanUrl) - } else { - console.log('头像URL未变化,跳过预加载') + // 预加载头像到本地(带缓存) + if (res.avatar_url && !res.avatar_url.startsWith('/images/')) { + imageManager.preloadImage(res.avatar_url) + .then(localPath => { + console.log('头像预加载成功:', localPath) + const userInfo = this.data.userInfo + if (userInfo) { + userInfo.avatar_url = localPath + this.setData({ userInfo }) + } + }) + .catch(err => { + console.error('头像预加载失败:', err) + }) } - } else { - console.log('用户信息或头像URL为空:', { userInfo: !!res, avatar_url: res?.avatar_url }) - this.setData({ - userInfo: res - }) } - console.log('最终头像URL:', res?.avatar_url) console.log('=== Profile页面 onShow 结束 ===') }) .catch(err => { @@ -81,6 +67,7 @@ Page({ /** * 预加载头像到本地(带缓存和压缩功能) + * @deprecated 已废弃,使用 imageManager.preloadImage 替代 */ preloadAvatar(url) { console.log('开始预加载头像:', url) diff --git a/miniprogram/pages/user-agreement/user-agreement.js b/miniprogram/pages/user-agreement/user-agreement.js index 93753a2e4dde1ac96b3bfd4971ac2945dd2e4e1c..bcb89cfd6f5cdcc3b899a9e3c625be6dae4e7192 100644 --- a/miniprogram/pages/user-agreement/user-agreement.js +++ b/miniprogram/pages/user-agreement/user-agreement.js @@ -38,7 +38,7 @@ Page({ 本协议自您使用本服务之日起生效。 配膳官团队 -2024年 +2025年 ` }, diff --git a/miniprogram/project.private.config.json b/miniprogram/project.private.config.json index 766c3435352a086c6a4249fcd1b0f2775ac2874f..32c9a09694f9fcd3b3e6229afbfbd68a4db90f16 100644 --- a/miniprogram/project.private.config.json +++ b/miniprogram/project.private.config.json @@ -3,5 +3,6 @@ "projectname": "%E9%85%8D%E8%86%B3%E5%AE%98", "setting": { "compileHotReLoad": true - } + }, + "libVersion": "3.8.12" } \ No newline at end of file diff --git a/miniprogram/utils/imageLoader.js b/miniprogram/utils/imageLoader.js deleted file mode 100644 index 3cd75279ad0c195aef64071bceb90aa44b76d7d3..0000000000000000000000000000000000000000 --- a/miniprogram/utils/imageLoader.js +++ /dev/null @@ -1,84 +0,0 @@ -// 图片加载工具 -class ImageLoader { - constructor() { - this.retryCount = 3 - this.retryDelay = 1000 - } - - /** - * 加载图片,带重试机制 - */ - async loadImageWithRetry(url, retryCount = this.retryCount) { - return new Promise((resolve, reject) => { - const attemptLoad = (attempts) => { - console.log(`尝试加载图片,第${this.retryCount - attempts + 1}次:`, url) - - wx.request({ - url: url, - method: 'HEAD', // 只获取头部信息,检查图片是否存在 - timeout: 10000, - success: (res) => { - if (res.statusCode === 200) { - console.log('图片URL验证成功:', url) - resolve(url) - } else { - console.error('图片URL验证失败,状态码:', res.statusCode) - if (attempts > 0) { - setTimeout(() => attemptLoad(attempts - 1), this.retryDelay) - } else { - reject(new Error(`图片加载失败,状态码: ${res.statusCode}`)) - } - } - }, - fail: (err) => { - console.error('图片URL请求失败:', err) - if (attempts > 0) { - setTimeout(() => attemptLoad(attempts - 1), this.retryDelay) - } else { - reject(err) - } - } - }) - } - - attemptLoad(retryCount) - }) - } - - /** - * 预加载图片 - */ - preloadImage(url) { - return new Promise((resolve, reject) => { - wx.downloadFile({ - url: url, - success: (res) => { - if (res.statusCode === 200) { - resolve(res.tempFilePath) - } else { - reject(new Error(`预加载失败,状态码: ${res.statusCode}`)) - } - }, - fail: reject - }) - }) - } - - /** - * 检查图片URL是否有效 - */ - async validateImageUrl(url) { - try { - await this.loadImageWithRetry(url, 1) // 只尝试一次验证 - return true - } catch (error) { - console.error('图片URL无效:', url, error) - return false - } - } -} - -// 创建全局实例 -const imageLoader = new ImageLoader() - -module.exports = imageLoader diff --git a/miniprogram/utils/imageManager.js b/miniprogram/utils/imageManager.js new file mode 100644 index 0000000000000000000000000000000000000000..d0826b4073df216bf11d96345bacd86da75e9930 --- /dev/null +++ b/miniprogram/utils/imageManager.js @@ -0,0 +1,329 @@ +// 统一的图片管理工具 +// 处理图片加载、缓存、URL转换等 + +const fs = wx.getFileSystemManager() + +// 获取app实例的辅助函数 +function getAppInstance() { + try { + return getApp() + } catch (e) { + return null + } +} + +class ImageManager { + constructor() { + this.cachePrefix = 'img_cache_' + this.maxCacheAge = 7 * 24 * 60 * 60 * 1000 // 7天缓存 + } + + /** + * 处理图片URL - 统一入口 + * @param {string|object} imageData - 图片数据(可能是字符串URL或包含image/image_url的对象) + * @param {string} defaultImage - 默认图片路径 + * @returns {string} 处理后的图片URL + */ + processImageUrl(imageData, defaultImage = '/images/default-dish.png') { + let imageUrl = '' + + // 处理不同的输入格式 + if (typeof imageData === 'string') { + imageUrl = imageData + } else if (imageData && typeof imageData === 'object') { + imageUrl = imageData.image_url || imageData.image || '' + } + + // 如果没有URL,返回默认图片 + if (!imageUrl) { + return defaultImage + } + + // 处理 http://tmp/ 临时文件路径(微信小程序内部临时文件) + // 注意:必须保持 http://tmp/ 原样,不能转换为 https://tmp/ + // 虽然会有 HTTPS 警告,但这是微信内部的路径格式,功能可以正常工作 + // 如果转换为 https://tmp/,微信会将其当作网络请求处理,导致失败 + if (imageUrl.startsWith('http://tmp/')) { + return imageUrl + } + + // 如果已经是本地路径(wxfile 或 /images),直接返回 + if (imageUrl.startsWith('wxfile://') || imageUrl.startsWith('/images/')) { + return imageUrl + } + + // 如果是相对路径,拼接baseUrl + if (!imageUrl.startsWith('http')) { + const app = getAppInstance() + if (app && app.globalData) { + const baseUrl = app.globalData.baseUrl + imageUrl = `${baseUrl}${imageUrl.startsWith('/') ? '' : '/'}${imageUrl}` + } + } + + // 移除URL中的时间戳参数(如果有) + imageUrl = imageUrl.split('?')[0] + + return imageUrl + } + + /** + * 批量处理图片数组 + * @param {Array} images - 图片数组 + * @param {string} defaultImage - 默认图片 + * @returns {Array} 处理后的图片数组 + */ + processImages(images, defaultImage = '/images/default-dish.png') { + if (!Array.isArray(images) || images.length === 0) { + return [{ image_url: defaultImage, id: 1 }] + } + + return images.map((image, index) => Object.assign({}, image, { + image_url: this.processImageUrl(image, defaultImage), + id: image.id || index + 1 + })) + } + + /** + * 处理厨神/食神头像 + * @param {object} user - 用户对象 + * @returns {object} 处理后的用户对象 + */ + processUserAvatar(user) { + if (!user) return user + + const processedUser = Object.assign({}, user) + + if (user.avatar_url) { + processedUser.avatar_url = this.processImageUrl(user.avatar_url, '/images/default-avatar.png') + } + if (user.avatar) { + processedUser.avatar = this.processImageUrl(user.avatar, '/images/default-avatar.png') + } + + return processedUser + } + + /** + * 处理制作步骤图片 + * @param {Array} steps - 步骤数组 + * @returns {Array} 处理后的步骤数组 + */ + processCookingSteps(steps) { + if (!Array.isArray(steps)) return [] + + return steps.map(step => Object.assign({}, step, { + image_url: step.image_url || step.image ? + this.processImageUrl(step, '/images/default-dish.png') : null + })) + } + + /** + * 预加载图片到本地(带缓存) + * @param {string} url - 图片URL + * @param {boolean} useCache - 是否使用缓存 + * @returns {Promise} 本地图片路径 + */ + async preloadImage(url, useCache = true) { + // 如果已经是本地路径(包括 http://tmp/ 临时文件),直接返回 + // 注意:http://tmp/ 是微信小程序内部的临时文件路径,必须保持原样 + if (url.startsWith('wxfile://') || url.startsWith('/images/') || url.startsWith('http://tmp/')) { + return url + } + + // 处理URL + const processedUrl = this.processImageUrl(url) + + // 检查缓存 + if (useCache) { + const cachedPath = this.getCachedImage(processedUrl) + if (cachedPath) { + console.log('使用缓存图片:', cachedPath) + return cachedPath + } + } + + // 下载图片 + return new Promise((resolve, reject) => { + wx.downloadFile({ + url: processedUrl, + timeout: 30000, + success: (res) => { + // 200: 正常下载,304: 使用缓存(服务器返回304时,微信会自动使用本地缓存) + if (res.statusCode === 200 || res.statusCode === 304) { + if (res.statusCode === 304) { + console.log('图片未修改,使用缓存:', processedUrl) + } else { + console.log('图片下载成功:', res.tempFilePath) + } + + resolve(res.tempFilePath) + + // 保存到缓存(只有200时才需要保存,304时微信已经使用了缓存) + if (useCache && res.statusCode === 200) { + try { + this.setCachedImage(processedUrl, res.tempFilePath) + } catch (e) { + console.error('保存缓存失败:', e) + // 继续,不影响图片使用 + } + } + } else { + console.error('图片下载失败,状态码:', res.statusCode) + reject(new Error(`下载失败: ${res.statusCode}`)) + } + }, + fail: (err) => { + console.error('图片下载失败:', err) + reject(err) + } + }) + }) + } + + /** + * 获取缓存的图片 + * @param {string} url - 图片URL + * @returns {string|null} 缓存的本地路径 + */ + getCachedImage(url) { + try { + const cacheKey = this.getCacheKey(url) + const cached = wx.getStorageSync(cacheKey) + + if (cached && cached.path && cached.timestamp) { + // 检查缓存是否过期 + const now = Date.now() + if (now - cached.timestamp < this.maxCacheAge) { + // 检查文件是否还存在 + try { + fs.accessSync(cached.path) + return cached.path + } catch (e) { + // 文件不存在,清除缓存 + wx.removeStorageSync(cacheKey) + } + } else { + // 缓存过期,清除 + wx.removeStorageSync(cacheKey) + } + } + } catch (e) { + console.error('获取缓存失败:', e) + } + + return null + } + + /** + * 保存图片到缓存 + * @param {string} url - 图片URL + * @param {string} localPath - 本地路径 + */ + setCachedImage(url, localPath) { + try { + const cacheKey = this.getCacheKey(url) + wx.setStorageSync(cacheKey, { + url: url, + path: localPath, + timestamp: Date.now() + }) + } catch (e) { + console.error('保存缓存失败:', e) + } + } + + /** + * 生成缓存键 + * @param {string} url - 图片URL + * @returns {string} 缓存键 + */ + getCacheKey(url) { + // 移除URL中的参数和baseUrl,只保留路径部分 + const cleanUrl = url.split('?')[0] + const app = getAppInstance() + if (app && app.globalData && app.globalData.baseUrl) { + const path = cleanUrl.replace(app.globalData.baseUrl, '') + return `${this.cachePrefix}${this.hashCode(path)}` + } + // 如果没有app,直接使用完整URL的hash + return `${this.cachePrefix}${this.hashCode(cleanUrl)}` + } + + /** + * 简单的字符串哈希函数 + * @param {string} str - 字符串 + * @returns {string} 哈希值 + */ + hashCode(str) { + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = ((hash << 5) - hash) + char + hash = hash & hash // Convert to 32bit integer + } + return Math.abs(hash).toString(36) + } + + /** + * 清除所有图片缓存 + */ + clearAllCache() { + try { + const storageInfo = wx.getStorageInfoSync() + const imageCacheKeys = storageInfo.keys.filter(key => + key.startsWith(this.cachePrefix) + ) + + imageCacheKeys.forEach(key => { + wx.removeStorageSync(key) + }) + + console.log(`清除图片缓存完成,共清除 ${imageCacheKeys.length} 个缓存`) + } catch (e) { + console.error('清除缓存失败:', e) + } + } + + /** + * 清除过期缓存 + */ + clearExpiredCache() { + try { + const storageInfo = wx.getStorageInfoSync() + const imageCacheKeys = storageInfo.keys.filter(key => + key.startsWith(this.cachePrefix) + ) + + let clearedCount = 0 + const now = Date.now() + + imageCacheKeys.forEach(key => { + try { + const cached = wx.getStorageSync(key) + if (cached && cached.timestamp) { + if (now - cached.timestamp >= this.maxCacheAge) { + wx.removeStorageSync(key) + clearedCount++ + } + } + } catch (e) { + // 忽略单个缓存的错误 + } + }) + + console.log(`清除过期图片缓存完成,共清除 ${clearedCount} 个缓存`) + } catch (e) { + console.error('清除过期缓存失败:', e) + } + } +} + +// 创建单例 +const imageManager = new ImageManager() + +module.exports = { + imageManager, + ImageManager +} + diff --git a/miniprogram/utils/request.js b/miniprogram/utils/request.js index 4e9b4485dae72fbeff44272201af243a88ec382c..c38efa2a425ef5ee7578a5a40128338d2c0929a9 100644 --- a/miniprogram/utils/request.js +++ b/miniprogram/utils/request.js @@ -23,10 +23,9 @@ function request(options) { method, data, timeout: 60000, // 增加到60秒超时 - header: { - 'Content-Type': 'application/json', - ...header - }, + header: Object.assign({ + 'Content-Type': 'application/json' + }, header), success(res) { if (res.statusCode >= 200 && res.statusCode < 300) { resolve(res.data) @@ -91,7 +90,7 @@ function request(options) { (1000 * (retry + 1)) // 1秒、2秒、3秒 setTimeout(() => { - request({ ...options, retry: retry + 1 }) + request(Object.assign({}, options, { retry: retry + 1 })) .then(resolve) .catch(reject) }, delay) @@ -175,10 +174,11 @@ function put(url, data = {}) { /** * DELETE请求 */ -function del(url) { +function del(url, data = {}) { return request({ url, - method: 'DELETE' + method: 'DELETE', + data }) } diff --git a/miniprogram/utils/util.js b/miniprogram/utils/util.js index 9fe7f97d922f78afd4c6a1a6450b8566243c6862..57d8de80ca54f9678c41e19d12bc0d2afd15367c 100644 --- a/miniprogram/utils/util.js +++ b/miniprogram/utils/util.js @@ -113,7 +113,8 @@ function showConfirm(content, title = '提示') { */ function debounce(fn, delay = 500) { let timer = null - return function(...args) { + return function() { + const args = Array.prototype.slice.call(arguments) if (timer) clearTimeout(timer) timer = setTimeout(() => { fn.apply(this, args) @@ -126,7 +127,8 @@ function debounce(fn, delay = 500) { */ function throttle(fn, delay = 500) { let lastTime = 0 - return function(...args) { + return function() { + const args = Array.prototype.slice.call(arguments) const now = Date.now() if (now - lastTime >= delay) { fn.apply(this, args) diff --git a/miniprogram/utils/version.js b/miniprogram/utils/version.js new file mode 100644 index 0000000000000000000000000000000000000000..e560bec15d50cff0c3ea7d98a747fd15df275d3f --- /dev/null +++ b/miniprogram/utils/version.js @@ -0,0 +1,25 @@ +// 版本配置文件 +// 每次上传新版本时,请在此处更新版本号,与上传时填写的版本号保持一致 + +const VERSION_INFO = { + // 应用版本号(主版本号.次版本号.修订号) + // ⚠️ 重要:上传代码时,请确保这里的版本号与上传时填写的版本号一致 + version: '1.0.0', + // 版本名称(可选,如:正式版、测试版等) + versionName: '正式版' +} + +// 获取版本信息 +function getVersionInfo() { + return Object.assign({}, VERSION_INFO, { + // 完整版本号(包含版本名称) + fullVersion: `${VERSION_INFO.version} (${VERSION_INFO.versionName})` + }) +} + +// 导出版本信息 +module.exports = { + VERSION_INFO, + getVersionInfo +} + diff --git "a/\345\210\206\347\261\273\346\226\271\346\241\210\350\256\276\350\256\241.md" "b/\345\210\206\347\261\273\346\226\271\346\241\210\350\256\276\350\256\241.md" new file mode 100644 index 0000000000000000000000000000000000000000..5cd4ad1c5a9ff69e1b9e3882f9ad9f34d4f98300 --- /dev/null +++ "b/\345\210\206\347\261\273\346\226\271\346\241\210\350\256\276\350\256\241.md" @@ -0,0 +1,355 @@ +# 菜品分类方案设计文档 + +## 问题描述 + +当前系统只有一个 `category` 字段(单选),用于营养成分分类。但实际需求是: +1. **菜品类型分类**(单选):炒菜、蒸菜、烧菜、凉菜、烧烤、水煮菜、火锅配餐、烧烤配菜、甜点、主食、面、汤类、卤菜、煎炸、炖菜(共15种) +2. **营养成分分类**(多选):蛋白质、碳水、脂肪、蔬菜(包含维生素、纤维) + +一个菜品可能包含多种营养成分,需要支持多选。 + +**设计说明:** +- 营养成分中的"蔬菜"涵盖了维生素和膳食纤维的营养价值,符合实际使用场景 +- 菜品类型描述的是"烹饪方式"或"菜品形态",不包含餐别概念 +- "早餐"餐别适用性通过 `suitable_meal_types` 字段标记(如:水煮鸡蛋适合早餐) + +## 设计方案:双分类体系 + +**结构:** +- `dish_type` (CharField, 单选) - 菜品类型 +- `nutrition_categories` (ManyToMany) - 营养成分(多选) +- `suitable_meal_types` (JSONField, 多选) - 餐别适用性 + +**优点:** +- ✅ 职责分离清晰:菜品类型用于展示/筛选,营养成分用于配餐逻辑 +- ✅ 查询效率高:可以分别对两种分类进行筛选 +- ✅ 扩展性好:后续可以轻松添加新的营养成分或类型 +- ✅ 向后兼容:保留原有的 `category` 字段,可以逐步迁移 + +**数据结构:** +```python +# 菜品类型(单选)- 共15种 +DISH_TYPE_CHOICES = ( + ('stir_fry', '炒菜'), + ('steam', '蒸菜'), + ('braise', '烧菜'), + ('cold', '凉菜'), + ('bbq', '烧烤'), + ('boiled', '水煮菜'), + ('hotpot', '火锅配餐'), + ('bbq_side', '烧烤配菜'), + ('dessert', '甜点'), + ('staple', '主食'), + ('noodle', '面'), + ('soup', '汤类'), + ('stew', '炖菜'), + ('braised_food', '卤菜'), + ('fried', '煎炸'), +) + +# 餐别适用性(多选)- JSONField 数组 +# 例如:['breakfast', 'lunch'] 表示适合早餐和午餐 + +# 营养成分(多选) +class NutritionCategory(models.Model): + code = CharField('代码', max_length=20, unique=True) + name = CharField('名称', max_length=50) + +# 营养成分分类(包含维生素、纤维) +NUTRITION_CHOICES = ( + ('protein', '蛋白质'), + ('carb', '碳水'), + ('fat', '脂肪'), + ('vegetable', '蔬菜'), # 包含维生素、纤维 +) +``` + +### 数据库设计 + +```python +class NutritionCategory(models.Model): + """营养成分分类模型""" + code = models.CharField('代码', max_length=20, unique=True) + name = models.CharField('名称', max_length=50) + order = models.IntegerField('排序', default=0) + description = models.TextField('说明', blank=True, help_text='营养成分的说明,如:蔬菜包含维生素、纤维') + + class Meta: + db_table = 'nutrition_categories' + verbose_name = '营养成分分类' + verbose_name_plural = '营养成分分类' + ordering = ['order', 'id'] + + def __str__(self): + return self.name + + +class Dish(models.Model): + """菜品模型""" + + # 菜品类型(单选)- 共15种 + DISH_TYPE_CHOICES = ( + ('stir_fry', '炒菜'), + ('steam', '蒸菜'), + ('braise', '烧菜'), + ('cold', '凉菜'), + ('bbq', '烧烤'), + ('boiled', '水煮菜'), + ('hotpot', '火锅配餐'), + ('bbq_side', '烧烤配菜'), + ('dessert', '甜点'), + ('staple', '主食'), + ('noodle', '面'), + ('soup', '汤类'), + ('stew', '炖菜'), + ('braised_food', '卤菜'), + ('fried', '煎炸'), + ) + + # 餐别适用性(多选) + MEAL_TYPE_CHOICES = ( + ('breakfast', '早餐'), + ('lunch', '午餐'), + ('dinner', '晚餐'), + ) + + # 保留原有分类(用于向后兼容,可选) + CATEGORY_CHOICES = ( + ('vegetable', '蔬菜'), + ('protein', '蛋白质'), + ('carb', '碳水'), + ('fat', '脂肪'), + ) + + # ... 其他字段 ... + dish_type = models.CharField('菜品类型', max_length=20, choices=DISH_TYPE_CHOICES, null=True, blank=True) + + # 餐别适用性(多选)- JSONField 存储数组 + suitable_meal_types = models.JSONField( + '适合的餐别', + default=list, + blank=True, + help_text='该菜品适合的餐别列表,如:["breakfast", "lunch"]' + ) + + nutrition_categories = models.ManyToManyField( + NutritionCategory, + related_name='dishes', + verbose_name='营养成分分类', + blank=True, + help_text='一个菜品可以包含多种营养成分(多选)' + ) + # 保留 category 字段用于向后兼容(可选) + category = models.CharField('分类', max_length=20, choices=CATEGORY_CHOICES, null=True, blank=True) +``` + +**初始数据(营养成分分类):** +```python +# 在迁移文件中创建初始数据 +NUTRITION_CATEGORIES = [ + {'code': 'protein', 'name': '蛋白质', 'order': 1}, + {'code': 'carb', 'name': '碳水', 'order': 2}, + {'code': 'fat', 'name': '脂肪', 'order': 3}, + {'code': 'vegetable', 'name': '蔬菜', 'order': 4, 'description': '包含维生素、纤维'}, +] +``` + +### 实施步骤 + +1. **创建营养成分分类表** + - 创建 `NutritionCategory` 模型 + - 创建初始数据迁移 + +2. **修改 Dish 模型** + - 添加 `dish_type` 字段 + - 添加 `nutrition_categories` 多对多关系 + - 保留 `category` 字段(向后兼容) + +3. **数据迁移** + - 将现有 `category` 数据迁移到 `nutrition_categories` + - 为现有菜品设置默认的 `dish_type` + +4. **更新后端API** + - 修改序列化器支持新字段 + - 更新筛选逻辑 + - 更新配餐公式逻辑 + +5. **更新前端** + - 修改菜品选择器支持多选营养成分 + - 添加菜品类型筛选 + - 更新配餐逻辑 + +## 使用示例 + +### 后端查询示例 + +```python +# 按菜品类型筛选 +dishes = Dish.objects.filter(dish_type='stir_fry') + +# 按营养成分筛选(包含蛋白质的菜品) +dishes = Dish.objects.filter(nutrition_categories__code='protein') + +# 同时包含蛋白质和碳水的菜品 +dishes = Dish.objects.filter( + nutrition_categories__code='protein' +).filter( + nutrition_categories__code='carb' +).distinct() + +# 配餐公式检查(需要包含蛋白质、碳水、蔬菜等) +required_nutritions = ['protein', 'carb', 'vegetable'] +dishes = Dish.objects.filter( + nutrition_categories__code__in=required_nutritions +).distinct() + +# 组合筛选:菜品类型为炒菜,且包含蛋白质 +dishes = Dish.objects.filter( + dish_type='stir_fry', + nutrition_categories__code='protein' +).distinct() + +# 查询适合早餐的菜品 +breakfast_dishes = Dish.objects.filter( + suitable_meal_types__contains=['breakfast'] +) + +# 组合筛选:适合早餐且是炒菜类型的菜品 +breakfast_stir_fry = Dish.objects.filter( + dish_type='stir_fry', + suitable_meal_types__contains=['breakfast'] +) +``` + +### 前端使用示例 + +```javascript +// 筛选菜品类型为炒菜的菜品 +dishes.filter(d => d.dish_type === 'stir_fry') + +// 筛选包含蛋白质的菜品 +dishes.filter(d => d.nutrition_categories.some(c => c.code === 'protein')) + +// 筛选同时包含蛋白质和碳水的菜品 +dishes.filter(d => { + const categories = d.nutrition_categories.map(c => c.code) + return categories.includes('protein') && categories.includes('carb') +}) + +// 筛选包含蔬菜(维生素、纤维)的菜品 +dishes.filter(d => d.nutrition_categories.some(c => c.code === 'vegetable')) + +// 筛选适合早餐的菜品 +dishes.filter(d => d.suitable_meal_types && d.suitable_meal_types.includes('breakfast')) + +// 组合筛选:适合早餐且包含蛋白质的菜品 +dishes.filter(d => + d.suitable_meal_types && d.suitable_meal_types.includes('breakfast') && + d.nutrition_categories.some(c => c.code === 'protein') +) +``` + +## 最终确认的分类方案 + +### 营养成分分类(已整合到数据库设计) + +**最终确定的分类:** +- 蛋白质 (`protein`) +- 碳水 (`carb`) +- 脂肪 (`fat`) +- 蔬菜 (`vegetable`) - 包含维生素、纤维 + +**说明:** +- "蔬菜"分类涵盖了维生素和膳食纤维的营养价值,更符合实际使用场景 +- 如果后续需要更细粒度的分类(如单独区分维生素、纤维等),可以在数据库层面扩展 + +### 菜品类型分类(已整合到数据库设计) + +**共15种类型(已确认):** +- 炒菜 (`stir_fry`) +- 蒸菜 (`steam`) +- 烧菜 (`braise`) +- 凉菜 (`cold`) +- 烧烤 (`bbq`) +- 水煮菜 (`boiled`) +- 火锅配餐 (`hotpot`) +- 烧烤配菜 (`bbq_side`) +- 甜点 (`dessert`) +- 主食 (`staple`) +- 面 (`noodle`) +- 汤类 (`soup`) - 如:排骨汤、鸡汤、冬瓜汤等 +- 炖菜 (`stew`) - 如:红烧肉、炖牛肉等 +- 卤菜 (`braised_food`) - 如:卤鸡爪、卤牛肉等 +- 煎炸 (`fried`) - 如:煎蛋、炸鸡等 + +**说明:** +- 所有类型均为烹饪方式或菜品形态,不包含餐别概念 +- "早餐"餐别适用性通过 `suitable_meal_types` 字段标记 + +**关于"早餐"类型的处理方案:** + +"早餐"是一个**餐别概念**而非**烹饪方式**,不应放在 `dish_type` 中。 + +**推荐方案:餐别适用性字段** + +在 `Dish` 模型中添加 `suitable_meal_types` 字段,标记菜品适合的餐别: + +**优点:** +- ✅ 职责清晰:菜品类型描述"怎么做的"(烹饪方式),餐别适用性描述"适合什么时候吃" +- ✅ 灵活性:一个菜品可以同时适合多个餐别(如:鸡蛋适合早餐、午餐、晚餐) +- ✅ 符合业务逻辑:与实际使用场景匹配 + +**设计要点:** +- 从 `dish_type` 中移除 "早餐" +- 添加 `suitable_meal_types` 字段(JSONField 数组),支持多选 + +## 实施注意事项 + +### 数据库迁移注意事项 + +1. **向后兼容**:保留 `category` 字段,逐步迁移现有数据 +2. **数据迁移策略**: + - 将现有 `category` 数据映射到 `nutrition_categories` + - 为现有菜品设置默认的 `dish_type`(可能需要手动或通过规则判断) + - 确保迁移后所有菜品都有 `dish_type` 和至少一个 `nutrition_categories` + +3. **初始数据准备**: + - 创建营养成分分类的初始数据(蛋白质、碳水、脂肪、蔬菜) + - 为每个分类设置合适的排序顺序 + +### 代码更新注意事项 + +1. **后端API更新**: + - 修改序列化器支持 `dish_type` 和 `nutrition_categories` 字段 + - 更新筛选逻辑,支持按菜品类型和营养成分筛选 + - 更新配餐公式逻辑,使用 `nutrition_categories` 而不是 `category` + - 更新菜品选择器API,支持多选营养成分 + +2. **前端适配**: + - 修改菜品创建/编辑表单,支持选择菜品类型(单选)和营养成分(多选) + - 更新菜品选择器,支持多选营养成分的UI组件 + - 更新筛选功能,支持菜品类型筛选 + - 更新配餐逻辑,检查营养成分是否符合配餐公式 + +3. **配餐公式更新**: + ```python + # 原有的配餐公式(基于 category) + breakfast: ['vegetable', 'protein'] + lunch: ['vegetable', 'protein', 'carb', 'fat'] + dinner: ['vegetable', 'protein', 'carb'] + + # 更新后的配餐公式(基于 nutrition_categories) + breakfast: ['vegetable', 'protein'] + lunch: ['vegetable', 'protein', 'carb', 'fat'] + dinner: ['vegetable', 'protein', 'carb'] + # 注意:现在 vegetable 是指营养成分分类,而不是菜品类型 + ``` + +## 下一步 + +请确认是否采用方案一,如果同意,我可以开始实施: +1. 创建数据库迁移 +2. 更新模型代码 +3. 更新后端API +4. 更新前端代码 + diff --git "a/\345\233\276\347\211\207\344\274\230\345\214\226\345\256\236\346\226\275\346\200\273\347\273\223.md" "b/\345\233\276\347\211\207\344\274\230\345\214\226\345\256\236\346\226\275\346\200\273\347\273\223.md" new file mode 100644 index 0000000000000000000000000000000000000000..f219f4cfcd4ecabd14052c0ccd42a1dbc87431fc --- /dev/null +++ "b/\345\233\276\347\211\207\344\274\230\345\214\226\345\256\236\346\226\275\346\200\273\347\273\223.md" @@ -0,0 +1,114 @@ +# 图片加载优化实施总结 + +## 已完成的优化 + +### 1. 后端图片处理 ✅ + +#### 1.1 创建图片处理工具类 +**文件**: `backend/meal_architect/utils/image_processor.py` + +- `compress_image()`: 压缩图片(最大1920x1080,质量85%) +- `generate_thumbnail()`: 生成缩略图(400x300,质量80%) +- `process_dish_image()`: 处理菜品图片(压缩+缩略图) +- `process_avatar_image()`: 处理头像(压缩到800x800) +- `process_step_image()`: 处理制作步骤图片(压缩到1200x900) + +#### 1.2 修改数据库模型 +**文件**: `backend/dishes/models.py` + +- 在 `DishImage` 模型中添加 `thumbnail` 字段 +- 添加 `dish_thumbnail_upload_path()` 函数用于缩略图存储路径 + +#### 1.3 修改视图处理 +**文件**: `backend/dishes/views.py` + +- `create()`: 创建菜品时自动压缩图片并生成缩略图 +- `upload_image()`: 上传菜品图片时自动压缩并生成缩略图 +- `upload_step_image()`: 上传制作步骤图片时自动压缩 + +**文件**: `backend/users/views.py` + +- `upload_avatar()`: 上传头像时自动压缩(800x800) + +#### 1.4 修改序列化器 +**文件**: `backend/dishes/serializers.py` + +- `DishImageSerializer`: 添加 `thumbnail_url` 字段返回缩略图URL +- `DishListSerializer.get_main_image()`: 优先返回缩略图URL(列表页使用) + +### 2. 小程序端优化 ✅ + +#### 2.1 添加懒加载 +- `miniprogram/pages/home/home.wxml`: 列表页图片添加 `lazy-load` +- `miniprogram/pages/menu/menu.wxml`: 菜单页图片添加 `lazy-load` +- `miniprogram/pages/gourmet/dish-detail/dish-detail.wxml`: 详情页图片添加 `lazy-load` + +#### 2.2 图片加载策略 +- 列表页自动使用缩略图(后端返回) +- 详情页使用原图(已压缩) +- 头像使用压缩后的图片(800x800) + +## 数据库迁移 + +**文件**: `backend/dishes/migrations/0001_initial_thumbnail.py` + +需要执行迁移: +```bash +cd backend +python manage.py migrate dishes +``` + +## 优化效果预期 + +### 优化前 +- 图片大小: 2-10MB(手机拍摄) +- 列表页加载10张图片: 30-60秒 +- 单张图片加载: 5-15秒 +- 用户体验: 很差 + +### 优化后 +- 原图大小: 200-500KB(压缩后) +- 缩略图大小: 50-100KB +- 列表页加载10张缩略图: 2-3秒 +- 单张缩略图加载: 0.2-0.5秒 +- 单张原图加载: 1-2秒 +- 用户体验: 良好 + +## 注意事项 + +### 1. 现有图片处理 +- 已上传的图片没有压缩和缩略图 +- 需要时可编写脚本批量处理旧图片 + +### 2. 存储空间 +- 缩略图会增加存储空间,但影响很小(每张约50-100KB) +- 原图压缩后节省大量空间 + +### 3. 性能影响 +- 图片压缩会增加服务器CPU使用 +- 对于单用户,影响可忽略 +- 上传时会有轻微延迟(压缩处理时间) + +### 4. 兼容性 +- 新上传的图片自动处理 +- 旧图片仍然可以正常显示(没有缩略图时会使用原图) + +## 技术细节 + +### 图片压缩参数 +- **菜品原图**: 最大1920x1080,质量85% +- **缩略图**: 400x300,质量80% +- **头像**: 最大800x800,质量85% +- **步骤图**: 最大1200x900,质量85% + +### 格式转换 +- 所有图片统一转换为JPEG格式 +- 自动处理透明通道(转换为白色背景) + +## 后续可选优化 + +1. **WebP格式支持**: 进一步减少图片大小(需要评估小程序兼容性) +2. **批量处理旧图片**: 编写脚本批量压缩和生成缩略图 +3. **CDN加速**: 使用CDN加速图片加载(需要配置) +4. **渐进式加载**: 先显示模糊缩略图,再加载清晰原图 + diff --git "a/\346\224\271\345\212\250\350\257\204\344\274\260\346\226\271\346\241\210.md" "b/\346\224\271\345\212\250\350\257\204\344\274\260\346\226\271\346\241\210.md" new file mode 100644 index 0000000000000000000000000000000000000000..8bd0efd7059c20373f9bc22c78333dcffec9342e --- /dev/null +++ "b/\346\224\271\345\212\250\350\257\204\344\274\260\346\226\271\346\241\210.md" @@ -0,0 +1,419 @@ +# 双分类体系改动评估方案 + +## 当前代码分析 + +### 当前数据结构 +- **Dish模型**:只有一个 `category` 字段(单选) + - 选项:`vegetable`(蔬菜)、`protein`(蛋白质)、`carb`(碳水)、`fat`(脂肪) + - 用于营养成分分类 + +### 当前使用场景 + +**1. 厨神端(dish-edit):** +- 创建/编辑菜品时选择 `category`(营养成分分类) +- 前端:`miniprogram/pages/chef/dish-edit/dish-edit.js` +- 使用字段:`formData.category` + +**2. 食神端(dish-selector):** +- 选择菜品时根据 `category` 筛选 +- 前端:`miniprogram/pages/gourmet/dish-selector/dish-selector.js` +- 使用字段:`dish.category`、`selectedCategories`、`categoryList` + +**3. 后端API:** +- `dish_selector` API:根据 `category` 筛选菜品 +- 序列化器:返回 `category` 和 `category_display` + +## 改动方案(最小化前端改动) + +**说明:数据库会重建,不需要考虑向后兼容** + +### 核心策略 + +1. **食神端简化显示** + - 食神端只使用 `nutrition_categories` 的第一个主分类进行筛选和显示 + - 前端代码改动很小(主要是字段名从 `category` 改为 `nutrition_categories[0]`) + +2. **厨神端新增字段** + - 新增 `dish_type` 选择(菜品类型)- 必选 + - 修改 `category` 为 `nutrition_categories`(营养成分,多选,可选) + - 营养成分用于后续分析功能 + +--- + +## 详细改动清单 + +### 一、数据库模型改动 + +#### 1.1 新增模型和字段 + +```python +# backend/dishes/models.py + +class NutritionCategory(models.Model): + """营养成分分类模型""" + code = models.CharField('代码', max_length=20, unique=True) + name = models.CharField('名称', max_length=50) + order = models.IntegerField('排序', default=0) + + class Meta: + db_table = 'nutrition_categories' + verbose_name = '营养成分分类' + verbose_name_plural = '营养成分分类' + ordering = ['order', 'id'] + + def __str__(self): + return self.name + + +class Dish(models.Model): + """菜品模型""" + + # 菜品类型(必选) + dish_type = models.CharField('菜品类型', max_length=20, choices=DISH_TYPE_CHOICES) + + # 营养成分分类(多选,可选,用于后续分析) + nutrition_categories = models.ManyToManyField( + NutritionCategory, + related_name='dishes', + verbose_name='营养成分分类', + blank=True + ) + + # 餐别适用性(多选,可选) + suitable_meal_types = models.JSONField('适合的餐别', default=list, blank=True) +``` + +**改动说明:** +- ❌ 删除 `category` 字段(数据库重建,不需要保留) +- ✅ 新增 `dish_type`(菜品类型,必选) +- ✅ 新增 `nutrition_categories`(营养成分,多选,可选) +- ✅ 新增 `suitable_meal_types`(餐别适用性,多选,可选) + +--- + +### 二、后端改动 + +#### 2.1 序列化器改动 + +```python +# backend/dishes/serializers.py + +class DishSerializer(serializers.ModelSerializer): + """菜品序列化器""" + chef = UserSerializer(read_only=True) + ingredients = IngredientSerializer(many=True, required=False) + images = DishImageSerializer(many=True, required=False) + cooking_steps = CookingStepSerializer(many=True, required=False) + + # 原有字段 + status_display = serializers.CharField(source='get_status_display', read_only=True) + + # 新增字段 + dish_type = serializers.CharField(required=True) + dish_type_display = serializers.CharField(source='get_dish_type_display', read_only=True) + nutrition_categories = serializers.SerializerMethodField() + category = serializers.SerializerMethodField() # 兼容字段:返回第一个营养成分 + category_display = serializers.SerializerMethodField() # 兼容字段 + suitable_meal_types = serializers.JSONField(required=False, allow_null=True) + + class Meta: + model = Dish + fields = [ + 'id', 'chef', 'name', + # 新字段 + 'dish_type', 'dish_type_display', + 'nutrition_categories', 'suitable_meal_types', + # 兼容字段(供食神端使用) + 'category', 'category_display', + # 其他字段 + 'status', 'status_display', 'description', + 'ingredients', 'images', 'cooking_steps', + 'created_at', 'updated_at' + ] + read_only_fields = ['id', 'chef', 'created_at', 'updated_at'] + + def get_nutrition_categories(self, obj): + """获取营养成分分类""" + if obj.nutrition_categories.exists(): + return [{'code': nc.code, 'name': nc.name} for nc in obj.nutrition_categories.all()] + return [] + + def get_category(self, obj): + """兼容字段:返回第一个营养成分的code(供食神端筛选使用)""" + if obj.nutrition_categories.exists(): + return obj.nutrition_categories.first().code + return None + + def get_category_display(self, obj): + """兼容字段:返回第一个营养成分的名称""" + if obj.nutrition_categories.exists(): + return obj.nutrition_categories.first().name + return '' + + def create(self, validated_data): + """创建菜品""" + nutrition_categories = validated_data.pop('nutrition_categories', []) + dish = Dish.objects.create(**validated_data) + + # 设置营养成分分类 + if nutrition_categories: + dish.nutrition_categories.set(nutrition_categories) + + return dish + + def update(self, instance, validated_data): + """更新菜品""" + nutrition_categories = validated_data.pop('nutrition_categories', None) + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + + # 更新营养成分分类 + if nutrition_categories is not None: + instance.nutrition_categories.set(nutrition_categories) + + return instance +``` + +**改动说明:** +- ❌ 删除 `category` 字段(数据库重建,不需要保留) +- ✅ 新增 `dish_type`、`nutrition_categories`、`suitable_meal_types` +- ✅ **兼容字段**:`category` 和 `category_display` 通过 `SerializerMethodField` 返回第一个营养成分(供食神端使用) +- ✅ 食神端看到的 `category` 格式保持不变(但实际来源于 `nutrition_categories[0]`) + +--- + +#### 2.2 菜品选择器API改动(小改动) + +```python +# backend/plans/views.py - dish_selector函数 + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def dish_selector(request): + """菜品选择器API - 根据餐别和营养需求筛选菜品""" + # ... 原有代码 ... + + # 如果指定了分类(改为使用nutrition_categories筛选) + if category: + dishes_query = dishes_query.filter(nutrition_categories__code=category) + + # ... 其他代码保持不变 ... + + # 序列化数据时,category字段通过SerializerMethodField自动生成(不需要改动) +``` + +**改动说明:** +- ✅ **小改动**:筛选条件从 `category` 改为 `nutrition_categories__code` +- ✅ 返回数据中的 `category` 通过序列化器的 `get_category` 方法自动生成(兼容字段) + +--- + +#### 2.3 新增API:获取菜品类型和营养成分选项 + +```python +# backend/dishes/views.py + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_dish_type_choices(request): + """获取菜品类型选项""" + choices = [ + {'value': choice[0], 'label': choice[1]} + for choice in Dish.DISH_TYPE_CHOICES + ] + return Response({'dish_types': choices}) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_nutrition_category_choices(request): + """获取营养成分分类选项""" + if request.user.role != 'chef': + return Response({'error': '只有厨神可以查看营养成分分类'}, status=status.HTTP_403_FORBIDDEN) + + categories = NutritionCategory.objects.all().order_by('order', 'id') + choices = [{'code': nc.code, 'name': nc.name} for nc in categories] + return Response({'nutrition_categories': choices}) +``` + +--- + +### 三、前端改动 + +#### 3.1 厨神端(dish-edit)- **需要改动** + +**改动内容:** + +1. **新增菜品类型选择(必选)** + - 在表单中添加 `dish_type` 选择器 + - 从后端API获取 `dish_type` 选项 + +2. **修改营养成分选择(可选,多选)** + - 将原来的单选 `category` 改为多选 `nutrition_categories` + - 标记为"可选"(用于后续分析功能) + - 可以不选择营养成分(用于后续分析功能) + +**文件:** `miniprogram/pages/chef/dish-edit/dish-edit.js` + +```javascript +// 修改字段结构 +formData: { + name: '', + dish_type: '', // 新增:菜品类型(必选) + nutrition_categories: [], // 修改:营养成分分类(多选,可选) + status: 'published', + // ... 其他字段 +}, + +// 新增:菜品类型选项 +dishTypes: [], // 从后端加载 + +// 修改:营养成分分类(改为多选,可选) +nutritionCategories: [], // 从后端加载 + +onLoad(options) { + // ... 原有代码 ... + this.loadDishTypes() // 新增:加载菜品类型 + this.loadNutritionCategories() // 新增:加载营养成分分类(仅厨神) +} + +// 新增:加载菜品类型 +loadDishTypes() { + get('/api/dishes/dish-type-choices/') + .then(res => { + this.setData({ dishTypes: res.dish_types || [] }) + }) + .catch(err => { + console.error('加载菜品类型失败:', err) + }) +} + +// 新增:加载营养成分分类 +loadNutritionCategories() { + get('/api/dishes/nutrition-category-choices/') + .then(res => { + this.setData({ nutritionCategories: res.nutrition_categories || [] }) + }) + .catch(err => { + console.error('加载营养成分分类失败:', err) + }) +} + +// 修改:保存菜品 +saveDish() { + const dishData = { + name: formData.name.trim(), + dish_type: formData.dish_type, // 必选:菜品类型 + nutrition_categories: formData.nutrition_categories.map(nc => nc.code), // 可选:营养成分 + status: formData.status, + description: formData.description || '' + } + + // ... 其他代码 +} +``` + +**改动量:** 中等(新增菜品类型选择,营养成分改为多选可选) + +--- + +#### 3.2 食神端(dish-selector)- **小改动** + +**改动内容:** +- ✅ **保持原有逻辑不变** +- ✅ 继续使用 `dish.category` 筛选和显示 +- ✅ 后端通过兼容字段自动生成 `category`,前端看到的数据格式不变 +- ⚠️ 需要确保筛选逻辑使用 `category` 字段(后端API会自动处理) + +**文件:** `miniprogram/pages/gourmet/dish-selector/dish-selector.js` + +**改动量:** **小**(字段名保持不变,只需确保后端API正常返回 `category` 字段) + +--- + +#### 3.3 其他前端页面 + +**菜单浏览(menu):** +- ✅ 保持原有 `category` 筛选逻辑 +- ✅ **几乎不需要改动** + +**改动量:** **几乎为0** + +--- + +## 改动总结 + +### 改动量评估 + +| 模块 | 改动量 | 说明 | +|------|--------|------| +| **数据库模型** | 中等 | 新增表和字段,保留原字段 | +| **后端序列化器** | 中等 | 新增字段,自动映射逻辑 | +| **后端API** | 小 | 新增获取选项API,菜品选择器几乎不改 | +| **前端厨神端** | 中等 | 新增菜品类型选择,营养成分改为多选可选 | +| **前端食神端** | **几乎为0** | 保持原有逻辑,字段格式不变 | + +### 关键优势 + +1. ✅ **前端食神端几乎不需要改动** + - 继续使用 `category` 字段 + - 数据格式不变 + - 筛选逻辑不变 + +2. ✅ **向后兼容** + - 保留 `category` 字段 + - 自动映射机制 + - 现有数据仍然可用 + +3. ✅ **符合需求** + - 厨神端可以选择菜品类型和营养成分(可选) + - 食神端不需要关注营养成分 + - 营养成分用于后续分析功能 + +### 实施步骤 + +1. **数据库迁移** + - 创建 `NutritionCategory` 模型 + - 添加新字段到 `Dish` 模型 + - 创建初始数据 + +2. **后端改动** + - 更新模型和序列化器 + - 实现自动映射逻辑 + - 新增获取选项API + +3. **前端厨神端** + - 新增菜品类型选择 + - 营养成分改为多选可选 + +4. **前端食神端** + - 验证现有功能正常(几乎不需要改动) + +5. **测试验证** + - 测试向后兼容 + - 测试自动映射 + - 测试筛选功能 + +--- + +## 注意事项 + +1. **数据库重建** + - 删掉现有数据库,重建新的数据库结构 + - 不需要数据迁移逻辑 + +2. **兼容字段逻辑** + - 确保序列化器的 `get_category` 方法能正确返回第一个营养成分 + - 如果没有选择营养成分,`category` 返回 `null` + +3. **可选字段** + - `nutrition_categories` 是可选的(用于后续分析功能) + - `dish_type` 必选(厨神上传时必须选择) + - `suitable_meal_types` 可选(用于标记适合的餐别) + +4. **筛选逻辑** + - 后端API筛选时使用 `nutrition_categories__code=category` + - 返回数据时通过兼容字段 `category` 返回第一个营养成分 + - 食神端看到的格式保持不变 + diff --git "a/\351\241\265\351\235\242\346\270\205\345\215\225.md" "b/\351\241\265\351\235\242\346\270\205\345\215\225.md" new file mode 100644 index 0000000000000000000000000000000000000000..e55f82e16f69d86aef5be5c92de571dd2a5e5daf --- /dev/null +++ "b/\351\241\265\351\235\242\346\270\205\345\215\225.md" @@ -0,0 +1,111 @@ +# Meal Architect 小程序页面清单 + +## 📋 概述 +- **总页面数**: 30个页面 +- **角色类型**: 2种(食神、厨神) +- **创建时间**: 2024 + +--- + +## 🎭 角色说明 + +### 1️⃣ 食神(Gourmet) +- 浏览厨神菜单 +- 选择菜品 +- 查看每日饮食计划 +- 关注厨神 + +### 2️⃣ 厨神(Chef) +- 管理菜品 +- 创建套餐 +- 服务食神 +- 查看排班和购物清单 + +--- + +## 📄 完整页面清单 + +### 🔐 认证与身份模块(7页) +| 页面 | 路径 | 说明 | +|------|------|------| +| `login` | `pages/login/login` | 用户登录页面 | +| `role-select` | `pages/role-select/role-select` | 角色选择页面 | +| `profile` | `pages/profile/profile` | 个人中心 | +| `profile-edit` | `pages/profile-edit/profile-edit` | 编辑个人资料 | +| `avatar-crop` | `pages/avatar-crop/avatar-crop` | 头像裁剪 | +| `settings` | `pages/settings/settings` | 设置页面 | + +### 🌍 公共页面模块(7页) +| 页面 | 路径 | 说明 | +|------|------|------| +| `home` | `pages/home/home` | 首页(TabBar) | +| `menu` | `pages/menu/menu` | 菜单(TabBar) | +| `plan` | `pages/plan/plan` | 计划(TabBar) | +| `feedback` | `pages/feedback/feedback` | 意见反馈 | +| `about` | `pages/about/about` | 关于我们 | +| `user-agreement` | `pages/user-agreement/user-agreement` | 用户协议 | +| `privacy-policy` | `pages/privacy-policy/privacy-policy` | 隐私政策 | + +### 🍽️ 食神(Gourmet)专属模块(6页) +| 页面 | 路径 | 说明 | +|------|------|------| +| `calendar` | `pages/gourmet/calendar/calendar` | 日历视图 | +| `chefs` | `pages/gourmet/chefs/chefs` | 厨神列表 | +| `menu-browser` | `pages/gourmet/menu-browser/menu-browser` | 菜单浏览 | +| `daily-plan` | `pages/gourmet/daily-plan/daily-plan` | 每日计划 | +| `dish-selector` | `pages/gourmet/dish-selector/dish-selector` | 菜品选择器 | +| `dish-detail` | `pages/gourmet/dish-detail/dish-detail` | 菜品详情 | + +### 👨‍🍳 厨神(Chef)专属模块(8页) +| 页面 | 路径 | 说明 | +|------|------|------| +| `dishes` | `pages/chef/dishes/dishes` | 我的菜品 | +| `dish-edit` | `pages/chef/dish-edit/dish-edit` | 编辑/创建菜品 | +| `meal-sets` | `pages/chef/meal-sets/meal-sets` | 套餐管理 | +| `meal-set-edit` | `pages/chef/meal-set-edit/meal-set-edit` | 编辑/创建套餐 | +| `gourmets` | `pages/chef/gourmets/gourmets` | 我的食神 | +| `gourmet-plans` | `pages/chef/gourmet-plans/gourmet-plans` | 食神计划 | +| `schedule` | `pages/chef/schedule/schedule` | 排班管理 | +| `shopping-list` | `pages/chef/shopping-list/shopping-list` | 购物清单 | + +--- + +## 📊 统计 + +### 按模块统计 +- 认证与身份模块: 7页 +- 公共页面模块: 7页 +- 食神专属模块: 6页 +- 厨神专属模块: 8页 +- **合计**: 28页 + +### 按角色统计 +- **食神可见**: 7公共 + 6专属 = 13页 +- **厨神可见**: 7公共 + 8专属 = 15页 +- **公共页面**: 7页 + +--- + +## 🎯 TabBar 配置 +当前底部导航栏包含4个Tab: +1. **首页** - `pages/home/home` +2. **菜单** - `pages/menu/menu` +3. **计划** - `pages/plan/plan` +4. **我的** - `pages/profile/profile` + +--- + +## 📝 页面文件结构说明 +每个页面目录包含4个文件: +- `.js` - 页面逻辑 +- `.json` - 页面配置 +- `.wxml` - 页面结构 +- `.wxss` - 页面样式 + +--- + +## 🔍 备注 +- 部分页面可能需要根据用户角色动态显示/隐藏 +- TabBar 根据角色动态调整 +- 导航逻辑需要区分两种用户角色 + diff --git "a/\351\241\265\351\235\242\347\273\223\346\236\204\345\233\276.md" "b/\351\241\265\351\235\242\347\273\223\346\236\204\345\233\276.md" new file mode 100644 index 0000000000000000000000000000000000000000..98907343dcc3ab64f870f584308d4a1b20d2d154 --- /dev/null +++ "b/\351\241\265\351\235\242\347\273\223\346\236\204\345\233\276.md" @@ -0,0 +1,259 @@ +# Meal Architect 小程序页面结构图 + +## 📱 整体结构图 + +``` +配膳官小程序 +│ +├─── 【启动流程】 +│ ├─ login (登录) +│ └─ role-select (角色选择) +│ +├─── 【TabBar 主导航】(4个Tab) +│ ├─ home (首页) +│ ├─ menu (菜单) +│ ├─ plan (计划) +│ └─ profile (我的) +│ +├─── 【公共功能】 +│ ├─ profile-edit (编辑资料) +│ ├─ avatar-crop (头像裁剪) +│ ├─ settings (设置) +│ ├─ feedback (意见反馈) +│ ├─ about (关于) +│ ├─ user-agreement (用户协议) +│ └─ privacy-policy (隐私政策) +│ +├─── 【食神模块】 +│ ├─ calendar (日历) +│ ├─ chefs (厨神列表) +│ ├─ menu-browser (菜单浏览) +│ ├─ daily-plan (每日计划) +│ ├─ dish-selector (菜品选择) +│ └─ dish-detail (菜品详情) +│ +└─── 【厨神模块】 + ├─ dishes (我的菜品) + ├─ dish-edit (编辑菜品) + ├─ meal-sets (套餐管理) + ├─ meal-set-edit (编辑套餐) + ├─ gourmets (我的食神) + ├─ gourmet-plans (食神计划) + ├─ schedule (排班) + └─ shopping-list (购物清单) +``` + +--- + +## 🔄 用户导航流程图 + +### 启动流程 + +``` +[应用启动] + │ + ├─ 未登录 → login (登录) + │ │ + │ └─ role-select (选择角色) + │ │ + │ ├─ 食神 + │ │ └─ home (食神首页) + │ │ + │ └─ 厨神 + │ └─ home (厨神首页) + │ + └─ 已登录 → home (根据角色显示不同首页) +``` + +--- + +## 🍽️ 食神端导航流程 + +### 主要功能路径 + +``` +【TabBar 底部导航】 +│ +├─ home (首页) +│ ├─ → chefs (选择厨神) +│ │ ├─ → dish-detail (查看菜品详情) +│ │ └─ → menu-browser (浏览菜单) +│ │ +│ └─ → calendar (查看日历) +│ └─ → daily-plan (每日计划) +│ +├─ menu (菜单) +│ └─ → menu-browser (浏览菜单) +│ └─ → dish-selector (选择菜品) +│ └─ → dish-detail (菜品详情) +│ +├─ plan (计划) +│ ├─ → daily-plan (每日计划) +│ └─ → calendar (日历视图) +│ +└─ profile (我的) + ├─ → profile-edit (编辑资料) + │ └─ → avatar-crop (裁剪头像) + ├─ → settings (设置) + │ ├─ → feedback (意见反馈) + │ ├─ → about (关于) + │ ├─ → user-agreement (用户协议) + │ └─ → privacy-policy (隐私政策) + └─ → [退出登录] → login +``` + +--- + +## 👨‍🍳 厨神端导航流程 + +### 主要功能路径 + +``` +【TabBar 底部导航】 +│ +├─ home (首页) +│ ├─ → dishes (我的菜品) +│ │ ├─ → dish-edit (创建菜品) +│ │ └─ → dish-edit (编辑菜品) +│ │ +│ ├─ → meal-sets (套餐管理) +│ │ ├─ → meal-set-edit (创建套餐) +│ │ └─ → meal-set-edit (编辑套餐) +│ │ +│ └─ → gourmets (我的食神) +│ ├─ → gourmet-plans (食神计划) +│ └─ → schedule (查看排班) +│ +├─ menu (菜单) +│ └─ → dishes (菜品管理) +│ +├─ plan (计划) +│ ├─ → schedule (排班管理) +│ │ └─ → shopping-list (购物清单) +│ ├─ → gourmets (我的食神) +│ │ └─ → gourmet-plans (食神计划) +│ └─ → meal-sets (套餐管理) +│ +└─ profile (我的) + ├─ → profile-edit (编辑资料) + │ └─ → avatar-crop (裁剪头像) + ├─ → settings (设置) + │ ├─ → feedback (意见反馈) + │ ├─ → about (关于) + │ ├─ → user-agreement (用户协议) + │ └─ → privacy-policy (隐私政策) + └─ → [退出登录] → login +``` + +--- + +## 🎯 页面层级说明 + +### Level 1: 入口页面(TabBar) +- **home** - 首页 +- **menu** - 菜单 +- **plan** - 计划 +- **profile** - 我的 + +### Level 2: 功能模块页面 +**食神端:** +- **chefs** - 厨神列表 +- **calendar** - 日历视图 +- **menu-browser** - 菜单浏览 + +**厨神端:** +- **dishes** - 菜品管理 +- **meal-sets** - 套餐管理 +- **gourmets** - 食神管理 +- **schedule** - 排班管理 + +### Level 3: 详情/编辑页面 +**食神端:** +- **dish-detail** - 菜品详情 +- **dish-selector** - 菜品选择 +- **daily-plan** - 每日计划详情 + +**厨神端:** +- **dish-edit** - 编辑菜品 +- **meal-set-edit** - 编辑套餐 +- **gourmet-plans** - 食神计划详情 +- **shopping-list** - 购物清单 + +### Level 4: 辅助功能 +- **profile-edit** - 编辑资料 +- **avatar-crop** - 头像裁剪 +- **settings** - 设置 +- **feedback** - 意见反馈 +- **about** - 关于 +- **user-agreement** - 用户协议 +- **privacy-policy** - 隐私政策 + +--- + +## 📊 页面权限矩阵 + +| 页面 | 游客 | 食神 | 厨神 | 备注 | +|------|:----:|:----:|:----:|------| +| login | ✅ | ✅ | ✅ | 登录页面 | +| role-select | ✅ | ✅ | ✅ | 角色选择 | +| home | ✅ | ✅ | ✅ | TabBar | +| menu | ✅ | ✅ | ✅ | TabBar | +| plan | ✅ | ✅ | ✅ | TabBar | +| profile | ✅ | ✅ | ✅ | TabBar | +| chefs | ❌ | ✅ | ❌ | 食神专属 | +| calendar | ❌ | ✅ | ❌ | 食神专属 | +| menu-browser | ❌ | ✅ | ❌ | 食神专属 | +| daily-plan | ❌ | ✅ | ❌ | 食神专属 | +| dish-detail | ❌ | ✅ | ❌ | 食神专属 | +| dish-selector | ❌ | ✅ | ❌ | 食神专属 | +| dishes | ❌ | ❌ | ✅ | 厨神专属 | +| dish-edit | ❌ | ❌ | ✅ | 厨神专属 | +| meal-sets | ❌ | ❌ | ✅ | 厨神专属 | +| meal-set-edit | ❌ | ❌ | ✅ | 厨神专属 | +| gourmets | ❌ | ❌ | ✅ | 厨神专属 | +| gourmet-plans | ❌ | ❌ | ✅ | 厨神专属 | +| schedule | ❌ | ❌ | ✅ | 厨神专属 | +| shopping-list | ❌ | ❌ | ✅ | 厨神专属 | + +--- + +## 🔀 数据流向说明 + +### 食神端核心流程 +``` +1. 选择厨神 (chefs) + ↓ +2. 浏览菜单 (menu-browser) + ↓ +3. 选择菜品 (dish-selector) + ↓ +4. 查看详情 (dish-detail) + ↓ +5. 加入计划 (daily-plan) +``` + +### 厨神端核心流程 +``` +1. 创建菜品 (dish-edit) + ↓ +2. 创建套餐 (meal-set-edit) + ↓ +3. 服务食神 (gourmets) + ↓ +4. 查看计划 (gourmet-plans) + ↓ +5. 排班管理 (schedule) + ↓ +6. 生成购物清单 (shopping-list) +``` + +--- + +## 📝 备注 + +1. **TabBar 动态配置**: 根据用户角色(食神/厨神)动态显示/隐藏 Tab +2. **页面权限控制**: 部分页面需要根据用户角色进行权限验证 +3. **导航守卫**: 未登录用户访问需要登录的页面会跳转到登录页 +4. **数据缓存**: 用户信息和菜单数据采用本地缓存策略 +5. **跨平台**: 支持开发环境、体验版、正式版自动切换 +