Files
web/docs/superpowers/plans/2026-06-11-random-rating-popup.md
xuyueandClaude Opus 5.5 47d4679984 chore: 合并为单仓库,统一部署流程
- 根目录加入 docker-compose、Caddyfile、文档等原本不在仓库里的文件
- 前后端两份 GitHub Actions 合并成根目录的一个 deploy workflow:
  构建前端上传到 build/,再在服务器上 git pull 并重建容器

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:02:12 +08:00

475 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 等待 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="<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: 运行测试,确认失败**
```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
<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**
```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: 在模板中挂载组件**
在 `<TaskStatsModal v-model:show="showStats" :task-id="taskId" />` 之后添加:
```html
<RandomRatingModal v-if="authed" />
```
- [ ] **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"
```