chore: 合并为单仓库,统一部署流程

- 根目录加入 docker-compose、Caddyfile、文档等原本不在仓库里的文件
- 前后端两份 GitHub Actions 合并成根目录的一个 deploy workflow:
  构建前端上传到 build/,再在服务器上 git pull 并重建容器

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 16:02:12 +08:00
1 parent 70983a12a5
commit 47d4679984
40 files changed
+13723 -34

No files matched your search

@@ -0,0 +1,156 @@
# 等待 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]` — 排除指定提交(避免连续两次抽到同一作品)
**筛选逻辑**(按顺序短路):
1. 普通用户(`role == RoleChoices.NORMAL`)若今日 `Rating` 记录数已达 30(与 `Rating.clean()` 的上限一致)→ 直接返回 `null`,避免弹窗后打分必定失败
2. 候选集:`Submission.objects.exclude(user=request.user).exclude(ratings__user=request.user)`
- 若提供 `exclude_id`,再 `.exclude(pk=exclude_id)`
3. 优先从候选集中 `zone=ZoneChoices.PENDING`(评分人数 < 5)的子集里 `order_by("?")` 取一条
4. 若 pending 子集为空,则从候选集整体 `order_by("?")` 取一条
5. 若候选集本身为空 → 返回空
不区分 `task_type`(tutorial / challenge 均可能出现),不排除"当前正在做的这个挑战"的其他提交。
**响应 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
```
响应类型为 `Optional[RandomRatingOut]`;无候选时返回 `null`(HTTP 200),前端据此判断不弹窗。
打分仍复用现有 `PUT /api/submission/{submission_id}/score`,不新增打分接口。
## 前端设计
新建组件 `client/src/components/ai/RandomRatingModal.vue`,挂载在 `ChallengeDetail.vue` 模板内(与现有 `<n-modal>` 并列)。
**内部状态**:
- `show: boolean`
- `current: RandomRatingItem | null`
- `reshowTimer: number | null`
**核心方法**:
```ts
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`)新增:
```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` 过滤随机作品来源
- 排除"当前挑战"的其他提交(已决定不排除)
- 跳过按钮(已决定移除,强制打分为唯一主操作)