diff --git a/docs/superpowers/plans/2026-08-06-problem-reaction.md b/docs/superpowers/plans/2026-08-06-problem-reaction.md index dce6b10..4bb681c 100644 --- a/docs/superpowers/plans/2026-08-06-problem-reaction.md +++ b/docs/superpowers/plans/2026-08-06-problem-reaction.md @@ -2,6 +2,8 @@ > 2026-08-06 规则更新:当前实现已改为单选、点击即提交、提交后不可修改,并通过 `(problem, user)` 数据库唯一约束保证一人一题一条。新接口使用单值 `type` / `mine_type`;为支持前后端错序部署,过渡期仍接受单元素 `types` 并返回数组 `mine`。统计直接查询数据库,不再使用 reaction 缓存。本文中的多选及缓存步骤是早期实施记录。 +> 2026-08-06 后台形态变更:Task 3(管理接口 `/api/admin/reaction`)与 Task 6(后台反馈统计页)的产物已整体删除。后台反馈现在只是**题目列表的一列**:显示票数最高的那个表情图标,tooltip 出「标签名 + 人数」,无评价留空,并列取 `ReactionType` 定义序靠前的一个。数据由 `GET /api/admin/problem` 的 `top_reaction` 字段下发,聚合逻辑在 `reaction/services.py:get_top_reactions()`。详见 spec 顶部同日说明。 + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 把题目点评从「三维评分 + 文字」重写为「一排七个表情按钮,点击即表态」,并把后台从逐条评论管理改为按题目聚合的反馈统计表。 @@ -1090,6 +1092,8 @@ Co-Authored-By: Claude Opus 5 " 实现全部完成并通过终审。以下是评审过程中判定「可以带着合并」的次要项,都不阻塞上线,记在这里免得日后重新发现一遍。 +其中涉及后台统计页与统计缓存的四条(题号筛选返回空结果、缓存快照少算一票、`ReactionStatsRow` 缺 `_ratio`、后台筛选不重置页码与 `listStats` 无序号守卫)随后台页面下线与缓存移除一并作废,代码已不存在,保留仅作记录。 + **后端** - `problem_id` 传非数字(如 `?problem_id=abc`)会走兜底 except 返回泛化 500 并打一条 stack trace。前端始终传数字,实际影响只是日志噪音。与原 `comment/views/oj.py` 的写法一致。 diff --git a/docs/superpowers/specs/2026-08-06-problem-reaction-design.md b/docs/superpowers/specs/2026-08-06-problem-reaction-design.md index 64151b5..4859462 100644 --- a/docs/superpowers/specs/2026-08-06-problem-reaction-design.md +++ b/docs/superpowers/specs/2026-08-06-problem-reaction-design.md @@ -2,6 +2,8 @@ > 2026-08-06 规则更新:评价已改为单选,点击一个表情后立即提交,且提交后不可修改。一人一题在数据库中最多保留一条记录。新请求字段为 `type`,新响应字段为 `mine_type: ReactionKey | null`;为支持前后端错序部署,过渡期仍接受单元素 `types` 并返回数组 `mine`。计数直接查询数据库,不使用 Redis 缓存。本文后续关于“最多选 3 个、可取消、整份覆盖、统计缓存”的内容仅保留为早期设计记录,不再代表当前行为。 +> 2026-08-06 后台形态变更:独立的后台反馈统计页与 `GET /api/admin/reaction` 已全部下线(`reaction/views/admin.py`、`reaction/urls/admin.py`、`src/admin/reaction/list.vue`、路由与菜单项均已删除)。取而代之,**后台题目列表**(`src/admin/problem/list.vue`)多一列「反馈」,只显示该题票数最高的那一个表情图标,tooltip 出「标签名 + 人数」;无人评价则留空,并列第一时按 `ReactionType` 的定义顺序取靠前的一个。数据来自 `GET /api/admin/problem` 新增的 `top_reaction: {type, count} | null` 字段,由 `reaction/services.py:get_top_reactions()` 对当页题目做一次聚合查询算出(比赛题目列表不返回该字段,前端也不显示该列)。本文「管理接口」「后台页面」两节仅为早期设计记录,占比排序、七列计数表、按题号筛选均已不存在。 + 日期:2026-08-06 涉及仓库:`OnlineJudge`(后端)、`ojnext`(前端) diff --git a/oj/urls.py b/oj/urls.py index 2a244a7..96b6aea 100644 --- a/oj/urls.py +++ b/oj/urls.py @@ -16,7 +16,6 @@ urlpatterns = [ path("api/admin/", include("utils.urls")), path("api/", include("message.urls.oj")), path("api/", include("reaction.urls.oj")), - path("api/admin/", include("reaction.urls.admin")), path("api/", include("tutorial.urls.tutorial")), path("api/admin/", include("tutorial.urls.admin")), path("api/", include("ai.urls.oj")), diff --git a/problem/views/admin.py b/problem/views/admin.py index dd2cd08..1e56409 100644 --- a/problem/views/admin.py +++ b/problem/views/admin.py @@ -14,6 +14,7 @@ from django.http import StreamingHttpResponse from account.decorators import ensure_created_by, problem_permission_required, teacher_admin_required from contest.models import Contest, ContestStatus from judge.sql_runner import SQLCaseError, build_display +from reaction.services import get_top_reactions from submission.models import Submission from utils.api import APIError, APIView, CSRFExemptAPIView, validate_serializer from utils.openai import get_ai_client @@ -283,7 +284,12 @@ class ProblemAPI(ProblemBase): if not user.can_mgmt_all_problem(): problems = problems.filter(created_by=user) - return self.success(self.paginate_data(request, problems, ProblemAdminListSerializer)) + + data = self.paginate_data(request, problems, ProblemAdminListSerializer) + top_reactions = get_top_reactions([row["id"] for row in data["results"]]) + for row in data["results"]: + row["top_reaction"] = top_reactions.get(row["id"]) + return self.success(data) @problem_permission_required @validate_serializer(EditProblemSerializer) diff --git a/reaction/services.py b/reaction/services.py new file mode 100644 index 0000000..f2e1a47 --- /dev/null +++ b/reaction/services.py @@ -0,0 +1,19 @@ +from django.db.models import Count + +from reaction.models import Reaction, ReactionType + +# 并列票数时按 ReactionType 的定义顺序取靠前的那个,跟前端 REACTIONS 的顺序一致 +TYPE_ORDER = {t.value: index for index, t in enumerate(ReactionType)} + + +def get_top_reactions(problem_ids): + """返回 {problem_id: {"type": ..., "count": ...}},只包含有评价的题目。""" + if not problem_ids: + return {} + top = {} + rows = Reaction.objects.filter(problem_id__in=problem_ids).values("problem_id", "type").annotate(count=Count("id")) + for row in rows: + current = top.get(row["problem_id"]) + if current is None or (-row["count"], TYPE_ORDER[row["type"]]) < (-current["count"], TYPE_ORDER[current["type"]]): + top[row["problem_id"]] = {"type": row["type"], "count": row["count"]} + return top diff --git a/reaction/urls/admin.py b/reaction/urls/admin.py deleted file mode 100644 index 9bf6fb8..0000000 --- a/reaction/urls/admin.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.urls import path - -from ..views.admin import ReactionStatsAPI - -urlpatterns = [ - path("reaction", ReactionStatsAPI.as_view()), -] diff --git a/reaction/views/admin.py b/reaction/views/admin.py deleted file mode 100644 index ac90513..0000000 --- a/reaction/views/admin.py +++ /dev/null @@ -1,25 +0,0 @@ -from django.db.models import Count, F, Max, Q - -from account.decorators import super_admin_required -from reaction.models import Reaction, ReactionType -from utils.api import APIView - - -class ReactionStatsAPI(APIView): - @super_admin_required - def get(self, request): - queryset = ( - Reaction.objects.values(pid=F("problem___id"), title=F("problem__title")) - .annotate(users=Count("user", distinct=True)) - .annotate(last_time=Max("create_time")) - .annotate(**{t.value: Count("id", filter=Q(type=t.value)) for t in ReactionType}) - .order_by("-users") - ) - - problem_id = request.GET.get("problem") - if problem_id: - queryset = queryset.filter(problem___id__iexact=problem_id, problem__contest_id__isnull=True) - - data = self.paginate_data(request, queryset) - data["results"] = list(data["results"]) - return self.success(data)