# 等待 AI 回复时随机作品打分弹窗 Implementation Plan > **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:** 在 ChallengeDetail 页面,学生等待 AI 流式回复期间,弹出一个随机的其他同学的提交供其打分,增加评分数据量并提升等待期间的参与感。 **Architecture:** 新增一个无状态的只读接口 `GET /api/submission/random-for-rating/`,从其他学生的提交中随机选取一个(优先选评分人数 < 5 的),排除自己的提交和已评过的提交,并尊重每日 30 次评分上限。前端新增 `RandomRatingModal.vue`,监听现有的 `streaming` 状态(`store/prompt.ts`),在 AI 流式生成期间弹出该作品的预览 + 星级评分,评完自动换下一个,AI 回复完成立即关闭。 **Tech Stack:** Django + django-ninja(后端 API/Schema)、Vue 3 + TypeScript + Naive UI(前端弹窗/评分组件) 参考设计文档:`docs/superpowers/specs/2026-06-11-random-rating-popup-design.md` --- ## File Structure - Modify `api/submission/schemas.py` — 新增 `RandomRatingOut` 响应 schema - Modify `api/submission/api.py` — 新增 `GET /random-for-rating/` 端点 - Modify `api/submission/tests.py` — 新增端点单测 - Modify `client/src/utils/type.ts` — 新增 `RandomRatingItem` 类型 - Modify `client/src/api.ts` — 新增 `Submission.randomForRating()` 方法 - Create `client/src/components/ai/RandomRatingModal.vue` — 弹窗组件 - Modify `client/src/pages/ChallengeDetail.vue` — 挂载弹窗组件 --- ### Task 1: 后端 — `random-for-rating` 接口 **Files:** - Modify: `api/submission/schemas.py` - Modify: `api/submission/api.py` - Modify: `api/submission/tests.py` - [ ] **Step 1: 在 `api/submission/tests.py` 中添加失败的测试** 在文件顶部的 import 中加入 `Rating`(当前只 import 了 `Award, Submission, SubmissionAward`): ```python from .models import Award, Rating, Submission, SubmissionAward ``` 在文件末尾追加新的测试类: ```python class RandomForRatingApiTest(TestCase): def setUp(self): self.viewer = _make_user("viewer") self.author = _make_user("author") self.task = _make_task() def _submission(self, user, html="
work
"): return Submission.objects.create( user=user, task=self.task, html=html, css="", js="" ) def test_excludes_own_and_already_rated_submissions(self): self._submission(self.viewer) # 自己的提交,应排除 rated = self._submission(self.author) Rating.objects.create(user=self.viewer, submission=rated, score=4) eligible = self._submission(self.author) self.client.force_login(self.viewer) resp = self.client.get("/api/submission/random-for-rating/") self.assertEqual(resp.status_code, 200) data = resp.json() self.assertIsNotNone(data) self.assertEqual(data["submission_id"], str(eligible.id)) self.assertEqual(data["username"], "author") self.assertEqual(data["task_title"], self.task.title) self.assertEqual(data["task_display"], self.task.display) self.assertEqual(data["task_type"], "challenge") def test_prefers_submission_with_fewer_than_five_ratings(self): raters = [_make_user(f"rater{i}") for i in range(5)] many_ratings = self._submission(self.author, html="
many
") for rater in raters: Rating.objects.create(user=rater, submission=many_ratings, score=3) few_ratings = self._submission(self.author, html="
few
") for rater in raters[:2]: Rating.objects.create(user=rater, submission=few_ratings, score=3) self.client.force_login(self.viewer) resp = self.client.get("/api/submission/random-for-rating/") self.assertEqual(resp.status_code, 200) data = resp.json() self.assertEqual(data["submission_id"], str(few_ratings.id)) def test_exclude_id_param_excludes_given_submission(self): only = self._submission(self.author) self.client.force_login(self.viewer) resp = self.client.get( f"/api/submission/random-for-rating/?exclude_id={only.id}" ) self.assertEqual(resp.status_code, 200) self.assertIsNone(resp.json()) def test_normal_user_reaching_daily_cap_returns_none(self): self._submission(self.author, html="
extra
") for i in range(30): target = self._submission(self.author, html=f"
{i}
") Rating.objects.create(user=self.viewer, submission=target, score=3) self.client.force_login(self.viewer) resp = self.client.get("/api/submission/random-for-rating/") self.assertEqual(resp.status_code, 200) self.assertIsNone(resp.json()) ``` - [ ] **Step 2: 运行测试,确认失败** ```bash cd api && uv run manage.py test submission.tests.RandomForRatingApiTest ``` 期望:因为 `/api/submission/random-for-rating/` 路由不存在,请求返回 404,断言失败(`assertEqual(resp.status_code, 200)` 不通过)。 - [ ] **Step 3: 在 `api/submission/schemas.py` 末尾追加 `RandomRatingOut` schema** ```python class RandomRatingOut(Schema): submission_id: UUID username: str task_title: str task_display: int task_type: Literal["tutorial", "challenge"] html: Optional[str] = None css: Optional[str] = None js: Optional[str] = None @staticmethod def resolve_submission_id(obj): return obj.id @staticmethod def resolve_username(obj): return obj.user.username @staticmethod def resolve_task_title(obj): return obj.task.title @staticmethod def resolve_task_display(obj): return obj.task.display @staticmethod def resolve_task_type(obj): return obj.task.task_type ``` - [ ] **Step 4: 在 `api/submission/api.py` 中添加 `timezone` 导入和 schema 导入** 在现有的 `from django.db.models import (...)` 之后添加: ```python from django.utils import timezone ``` 在 `from .schemas import (...)` 列表中添加 `RandomRatingOut`(按字母顺序插入即可,例如放在 `RatingScoreIn,` 之后): ```python RatingScoreIn, RandomRatingOut, ``` - [ ] **Step 5: 在 `api/submission/api.py` 中添加新端点** 放在 `list_by_user_task` 函数之后(即第 288 行之后,`@router.delete("/flags")` 之前): ```python @router.get("/random-for-rating/", response=Optional[RandomRatingOut]) @login_required def get_random_for_rating(request, exclude_id: Optional[UUID] = None): """ 随机返回一个待打分的其他同学的提交(用于AI生成期间的随手打分弹窗) """ if request.user.role == RoleChoices.NORMAL: today_start = timezone.now().replace( hour=0, minute=0, second=0, microsecond=0 ) today_end = today_start + timezone.timedelta(days=1) rating_count = Rating.objects.filter( user=request.user, created__range=(today_start, today_end) ).count() if rating_count >= 30: return None candidates = ( Submission.objects.select_related("task", "user") .exclude(user=request.user) .exclude(ratings__user=request.user) ) if exclude_id: candidates = candidates.exclude(pk=exclude_id) pending = candidates.annotate(rating_count=Count("ratings")).filter( rating_count__lt=5 ) return pending.order_by("?").first() or candidates.order_by("?").first() ``` 注意:这里用 `Count("ratings")` 实时统计评分数,而不是依赖 `Submission.zone` 字段——因为 `zone` 只在某条提交收到评分(触发 `update_score`)后才会被设为 `pending`,全新的、从未被评过分的提交 `zone` 仍是 `None`,若依赖 `zone` 字段会把这些最需要被评分的提交漏掉。 - [ ] **Step 6: 运行测试,确认通过** ```bash cd api && uv run manage.py test submission.tests.RandomForRatingApiTest ``` 期望:4 个测试全部 `OK`。 - [ ] **Step 7: 运行完整 submission 测试套件,确认没有破坏现有测试** ```bash cd api && uv run manage.py test submission ``` 期望:全部 `OK`。 - [ ] **Step 8: Commit** ```bash git add api/submission/schemas.py api/submission/api.py api/submission/tests.py git commit -m "Add random-for-rating endpoint for AI-wait rating popup" ``` --- ### Task 2: 前端 — 类型与 API 客户端 **Files:** - Modify: `client/src/utils/type.ts` - Modify: `client/src/api.ts` - [ ] **Step 1: 在 `client/src/utils/type.ts` 末尾追加 `RandomRatingItem` 类型** ```ts export interface RandomRatingItem { submission_id: string username: string task_title: string task_display: number task_type: TASK_TYPE html: string | null css: string | null js: string | null } ``` - [ ] **Step 2: 在 `client/src/api.ts` 中导入新类型** 在 `import type { ... } from "./utils/type"` 列表中加入 `RandomRatingItem`(例如放在 `PromptRound,` 之后): ```ts PromptRound, RandomRatingItem, ``` - [ ] **Step 3: 在 `client/src/api.ts` 的 `Submission` 对象中添加 `randomForRating` 方法** 放在 `updateScore` 方法之后: ```ts async randomForRating(excludeId?: string) { const res = await http.get("/submission/random-for-rating/", { params: excludeId ? { exclude_id: excludeId } : {}, }) return res.data as RandomRatingItem | null }, ``` - [ ] **Step 4: Commit** ```bash git add client/src/utils/type.ts client/src/api.ts git commit -m "Add randomForRating API client method and type" ``` (本项目未配置独立的类型检查命令,TS 错误会在 Task 4 启动开发服务器时于终端输出中暴露。) --- ### Task 3: 前端 — `RandomRatingModal.vue` 组件 **Files:** - Create: `client/src/components/ai/RandomRatingModal.vue` - [ ] **Step 1: 创建组件文件** ```vue ``` - [ ] **Step 2: Commit** ```bash git add client/src/components/ai/RandomRatingModal.vue git commit -m "Add RandomRatingModal component" ``` --- ### Task 4: 前端 — 在 ChallengeDetail 中挂载弹窗 **Files:** - Modify: `client/src/pages/ChallengeDetail.vue` - [ ] **Step 1: 导入组件** 在现有 import 列表中加入(放在 `PromptHistoryPanel` 导入之后): ```ts import RandomRatingModal from "../components/ai/RandomRatingModal.vue" ``` - [ ] **Step 2: 在模板中挂载组件** 在 `` 之后添加: ```html ``` - [ ] **Step 3: 启动开发服务器并手动验证** ```bash ./dev.sh ``` 在浏览器中: 1. 以普通学生账号登录,打开任意挑战页面 2. 切换到「AI 对话」标签,发送一条消息 3. 观察:AI 开始流式生成时("AI 正在生成中…"),应弹出居中弹窗,显示其他同学的作品预览 + 星级评分 4. 点击星级打分,确认出现"感谢评分!"提示,并自动加载下一个作品(弹窗保持打开) 5. 点击弹窗右上角关闭,确认弹窗消失;若 AI 仍在生成,约 30 秒后应再次弹出新作品 6. 等待 AI 回复完成,确认弹窗自动关闭 - [ ] **Step 4: Commit** ```bash git add client/src/pages/ChallengeDetail.vue git commit -m "Show random rating popup while waiting for AI response" ```