diff --git a/backend/competition/admin.py b/backend/competition/admin.py index 798a5ff..3dc340b 100644 --- a/backend/competition/admin.py +++ b/backend/competition/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from unfold.admin import ModelAdmin from unfold.decorators import display -from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment +from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment, ScoreFormula class ScoreDimensionInline(admin.TabularInline): model = ScoreDimension @@ -143,3 +143,60 @@ class CommentAdmin(ModelAdmin): def content_preview(self, obj): return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content content_preview.short_description = "评语内容" + + +class ScoreFormulaAdmin(ModelAdmin): + """ + 评分公式管理 + 提供可视化公式编辑功能 + """ + list_display = ['name', 'competition', 'formula_preview_display', 'is_active', 'is_default', 'created_at'] + list_filter = ['competition', 'is_active', 'is_default'] + search_fields = ['name', 'description', 'formula', 'competition__title'] + autocomplete_fields = ['competition'] + + fieldsets = ( + ('基本信息', { + 'fields': ('competition', 'name', 'description') + }), + ('公式配置', { + 'fields': ('formula',), + 'description': '使用维度名称作为变量,例如: 创新性 * 0.3 + 实用性 * 0.5 + 演示效果 * 0.2' + }), + ('公式设置', { + 'fields': ('is_active', 'is_default') + }), + ) + + @display(description="公式预览") + def formula_preview_display(self, obj): + preview = obj.get_formula_preview() + return preview[:100] + '...' if len(preview) > 100 else preview if preview else '-' + + def get_form_kwargs(self, request, *args, **kwargs): + kwargs = super().get_form_kwargs(request, *args, **kwargs) + return kwargs + + def changeform_view(self, request, object_id=None, form_url='', extra_context=None): + extra_context = extra_context or {} + + if request.method == 'GET' and not object_id: + competition_id = request.GET.get('competition') + if competition_id: + try: + from .models import ScoreDimension + dimensions = ScoreDimension.objects.filter(competition_id=competition_id) + extra_context['dimensions'] = dimensions + except: + pass + + return super().changeform_view(request, object_id, form_url, extra_context) + + class Media: + css = { + 'all': ('competition/admin/css/formula-editor.css',) + } + js = ('competition/admin/js/formula-editor.js',) + + +admin.site.register(ScoreFormula, ScoreFormulaAdmin) diff --git a/backend/competition/migrations/0008_scoreformula.py b/backend/competition/migrations/0008_scoreformula.py new file mode 100644 index 0000000..b9b82d4 --- /dev/null +++ b/backend/competition/migrations/0008_scoreformula.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0.1 on 2026-03-20 05:27 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competition', '0007_competition_custom_score_formula_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ScoreFormula', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='用于标识这个公式,方便管理', max_length=100, verbose_name='公式名称')), + ('description', models.TextField(blank=True, verbose_name='公式说明')), + ('formula', models.TextField(help_text='使用维度名称作为变量,支持四则运算和函数', verbose_name='计算公式')), + ('is_active', models.BooleanField(default=True, verbose_name='是否启用')), + ('is_default', models.BooleanField(default=False, verbose_name='是否设为默认公式')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='score_formulas', to='competition.competition', verbose_name='所属比赛')), + ], + options={ + 'verbose_name': '评分公式', + 'verbose_name_plural': '评分公式配置', + 'ordering': ['-is_default', '-created_at'], + }, + ), + ] diff --git a/backend/competition/models.py b/backend/competition/models.py index 72aa391..7b20bb5 100644 --- a/backend/competition/models.py +++ b/backend/competition/models.py @@ -177,13 +177,14 @@ class Project(models.Model): def calculate_score(self): """ 计算项目得分 - 支持两种模式: + 支持三种模式: 1. 默认加权平均:每个评委的得分 = sum(维度分数 × 维度权重),然后所有评委取平均 - 2. 自定义算式:使用比赛级别的 custom_score_formula 计算最终得分 + 2. 自定义算式(比赛级别):使用比赛级别的 custom_score_formula 计算最终得分 + 3. 公式配置(公式级别):使用 ScoreFormula 模型中的公式配置 自定义算式变量格式: - dimension_X: 第X个维度的平均分(所有评委对该维度的平均分) - - 也可以在算式中直接使用维度ID + - 也可以使用维度名称作为变量 """ scores = self.scores.all() if not scores.exists(): @@ -193,6 +194,15 @@ class Project(models.Model): competition = self.competition + active_formula = ScoreFormula.objects.filter( + competition=competition, + is_active=True, + is_default=True + ).first() + + if active_formula: + return self._calculate_formula_score(scores, active_formula) + if competition.score_calculation_type == 'custom' and competition.custom_score_formula: return self._calculate_custom_score(scores, competition.custom_score_formula) @@ -227,7 +237,7 @@ class Project(models.Model): def _calculate_custom_score(self, scores, formula): """ - 自定义算式模式 + 自定义算式模式(比赛级别) 使用比赛配置的自定义算式计算得分 """ dimension_scores = {} @@ -238,6 +248,7 @@ class Project(models.Model): if dim_scores.exists(): avg = sum(float(s.score) for s in dim_scores) / dim_scores.count() dimension_scores[f'dimension_{dimension.id}'] = avg + dimension_scores[dimension.name] = avg if not dimension_scores: self.final_score = 0 @@ -254,6 +265,37 @@ class Project(models.Model): print(f"算式计算错误: {e}, formula: {formula}, values: {dimension_scores}") return self._calculate_default_score(scores) + def _calculate_formula_score(self, scores, formula_obj): + """ + 公式配置模式(使用 ScoreFormula 模型) + 使用公式配置中的公式计算得分 + """ + dimension_scores = {} + + dimensions = self.competition.score_dimensions.all() + for dimension in dimensions: + dim_scores = scores.filter(dimension=dimension) + if dim_scores.exists(): + avg = sum(float(s.score) for s in dim_scores) / dim_scores.count() + dimension_scores[dimension.name] = avg + + if not dimension_scores: + self.final_score = 0 + self.save() + return 0 + + formula = formula_obj.formula + + try: + result = eval(formula, {"__builtins__": {}}, dimension_scores) + final_score = float(result) + self.final_score = round(final_score, 2) + self.save() + return self.final_score + except Exception as e: + print(f"公式计算错误: {e}, formula: {formula}, values: {dimension_scores}") + return self._calculate_default_score(scores) + def calculate_judge_score(self, judge): """ 计算指定评委对该项目的得分 @@ -346,3 +388,70 @@ class Comment(models.Model): def __str__(self): return f"{self.judge.user.nickname} -> {self.project.title}" + + +class ScoreFormula(models.Model): + """ + 评分公式配置 + 用于可视化配置得分计算公式 + """ + competition = models.ForeignKey(Competition, on_delete=models.CASCADE, related_name='score_formulas', verbose_name="所属比赛") + name = models.CharField(max_length=100, verbose_name="公式名称", help_text="用于标识这个公式,方便管理") + description = models.TextField(verbose_name="公式说明", blank=True) + + formula = models.TextField(verbose_name="计算公式", help_text="使用维度名称作为变量,支持四则运算和函数") + + is_active = models.BooleanField(default=True, verbose_name="是否启用") + is_default = models.BooleanField(default=False, verbose_name="是否设为默认公式") + + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") + + class Meta: + verbose_name = "评分公式" + verbose_name_plural = "评分公式配置" + ordering = ['-is_default', '-created_at'] + + def __str__(self): + return f"{self.competition.title} - {self.name}" + + def get_formula_preview(self): + """ + 获取公式预览,将维度变量替换为维度名称 + """ + if not self.formula: + return "" + + dimension_map = {f'd["{d.name}"]': f'[{d.name}]' for d in self.competition.score_dimensions.all()} + dimension_map.update({f"d['{d.name}']": f'[{d.name}]' for d in self.competition.score_dimensions.all()}) + + result = self.formula + for old, new in dimension_map.items(): + result = result.replace(old, new) + + return result + + def generate_python_code(self): + """ + 生成可执行的 Python 代码 + """ + if not self.formula: + return "" + + dimension_names = [d.name for d in self.competition.score_dimensions.all()] + + code_lines = [ + "def calculate_score(d):", + " '''", + f" 计算公式: {self.name}", + " 参数 d: 字典,键为维度名称,值为该维度的平均分", + " '''", + ] + + for name in dimension_names: + code_lines.append(f" {name} = d.get('{name}', 0)") + + code_lines.append("") + code_lines.append(f" return {self.formula}") + + return "\n".join(code_lines) diff --git a/backend/competition/urls.py b/backend/competition/urls.py index 4198ea6..78ed5a7 100644 --- a/backend/competition/urls.py +++ b/backend/competition/urls.py @@ -2,7 +2,7 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter from .views import ( CompetitionViewSet, ProjectViewSet, ProjectFileViewSet, - ScoreViewSet, CommentViewSet + ScoreViewSet, CommentViewSet, CompetitionDimensionsAPIView ) from . import judge_views @@ -18,6 +18,9 @@ urlpatterns = [ path('admin/', judge_views.admin_entry, name='judge_admin_entry'), path('judge/', include('competition.judge_urls')), # Sub-routes under /judge/ + # API Routes + path('competition//dimensions/', CompetitionDimensionsAPIView.as_view(), name='competition-dimensions'), + # Existing API Routes path('', include(router.urls)), ] diff --git a/backend/competition/views.py b/backend/competition/views.py index 34c1dd0..86096bf 100644 --- a/backend/competition/views.py +++ b/backend/competition/views.py @@ -1,6 +1,7 @@ from rest_framework import viewsets, permissions, status, filters, serializers -from rest_framework.decorators import action +from rest_framework.decorators import action, api_view, permission_classes from rest_framework.response import Response +from rest_framework.views import APIView from django.db.models import Q from shop.utils import get_current_wechat_user from .models import Competition, CompetitionEnrollment, Project, ProjectFile, Score, Comment, ScoreDimension @@ -280,3 +281,31 @@ class CommentViewSet(viewsets.ModelViewSet): raise serializers.ValidationError("您不是该比赛的评委") serializer.save(judge=enrollment) + + +class CompetitionDimensionsAPIView(APIView): + """ + 获取比赛评分维度的API + """ + permission_classes = [permissions.AllowAny] + + def get(self, request, competition_id): + try: + competition = Competition.objects.get(id=competition_id) + dimensions = ScoreDimension.objects.filter(competition=competition).order_by('order') + + data = { + 'dimensions': [ + { + 'id': d.id, + 'name': d.name, + 'weight': float(d.weight), + 'max_score': d.max_score, + 'description': d.description + } + for d in dimensions + ] + } + return Response(data) + except Competition.DoesNotExist: + return Response({'error': '比赛不存在'}, status=404)