- 根目录加入 docker-compose、Caddyfile、文档等原本不在仓库里的文件 - 前后端两份 GitHub Actions 合并成根目录的一个 deploy workflow: 构建前端上传到 build/,再在服务器上 git pull 并重建容器 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5.7 KiB
5.7 KiB
等待 AI 回复时随机作品打分弹窗 设计文档
日期: 2026-06-11
背景与目标
当前评分系统数据量偏少,学生积极性不高;同时在 ChallengeDetail 页面等待 AI 流式回复期间,学生处于"无事可做"的等待状态。希望利用这段等待时间,弹出一个随机的其他同学的提交,让学生打分,从而:
- 增加评分数据量,让评分系统更有效(n 越大,
update_score的统计意义越强) - 提升等待期间的参与感
仅在 ChallengeDetail.vue(含 AI 对话面板的页面)生效。
触发逻辑
监听 store/prompt.ts 的 streaming 状态:
streaming: false → true
→ clearTimeout(reshowTimer)
→ fetchAndShow() // 请求随机作品,有结果则弹窗
streaming: true → false
→ clearTimeout(reshowTimer)
→ show = false // 立即关闭弹窗
弹窗内交互:
- 打分(点击星级)→ 调用现有
PUT /submission/{id}/score→ 提示"感谢评分!" → 立即fetchAndShow()换下一个作品,弹窗保持打开 - 关闭(右上角 ✕)→
show = false;若此时streaming === true,30 秒后fetchAndShow()重新弹出一个新作品 - 无可评作品(接口返回空)→ 不弹窗 / 不重弹,等下一次
streaming: false → true再尝试
组件卸载(离开页面)时清理 reshowTimer 并关闭弹窗。
无"跳过"按钮——星级评分是唯一主操作,关闭仅是临时隐藏(30 秒后会带新作品再弹出)。
后端 API 设计
新增端点:GET /api/submission/random-for-rating/(login_required)
查询参数:
exclude_id: Optional[UUID]— 排除指定提交(避免连续两次抽到同一作品)
筛选逻辑(按顺序短路):
- 普通用户(
role == RoleChoices.NORMAL)若今日Rating记录数已达 30(与Rating.clean()的上限一致)→ 直接返回null,避免弹窗后打分必定失败 - 候选集:
Submission.objects.exclude(user=request.user).exclude(ratings__user=request.user)- 若提供
exclude_id,再.exclude(pk=exclude_id)
- 若提供
- 优先从候选集中
zone=ZoneChoices.PENDING(评分人数 < 5)的子集里order_by("?")取一条 - 若 pending 子集为空,则从候选集整体
order_by("?")取一条 - 若候选集本身为空 → 返回空
不区分 task_type(tutorial / challenge 均可能出现),不排除"当前正在做的这个挑战"的其他提交。
响应 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
响应类型为 Optional[RandomRatingOut];无候选时返回 null(HTTP 200),前端据此判断不弹窗。
打分仍复用现有 PUT /api/submission/{submission_id}/score,不新增打分接口。
前端设计
新建组件 client/src/components/ai/RandomRatingModal.vue,挂载在 ChallengeDetail.vue 模板内(与现有 <n-modal> 并列)。
内部状态:
show: booleancurrent: RandomRatingItem | nullreshowTimer: number | null
核心方法:
async function fetchAndShow() {
const res = await Submission.randomForRating(current.value?.submission_id)
if (res) {
current.value = res
show.value = true
} else {
show.value = false
}
}
async function onRate(score: number) {
await Submission.updateScore(current.value!.submission_id, score)
message.success("感谢评分!")
await fetchAndShow()
}
function onClose() {
show.value = false
if (streaming.value) {
reshowTimer = window.setTimeout(fetchAndShow, 30_000)
}
}
watch(streaming, (val) => {
if (reshowTimer) clearTimeout(reshowTimer)
if (val) fetchAndShow()
else show.value = false
})
onUnmounted(() => {
if (reshowTimer) clearTimeout(reshowTimer)
})
弹窗内容(n-modal preset="card",宽度约 480px):
- 标题:「等待 AI 回复时,给同学的作品打个分吧」
- 副标题:
{task_title} · 提交者 {username} - 中间:
<iframe>预览,复用buildPreviewDocument(utils/previewDocument),固定高度约 220px - 底部:居中放大的
n-rate(5星,与Preview.vue同款打分组件) - 底部小字提示:「打分后自动换下一个作品;AI 回复完成后弹窗会自动关闭」
- 右上角 ✕ 关闭按钮
API client(src/api.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
}
边界情况
- 打分接口报错(网络错误)→
message.error,弹窗保持打开、current不变,允许用户重新点击星级重试 - 候选池为空 / 当日评分已达上限 → 不弹窗,不重试,等下一次 AI 请求触发时再判断
- 离开 ChallengeDetail 页面 → 现有
onUnmounted(disconnectPrompt)中一并清理弹窗状态和计时器
测试计划
- 后端:单测覆盖
random-for-rating—— 排除自己的提交、排除已评过的提交、优先返回pending区、exclude_id生效、普通用户当日评分达 30 次后返回空 - 前端:开发环境手动验证 —— 发起 AI 对话触发弹窗、打分后自动换下一个、点击关闭后 30 秒重弹、AI 回复完成后弹窗自动关闭
不在本次范围内
- 管理员开关该功能的配置项
- 按
task_type过滤随机作品来源 - 排除"当前挑战"的其他提交(已决定不排除)
- 跳过按钮(已决定移除,强制打分为唯一主操作)