- 根目录加入 docker-compose、Caddyfile、文档等原本不在仓库里的文件 - 前后端两份 GitHub Actions 合并成根目录的一个 deploy workflow: 构建前端上传到 build/,再在服务器上 git pull 并重建容器 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14 KiB
等待 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):
from .models import Award, Rating, Submission, SubmissionAward
在文件末尾追加新的测试类:
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="<div>work</div>"):
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="<div>many</div>")
for rater in raters:
Rating.objects.create(user=rater, submission=many_ratings, score=3)
few_ratings = self._submission(self.author, html="<div>few</div>")
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="<div>extra</div>")
for i in range(30):
target = self._submission(self.author, html=f"<div>{i}</div>")
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: 运行测试,确认失败
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末尾追加RandomRatingOutschema
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 (...) 之后添加:
from django.utils import timezone
在 from .schemas import (...) 列表中添加 RandomRatingOut(按字母顺序插入即可,例如放在 RatingScoreIn, 之后):
RatingScoreIn,
RandomRatingOut,
- Step 5: 在
api/submission/api.py中添加新端点
放在 list_by_user_task 函数之后(即第 288 行之后,@router.delete("/flags") 之前):
@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: 运行测试,确认通过
cd api && uv run manage.py test submission.tests.RandomForRatingApiTest
期望:4 个测试全部 OK。
- Step 7: 运行完整 submission 测试套件,确认没有破坏现有测试
cd api && uv run manage.py test submission
期望:全部 OK。
- Step 8: Commit
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类型
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, 之后):
PromptRound,
RandomRatingItem,
- Step 3: 在
client/src/api.ts的Submission对象中添加randomForRating方法
放在 updateScore 方法之后:
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
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: 创建组件文件
<template>
<n-modal
preset="card"
:show="show"
title="等待 AI 回复时,给同学的作品打个分吧"
style="width: 480px"
@update:show="onUpdateShow"
>
<n-text
v-if="current"
depth="3"
style="font-size: 12px; display: block; margin-bottom: 8px"
>
{{ current.task_title }} · 提交者 {{ current.username }}
</n-text>
<div class="preview-wrapper">
<iframe class="preview-iframe" :srcdoc="previewContent"></iframe>
</div>
<div class="rate-row">
<n-rate
v-if="current"
:key="current.submission_id"
:size="32"
@update:value="onRate"
/>
</div>
<n-text
depth="3"
style="font-size: 11px; display: block; text-align: center; margin-top: 8px"
>
打分后自动换下一个作品;AI 回复完成后弹窗会自动关闭
</n-text>
</n-modal>
</template>
<script setup lang="ts">
import { ref, watch, onUnmounted } from "vue"
import { useMessage } from "naive-ui"
import { Submission } from "../../api"
import { streaming } from "../../store/prompt"
import { buildPreviewDocument } from "../../utils/previewDocument"
import type { RandomRatingItem } from "../../utils/type"
const message = useMessage()
const show = ref(false)
const current = ref<RandomRatingItem | null>(null)
const previewContent = ref("")
let reshowTimer: ReturnType<typeof setTimeout> | null = null
function clearReshowTimer() {
if (reshowTimer !== null) {
clearTimeout(reshowTimer)
reshowTimer = null
}
}
async function fetchAndShow() {
const item = await Submission.randomForRating(current.value?.submission_id)
if (item) {
current.value = item
previewContent.value = buildPreviewDocument({
html: item.html ?? "",
css: item.css ?? "",
js: item.js ?? "",
})
show.value = true
} else {
show.value = false
}
}
async function onRate(score: number) {
if (!current.value) return
try {
await Submission.updateScore(current.value.submission_id, score)
message.success("感谢评分!")
await fetchAndShow()
} catch (err: any) {
message.error(err.response?.data?.detail ?? "打分失败,请重试")
}
}
function onUpdateShow(value: boolean) {
show.value = value
if (!value) {
clearReshowTimer()
if (streaming.value) {
reshowTimer = setTimeout(fetchAndShow, 30_000)
}
}
}
watch(streaming, (val) => {
clearReshowTimer()
if (val) {
fetchAndShow()
} else {
show.value = false
}
})
onUnmounted(() => {
clearReshowTimer()
})
</script>
<style scoped>
.preview-wrapper {
height: 220px;
border: 1px solid #eee;
border-radius: 4px;
overflow: hidden;
}
.preview-iframe {
width: 100%;
height: 100%;
border: none;
}
.rate-row {
display: flex;
justify-content: center;
padding: 16px 0;
}
</style>
- Step 2: Commit
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 导入之后):
import RandomRatingModal from "../components/ai/RandomRatingModal.vue"
- Step 2: 在模板中挂载组件
在 <TaskStatsModal v-model:show="showStats" :task-id="taskId" /> 之后添加:
<RandomRatingModal v-if="authed" />
- Step 3: 启动开发服务器并手动验证
./dev.sh
在浏览器中:
- 以普通学生账号登录,打开任意挑战页面
- 切换到「AI 对话」标签,发送一条消息
- 观察:AI 开始流式生成时("AI 正在生成中…"),应弹出居中弹窗,显示其他同学的作品预览 + 星级评分
- 点击星级打分,确认出现"感谢评分!"提示,并自动加载下一个作品(弹窗保持打开)
- 点击弹窗右上角关闭,确认弹窗消失;若 AI 仍在生成,约 30 秒后应再次弹出新作品
- 等待 AI 回复完成,确认弹窗自动关闭
- Step 4: Commit
git add client/src/pages/ChallengeDetail.vue
git commit -m "Show random rating popup while waiting for AI response"