- 根目录加入 docker-compose、Caddyfile、文档等原本不在仓库里的文件 - 前后端两份 GitHub Actions 合并成根目录的一个 deploy workflow: 构建前端上传到 build/,再在服务器上 git pull 并重建容器 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
475 lines
14 KiB
Markdown
475 lines
14 KiB
Markdown
# 等待 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"
|
||
```
|