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

14 KiB
Raw Permalink Blame History

等待 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 末尾追加 RandomRatingOut schema
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

在浏览器中:

  1. 以普通学生账号登录,打开任意挑战页面
  2. 切换到「AI 对话」标签,发送一条消息
  3. 观察:AI 开始流式生成时("AI 正在生成中…"),应弹出居中弹窗,显示其他同学的作品预览 + 星级评分
  4. 点击星级打分,确认出现"感谢评分!"提示,并自动加载下一个作品(弹窗保持打开)
  5. 点击弹窗右上角关闭,确认弹窗消失;若 AI 仍在生成,约 30 秒后应再次弹出新作品
  6. 等待 AI 回复完成,确认弹窗自动关闭
  • Step 4: Commit
git add client/src/pages/ChallengeDetail.vue
git commit -m "Show random rating popup while waiting for AI response"