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,255 @@
# Ranking Zones Page — Design Spec
**Date:** 2026-03-16
**Status:** Approved
## Overview
A new standalone page that displays challenge submissions that students have explicitly nominated for ranking. Each nominated submission is placed into one of four score-based zones (Bayesian weighted score). Students choose which of their submissions enters the ranking; each student may nominate at most one submission per challenge.
---
## 1. Scoring: Bayesian Weighted Score (global — replaces existing `score`)
The existing `score` field is replaced with a Bayesian weighted score to prevent extreme scores from small numbers of raters distorting rankings. This affects the Submissions page, Leaderboard, My Scores, and the new Ranking page — all use Bayesian `score`.
### Formula
```
bayesian_score = (C × m + n × raw_score) / (C + n)
```
- `raw_score` = role-weighted average: `(super×0.5 + admin×0.3 + normal×0.2) / n`
- `m` = global mean of `raw_score` across all submissions with at least one rating, excluding the current submission
- `n` = number of raters on this submission
- `C` = 3 (confidence parameter)
When `n = 0`: both `score` and `raw_score` are 0.
### Model changes (`api/submission/models.py`)
Add `raw_score` field and add `Avg` import:
```python
from django.db.models import Avg # add to imports
raw_score = models.FloatField(default=0.0, verbose_name="原始加权分")
```
### `update_score()` replacement
```python
def update_score(self):
ratings = list(self.ratings.select_related("user").all())
n = len(ratings)
if n == 0:
self.raw_score = 0.0
self.score = 0.0
self.save(update_fields=["raw_score", "score"])
return
weighted_sum = sum(
r.score * (0.5 if r.user.role == RoleChoices.SUPER
else 0.3 if r.user.role == RoleChoices.ADMIN
else 0.2)
for r in ratings
)
self.raw_score = weighted_sum / n
C = 3
# Exclude self to avoid stale DB value skewing the mean
global_mean = (
Submission.objects.filter(raw_score__gt=0)
.exclude(pk=self.pk)
.aggregate(Avg("raw_score"))["raw_score__avg"]
) or self.raw_score # fallback if this is the only ever-rated submission
self.score = (C * global_mean + n * self.raw_score) / (C + n)
self.save(update_fields=["raw_score", "score"])
```
**Downstream note:** `Profile.recalculate_total_score()` (called after every rating via signal) uses best-score-per-challenge. After this change it will use Bayesian `score` values — leaderboard totals will reflect Bayesian scores. This is the intended global effect.
Migration required: adds `raw_score` column (default `0.0`, no backfill needed for existing rows).
---
## 2. Nomination: student opts one submission per challenge into ranking
### Model changes (`api/submission/models.py`)
```python
nominated = models.BooleanField(default=False, db_index=True, verbose_name="参与排名")
```
**Constraint:** at most one nominated submission per (user, task) pair — enforced in the API.
### New endpoint (`api/submission/api.py`)
```
PUT /api/submission/{submission_id}/nominate
```
- Login required; only the submission's owner may call it
- Sets `nominated=True` on this submission
- Sets `nominated=False` on all other submissions by the same user for the same task
- Returns `{"nominated": true}`
### Schema changes (`api/submission/schemas.py`)
Add `nominated: bool = False` to `SubmissionOut`:
```python
nominated: bool = False
```
No resolver needed — `nominated` maps directly from the model field.
### Frontend type (`client/src/utils/type.ts`)
Add to `SubmissionOut` interface:
```ts
nominated: boolean
```
### Nomination UI (`client/src/pages/Submissions.vue` and/or submission result)
- Add a "参与排名" button visible only to the submission's owner (`row.userid === currentUser.id`)
- Nominated submission: show a 🏅 indicator on the row
- Clicking calls `Submission.nominate(id)`, then toggles `nominated` on the affected rows in `data.value` (set clicked row to `true`, others with same `task_id` and same user to `false`)
---
## 3. Zones
Only `nominated=True` submissions appear on the Ranking page. Zones by Bayesian `score`:
| Zone | Score Condition | Sort Order |
|------|----------------|------------|
| 🏆 精华 | score ≥ 4.5 | score DESC |
| ⭐ 优秀 | 3.5 ≤ score < 4.5 | score DESC |
| 📝 普通 | 0 < score < 3.5 | score DESC |
| ⏳ 待评 | score = 0 | created DESC |
待评 zone uses `score__lt=0.001` (not exact float equality) to safely capture unrated submissions.
Minimum non-zero Bayesian score is well above 0.001 (single normal-role rater scoring 1 → raw=0.2 → Bayesian ≈ (3×m+0.2)/4).
---
## 4. Ranking Page layout
Split-pane (same as `Submissions.vue`): left 33% list, right 67% preview.
**Left panel:**
- Zone tabs with count badges (fetched on mount, not refreshed after scoring)
- Empty zone: `n-empty` placeholder
- Columns: `#` · `得分` · `提交者` · `任务` · `时间`
- Pagination 10/page; resets to page 1 on zone switch
**Right panel:**
- `Preview` component; `@after-score` updates only `my_score` on the row — zone reassignment waits for full reload (accepted)
---
## 5. Backend filter changes (`api/submission/`)
### `schemas.py` — `SubmissionFilter` additions
```python
score_min: Optional[float] = None
score_max_exclusive: Optional[float] = None
score_lt_threshold: Optional[float] = None # replaces score_exact_zero; use 0.001 for 待评
nominated: Optional[bool] = None
ordering: Optional[str] = None # "-score" | "score" | "-created"
```
### `api.py` — `list_submissions` additions
```python
if filters.nominated is not None:
submissions = submissions.filter(nominated=filters.nominated)
if filters.score_lt_threshold is not None:
submissions = submissions.filter(score__lt=filters.score_lt_threshold)
else:
if filters.score_min is not None:
submissions = submissions.filter(score__gte=filters.score_min)
if filters.score_max_exclusive is not None:
submissions = submissions.filter(score__lt=filters.score_max_exclusive)
if filters.ordering in ("-score", "score", "-created"):
submissions = submissions.order_by(filters.ordering)
```
Existing callers pass none of these params — unaffected.
### Zone → API param mapping
| Zone | Params |
|------|--------|
| 精华 | `task_type=challenge, nominated=true, score_min=4.5, ordering=-score` |
| 优秀 | `task_type=challenge, nominated=true, score_min=3.5, score_max_exclusive=4.5, ordering=-score` |
| 普通 | `task_type=challenge, nominated=true, score_min=0.001, score_max_exclusive=3.5, ordering=-score` |
| 待评 | `task_type=challenge, nominated=true, score_lt_threshold=0.001, ordering=-created` |
---
## 6. Frontend API changes (`client/src/api.ts`)
Updated `Submission.list` signature (adds new params, preserves existing ones):
```ts
async list(query: {
page: number
username?: string
flag?: string | null
task_type?: string
score_min?: number
score_max_exclusive?: number
score_lt_threshold?: number
nominated?: boolean
ordering?: string
})
```
New method:
```ts
async nominate(id: string) {
const res = await http.put(`/submission/${id}/nominate`)
return res.data
}
```
---
## 7. New file: `client/src/pages/Ranking.vue`
- Auth-guarded (`meta: { auth: true }`)
- Zone tab + per-zone page state; default zone: 精华
- 4 parallel count-badge calls on mount
- Selected submission → Preview
## 8. Router & nav
```ts
// router.ts
{ path: '/ranking', component: () => import('./pages/Ranking.vue'), meta: { auth: true } }
```
Add "排名" link in existing nav.
---
## 9. Database migration
Adds `raw_score` (FloatField, default=0.0) and `nominated` (BooleanField, default=False) to `submission_submission`.
---
## Out of Scope
- Per-challenge filtering on Ranking page
- Withdrawing from ranking without re-nominating
- Refreshing zone counts after inline scoring
- Tutorial submissions (fixed `task_type=challenge` on all Ranking page requests)
@@ -0,0 +1,153 @@
# 登录框重构:班级+姓名选择登录
**日期:** 2026-03-18
**状态:** 已批准
---
## 背景
当前登录框只有用户名+密码两个字段。用户(学生)的用户名格式为 `web{classname}{name}`(如 `web301张三`),输入完整用户名对学生不友好。需要重构为班级→姓名下拉选择方式登录,同时为管理员保留用户名+密码入口。
---
## 需求
- 学生通过"选班级 → 选姓名 → 输密码"方式登录
- 管理员通过原有用户名+密码方式登录
- 两种模式通过 Tab 切换
---
## 后端改动
### 1. User 模型新增字段
```python
# api/account/models.py
classname = models.CharField(max_length=50, blank=True, default="", verbose_name="班级")
```
执行数据库迁移。迁移后,**现有数据库中的所有用户(包括历史批量创建的用户)`classname` 均为空字符串**,因此不会出现在新端点的班级列表中。这是已知且可接受的行为——历史用户需重新通过新版批量创建接口创建,或手动更新 `classname`。
### 2. batch_create 接口保存 classname
在 `User(...)` 构造函数中设置 `classname`,在调用 `set_password()` 之前(`set_password` 内部调用 `self.save()`,必须保证字段在首次持久化时已赋值):
```python
user = User(username=username, classname=payload.classname)
user.set_password(password) # 触发 save(),此时 classname 已在实例上
users_to_create.append(user)
```
> 注:现有代码中 `set_password` 会触发 `save()`,之后的 `bulk_create(..., ignore_conflicts=True)` 对已存在行为空操作,无需改动该逻辑。
### 3. 新增两个无鉴权 GET 端点
> **安全说明:** 两个端点均不需要登录。`/names` 会返回某班级所有学生的用户名,存在账号枚举风险。考虑到平台面向内部学生,此为可接受的设计权衡。
**`GET /api/account/classes`**
返回所有不重复的非空班级名列表,按字典序升序排列。
```json
["301", "302", "高一1班"]
```
实现:
```python
User.objects.filter(classname__gt="").values_list("classname", flat=True).distinct().order_by("classname")
```
**`GET /api/account/names?classname=301`**
`classname` 为必填查询参数(缺省时 django-ninja 返回 422)。
返回该班级所有用户的姓名和用户名,按 `username` 升序排列(等价于按姓名升序,因 classname 前缀相同)。仅返回 `username` 以 `"web" + classname` 开头的用户,防止异常数据干扰。
```json
[
{"name": "张三", "username": "web301张三"},
{"name": "李四", "username": "web301李四"}
]
```
实现:
```python
prefix = "web" + classname
users = User.objects.filter(classname=classname, username__startswith=prefix).order_by("username")
return [{"name": u.username[len(prefix):], "username": u.username} for u in users]
```
如果 `classname` 不存在或该班无用户,返回空列表 `[]`。
---
## 前端改动
### Login.vue 重构
用 `n-tabs` 分为两个 Tab:
**Tab 1 - 学生登录**
| 字段 | 组件 | 说明 |
|------|------|------|
| 班级 | `n-select` | 挂载时调 `/classes` 加载,结果缓存在组件内,切换 Tab 不重新请求 |
| 姓名 | `n-select` | 选完班级后调 `/names?classname=X` 加载;班级未选时 disabled |
| 密码 | `n-input` type=password | 手动输入 |
**`n-select` 数据绑定:**
- 班级下拉:`label = classname, value = classname`
- 姓名下拉:`label = item.name, value = item.username`(即 `selectedUsername` 存储完整 username)
**登录按钮 disabled 条件:**
学生 Tab:班级、姓名、密码三者均已填写才可点击。
**级联状态管理:**
- 切换到其他 Tab 时,**清空**班级、姓名、密码的已选值(重置为初始状态)
- 重新选择班级时,**清空**已选姓名(`selectedUsername = ""`)并重新调 `/names` 加载
**API 错误处理:**
- `/classes` 加载失败:班级下拉显示为空,展示通用错误提示
- `/names` 加载失败:姓名下拉显示为空并保持 disabled,展示通用错误提示
登录时发送 `{ username: selectedUsername, password }` 到现有 `/api/account/login` 端点。
**Tab 2 - 管理员登录**
保持现有用户名+密码表单,无改动。管理员 Tab 登录按钮 disabled 条件:用户名和密码均非空。
### api.ts 新增
```typescript
Account.listClasses(): Promise<string[]>
Account.listNamesByClass(classname: string): Promise<{name: string, username: string}[]>
```
---
## 数据流
```
挂载 Login.vue
→ GET /api/account/classes → 缓存班级列表,填充班级下拉
用户选择班级
→ 清空 selectedUsername
→ GET /api/account/names?classname=X → 填充姓名下拉
(每个 option: label=name, value=username)
用户选姓名 → selectedUsername = item.username(完整 username)
用户输密码
→ POST /api/account/login { username: selectedUsername, password }
→ 登录成功 → 关闭 Modal,更新 user store
```
---
## 不在此次范围内
- 历史用户(非 batch 创建)的 classname 回填(现有用户 classname 为空,不出现在班级列表中)
- `UserListSchema` 和用户管理页不展示 classname 字段(可后续迭代)
- 学生忘记密码流程
@@ -0,0 +1,37 @@
# 提交打开次数统计 设计文档
**日期:** 2026-03-30
## 目标
统计每个 Submission 被打开(查看详情)的次数,供管理员判断哪些作品被查看最多(热度指标)。
## 触发条件
每次调用 `GET /api/submission/{submission_id}` 时计数加一,无论访问者身份。
## 数据模型变更
在 `Submission` 模型新增字段:
```python
view_count = models.PositiveIntegerField(default=0, verbose_name="查看次数")
```
新建对应 migration。
## API 变更
### `GET /api/submission/{submission_id}`
- 在返回响应前,执行原子递增:
```python
Submission.objects.filter(pk=submission_id).update(view_count=F("view_count") + 1)
```
- `SubmissionOut` schema 新增 `view_count: int` 字段,暴露给前端。
## 不在本次范围内
- 去重(同一用户多次访问分别计数)
- 时序数据 / 按时间段查询
- 前端展示(管理员可通过 Django Admin 或 API 查看)
@@ -0,0 +1,88 @@
# 提交统计"人气前五"功能设计
**日期**: 2026-03-31
## 需求
在提交统计弹窗(`TaskStatsModal`)中新增"人气前五"区块,展示当前 task 下 view_count 最高的前 5 条提交记录,跟随班级筛选。
## 后端变更
### 新增 Schema(`api/submission/schemas.py`)
```python
class TopViewedItem(Schema):
username: str
classname: str
view_count: int
submission_id: str # UUID,用于跳转
```
### `TaskStatsOut` 新增字段
```python
top_viewed: list[TopViewedItem]
```
### `get_task_stats` 查询逻辑(`api/submission/api.py`)
在已有的 `submitted_ids` / `student_ids` 过滤基础上,取 view_count 最高的前 5 条提交:
```python
top_viewed_qs = (
Submission.objects
.filter(task=task, user_id__in=student_ids)
.select_related("user")
.order_by("-view_count")[:5]
)
top_viewed = [
TopViewedItem(
username=s.user.username,
classname=s.user.classname,
view_count=s.view_count,
submission_id=str(s.id),
)
for s in top_viewed_qs
]
```
`student_ids` 已经根据 `classname` 参数过滤,因此自动跟随班级筛选。
每个用户可能有多条提交,取 view_count 最高的那条(不按用户去重,直接取整体前 5)。
## 前端变更
### `client/src/utils/type.ts`
新增接口:
```ts
export interface TopViewedItem {
username: string
classname: string
view_count: number
submission_id: string
}
```
`TaskStatsOut` 新增字段:
```ts
top_viewed: TopViewedItem[]
```
### `client/src/components/TaskStatsModal.vue`
在"标记统计"区块下方新增"人气前五"区块:
- 标题:"人气前五",风格与"标记统计"/"提交次数分布"一致
- 每项展示:排名序号(1-5)+ 用户显示名 + view_count(如"128 次")
- 点击可跳转到该提交详情(复用现有 `viewSubmission(id)` 方法)
- 若 `top_viewed` 为空则显示"暂无"
- view_count 为 0 的项仍展示(表示真实数据)
## 不涉及范围
- 不修改 `view_count` 的计算/更新逻辑
- 不新增 API 端点,复用现有 `/submission/stats/{task_id}`
- 不添加分页或更多排名
@@ -0,0 +1,180 @@
# 设计文档:学生删除 AI 对话轮次及关联提交
**日期:** 2026-04-14
**状态:** 已批准
---
## 需求
学生在挑战页的「AI 对话」tab 中,可以删除某一轮对话(一问一答),同时删除该 AI 回答自动生成的提交。反向也成立:在提交列表中删除一条提交,同时删除对应的那对消息。
---
## 数据模型
**`prompt/Message` 新增字段:**
```python
submission = models.OneToOneField(
"submission.Submission",
null=True, blank=True,
on_delete=models.SET_NULL,
related_name="source_message"
)
```
- `OneToOneField`:一条 assistant message 最多对应一个提交
- `SET_NULL`:FK 层面保底,防止外部操作引发 crash
- 业务级联删除在接口层显式处理(不依赖 `CASCADE`)
需新建 migration。
---
## 后端
### Schema 变更
`api/submission/schemas.py` 中 `SubmissionIn` 新增:
```python
message_id: Optional[UUID] = None
```
### 接口变更
**`POST /submission/`(create_submission)**
创建 Submission 后,若 `payload.message_id` 不为空:
1. 查找对应的 assistant Message(`source="conversation"`,属于当前 user+task)
2. 设置 `message.submission = submission` 并保存
**`DELETE /submission/{id}`(delete_submission)**
删除 Submission 前:
1. 通过 `submission.source_message`(`related_name`)找到关联的 assistant message(若有)
2. 找到该 assistant message 在同一 conversation 中前一条 user message(按 `created` 排序取紧邻的上一条)
3. 依次删除:user message → assistant message → submission
**新增 `DELETE /prompt/messages/{message_id}/pair`**
- 鉴权:`message.conversation.user == request.user`,否则 403
- 操作:
1. 找到 assistant message(`id=message_id`,`role="assistant"`)
2. 找到同 conversation 中紧邻的前一条 user message
3. 若 assistant message 有关联 submission,删除它
4. 删除 assistant message 和 user message
- 返回 `{"deleted": true, "submission_deleted": bool}`
> **为何用 assistant message id 而非 user message id:**
> `complete` 事件由后端发出,assistant message 在那一刻刚被保存,id 唯一确定;user message 已在之前保存,前端也持有,但以 assistant message 为锚点更自然。
### WebSocket 变更(`prompt/consumers.py`)
**`complete` 事件新增 `message_id`:**
```python
await self.send(text_data=json.dumps({
"type": "complete",
"code": code,
"message_id": str(assistant_message.id), # 新增
}))
```
**`init` 事件的消息列表每条带 `id`:**
```python
{
"id": str(m.id), # 新增
"role": m.role,
"content": m.content,
"code": {...} if m.role == "assistant" else None,
"created": m.created.isoformat(),
}
```
---
## 前端
### `store/prompt.ts`
`PromptMessage` 接口新增:
```typescript
id?: string // assistant message 的后端 id,用于删除
```
`complete` 事件处理:从事件中提取 `message_id`,存入 push 进 `messages` 的那条 assistant message。
`_onCodeComplete` 回调签名扩展,传入 `messageId`:
```typescript
type OnCodeComplete = (code: { html: string | null; css: string | null; js: string | null }, messageId: string) => void
```
`init` 事件处理:将后端返回的 `id` 存入每条消息。
### `api.ts`
**`Submission.create()`** 新增可选参数:
```typescript
async create(taskId, code, messageId?: string)
// 请求体加上 message_id: messageId ?? null
```
**`Prompt` 新增方法:**
```typescript
async deleteMessagePair(messageId: string): Promise<{ deleted: boolean; submission_deleted: boolean }>
```
### `ChallengeDetail.vue`
`setOnCodeComplete` 的回调改为接收 `messageId`,并将其传给 `Submission.create()`:
```typescript
setOnCodeComplete(async (code, messageId) => {
await Submission.create(taskId.value, { ...code }, messageId)
})
```
### `PromptPanel.vue`
**消息分组:** 将 `messages` 按 user+assistant 顺序两两分组,形成 `pairs`(computed)。不完整的末尾单条消息(仍在等待 AI 回复)不显示删除按钮。
**删除 UI:** 每个 pair 右上角显示一个垃圾桶图标(`lucide:trash-2`),hover 时显现。点击后:
1. 调用 `Prompt.deleteMessagePair(pair.assistantMessage.id)`
2. 成功后从本地 `messages` 中移除这两条
**交互细节:**
- 使用 Naive UI 的 `useMessage()` 给出成功/失败通知
- streaming 期间禁用所有删除按钮(`disabled` when `streaming`)
- 没有 `id` 的消息(历史消息加载前的边缘情况)不显示删除按钮
---
## 不在本次范围内
- 手动提交(ExternalAIPanel)产生的消息:它们以 `source="manual"` 存储,不在 PromptPanel 显示,无需处理
- 提交列表页的实时刷新:删除后消息已从后端移除,下次打开挑战页重连 WebSocket 时自然反映
- 管理员代替学生删除:本次仅限学生删自己的
---
## 文件改动清单
| 文件 | 类型 |
|------|------|
| `api/prompt/models.py` | 新增 `submission` 字段 |
| `api/prompt/migrations/0005_*.py` | 新建 migration |
| `api/prompt/api.py` | 新增 `DELETE /messages/{id}/pair` 端点 |
| `api/prompt/consumers.py` | `complete`/`init` 事件带 `message_id` |
| `api/submission/schemas.py` | `SubmissionIn` 加 `message_id` |
| `api/submission/api.py` | create 关联消息;delete 级联删消息 |
| `client/src/store/prompt.ts` | `PromptMessage` 加 `id`;回调传 `messageId` |
| `client/src/api.ts` | `Submission.create` 加 `messageId`;`Prompt.deleteMessagePair` |
| `client/src/pages/ChallengeDetail.vue` | `setOnCodeComplete` 传 `messageId` |
| `client/src/components/ai/PromptPanel.vue` | 消息分组 + 删除按钮 |
@@ -0,0 +1,316 @@
# 作品广场功能设计
**日期**: 2026-04-29
**状态**: 待审阅
## 背景
WebPreview 目前已有挑战、提交、评分、分区、人气统计和 AI 对话生成代码能力。现有机制偏向提交管理和评分结果展示,还缺少一个面向学生的优秀作品展示入口。作品广场用于把老师选出的优秀提交集中展示,让学生通过最终效果和创作提示词链学习同伴作品。
## 目标
- 学生登录后可以进入全站统一的作品广场,浏览获奖作品效果。
- 老师可以在后台维护奖项,并把提交作品授予一个或多个奖项。
- 每个奖项可以配置自己的作品排序方式。
- 作品广场只展示运行效果,不开放代码查看。
- 如果获奖作品有关联 AI 提示词链,所有登录学生都可以查看创作过程。
## 不在本期范围
- 学生提名、投票、评论、收藏。
- 对外公开展示。
- 作品代码公开。
- 复杂拖拽排序界面。初版用后台排序字段实现手动排序。
- 为奖项做单独的前端管理页。初版通过 Django 后台维护奖项和授奖关系。
## 核心规则
### 访问权限
- `/showcase` 需要登录。
- 普通学生只能浏览作品广场。
- 管理员和超级管理员可以通过 Django 后台维护奖项和授奖关系。
### 奖项
奖项由后台配置,不写死在前端。每个奖项包含:
- 名称,例如“最佳游戏”“最佳设计”。
- 简介,用于解释该专区的评选标准。
- 排序值,用于控制奖项专区在作品广场中的顺序。
- 是否启用。停用后不在前台展示。
- 作品排序方式。
作品排序方式初版支持:
- `manual`: 手动排序,按授奖关系上的排序值升序。
- `awarded_at`: 按授奖时间倒序。
- `score`: 按提交评分倒序。
- `view_count`: 按浏览量倒序。
默认排序方式为 `manual`。
### 授奖
- 一个提交作品可以获得多个奖项。
- 一个奖项可以包含多个提交作品。
- 同一个提交和同一个奖项只能建立一条授奖关系。
- 授奖关系包含手动排序值和授奖时间。
### 作品展示
作品广场按奖项分区展示,全站统一混合所有挑战作品,不按单个挑战拆分。
每个作品卡片展示:
- 作品运行效果。
- 作者用户名。
- 挑战标题。
- 奖项标签。
- 评分。
- 浏览量。
点击作品进入详情页,只展示运行效果和作品信息,不提供查看代码入口。
### 提示词链展示
如果提交作品关联到 `prompt.Message.submission`,作品详情展示“创作过程”区域。
展示内容:
- 每一轮学生提示词。
- 提示词来源:对话或手动。
- L1-L6 提示词层级,如果已分类。
- 每一轮 assistant 消息提取出的网页效果。
不展示内容:
- 系统提示词。
- 模型配置。
- API 信息。
- 代码文本。
创作过程默认折叠,学生点击后展开。
## 数据模型
### Award
建议放在 `submission/models.py`,因为奖项直接服务于提交作品展示。
字段:
- `name`: 奖项名称,唯一。
- `description`: 奖项简介,可为空。
- `sort_order`: 奖项排序值,默认 0。
- `is_active`: 是否启用,默认 true。
- `item_ordering`: 作品排序方式,默认 `manual`。
- `created` / `modified`: 复用 `TimeStampedModel`。
### SubmissionAward
授奖关系表。
字段:
- `submission`: 外键到 `Submission`。
- `award`: 外键到 `Award`。
- `sort_order`: 手动排序值,默认 0。
- `awarded_at`: 授奖时间。
- `created` / `modified`: 复用 `TimeStampedModel`。
约束:
- `(submission, award)` 唯一。
查询优化:
- `award`, `submission`, `sort_order`, `awarded_at` 建索引或通过外键索引满足主要查询。
- API 查询时 `select_related("submission", "submission__user", "submission__task", "award")`。
## 后端接口
### 作品广场列表
```
GET /api/submission/showcase/
```
登录后可访问。
返回启用奖项及其作品列表。无获奖作品的奖项默认不返回,避免前台出现空专区。
响应结构建议:
```json
[
{
"id": 1,
"name": "最佳设计",
"description": "视觉完成度高、布局清晰的作品",
"item_ordering": "manual",
"items": [
{
"submission_id": "uuid",
"username": "student01",
"task_title": "挑战标题",
"task_display": 3,
"score": 4.6,
"view_count": 28,
"html": "...",
"css": "...",
"js": "...",
"has_prompt_chain": true
}
]
}
]
```
### 作品详情
```
GET /api/submission/showcase/{submission_id}/
```
登录后可访问。仅允许访问已授奖的提交作品。
返回:
- 提交作品运行所需的 `html/css/js`。
- 作者、挑战、评分、浏览量。
- 当前作品获得的奖项列表。
- 是否存在提示词链。
### 作品提示词链
```
GET /api/submission/showcase/{submission_id}/prompt-chain/
```
登录后可访问。仅允许访问已授奖且有关联提示词链的提交作品。
后端通过 `Submission.source_message` 找到关联 assistant 消息,再回到同一 `Conversation`,按时间顺序构造用户提示词和 assistant 生成效果的轮次。
返回结构可复用 `ChainModal.vue` 当前使用的数据概念,但需要针对作品广场隐藏代码文本,只返回提示词内容、层级、来源和可预览的生成结果。
如果提交没有关联提示词链,作品详情接口返回 `has_prompt_chain: false`,该接口返回 404。
## 后台管理
在 Django admin 注册:
- `Award`
- `SubmissionAward`
`SubmissionAward` 后台字段:
- submission
- award
- sort_order
- awarded_at
列表页显示:
- 奖项名称
- 提交作者
- 挑战标题
- 评分
- 浏览量
- 手动排序值
- 授奖时间
筛选:
- award
- submission.task
- submission.user.classname
搜索:
- award.name
- submission.user.username
- submission.task.title
## 前端页面
### 路由
新增:
```
/showcase
```
路由 `meta.auth = true`。
### 入口
在主页/任务面板中新增“作品广场”入口,学生登录后可见。
### 作品广场页面
布局:
- 顶部标题和简短说明。
- 奖项专区纵向排列。
- 每个专区展示奖项名称、简介和作品网格。
- 每个作品卡片内嵌预览效果,显示作者、挑战、分数、浏览量。
空状态:
- 如果没有任何获奖作品,显示“暂无展示作品”。
### 作品详情页
新增或复用现有提交详情页,但需要隐藏代码查看能力。
内容:
- 作品运行效果。
- 作者、挑战、奖项、评分、浏览量。
- “创作过程”折叠区域。
创作过程:
- 有提示词链才显示入口。
- 默认折叠。
- 展开后显示提示词轮次和每轮生成效果。
## 安全与隐私
- 作品广场需要登录。
- 仅展示被授奖的提交。
- 不展示代码文本。
- 提示词链公开范围限定为登录用户。
- 提示词链只取用户消息和 assistant 生成效果,不展示系统提示词或接口配置。
## 测试计划
后端:
- 普通未登录用户访问作品广场接口返回 401/403。
- 登录用户可以查看已授奖作品。
- 未授奖提交不能通过详情接口访问。
- 一个提交可以获得多个奖项。
- 同一提交同一奖项不能重复授奖。
- 奖项停用后不在广场接口返回。
- 不同排序方式返回顺序正确。
- 有 `source_message` 的提交可以返回提示词链。
- 无 `source_message` 的提交在详情中返回 `has_prompt_chain: false`,请求提示词链接口返回 404。
前端:
- 未登录访问 `/showcase` 触发登录逻辑。
- 登录后能看到作品广场入口。
- 作品卡片只展示效果和元信息,不出现查看代码入口。
- 详情页能展示作品效果。
- 有提示词链时显示创作过程入口,无提示词链时隐藏。
## 后续扩展
- 学生点赞、收藏、评论。
- 奖项专题封面图。
- 老师在提交列表中直接授奖。
- 作品广场按班级、挑战、作者筛选。
- “本周精选”或“最近上榜”专区。
@@ -0,0 +1,262 @@
# 平时成绩册设计
**日期**: 2026-05-01
**状态**: 待用户复核
## 背景
期末需要在管理后台查看单个班级学生的平时成绩。现有系统已有学生班级字段、教程/挑战任务、提交记录、提交评分和单题统计弹窗,但缺少跨教程和挑战的班级成绩册。
本功能在管理后台新增“平时成绩”页面,面向管理员按班级查看、排序、分级和导出学生平时成绩。
## 目标
- 管理员可以选择一个班级查看该班平时成绩。
- 教程和挑战都计入成绩,不只统计挑战。
- 同一学生同一任务按最高分提交计算。
- 任务是否计入成绩由该班提交覆盖率决定,不依赖 `Task.is_public`。
- 页面显示排名、等级、每个任务得分、教程合计、挑战合计、总分、平均分、已提交任务数和未提交任务数。
- 支持导出当前班级成绩 CSV。
## 不在本期范围
- 不提供“全部班级”视图。
- 不做教程和挑战权重配置。
- 不做人工调整等级。
- 不新增持久化成绩快照,成绩册按当前提交和评分实时计算。
- 不修改现有 `Submission.score` 或 `Rating` 计算逻辑。
## 成绩计算规则
### 学生范围
成绩册必须选择一个班级。学生范围为:
- `User.role = normal`
- `User.classname = classname`
如果未传班级,接口返回 `400`,提示“请选择班级”。
### 任务范围
教程和挑战都可以纳入成绩。页面提供任务类型筛选:
- 全部
- 教程
- 挑战
任务纳入成绩的默认规则:
- 只看当前班级学生。
- 某任务只要该班至少 50% 学生有过提交,就计入成绩。
- 覆盖率门槛人数为 `ceil(班级学生数 * 0.5)`。
- 不使用 `Task.is_public` 判断是否计入,因为它只表示公开状态。
页面提供“显示所有有提交任务”的开关:
- 默认关闭,只显示计入成绩的任务。
- 开启后显示当前班级有提交记录的所有任务,用于检查任务是否未达到覆盖率门槛。
- 开启后每个任务仍返回 `included` 标记;汇总字段只计算 `included = true` 的任务。
### 单元格分数
每个学生每个任务的单元格分数为:
- 该学生在该任务所有提交中最高的 `Submission.score`。
- 如果没有提交,显示缺交,汇总时按 `0` 计入。
如果多个提交同分,提交详情链接指向最近创建的一条最高分提交。
### 汇总字段
每个学生行包含:
- `tutorial_total`: 教程最高分合计。
- `challenge_total`: 挑战最高分合计。
- `total_score`: 教程合计 + 挑战合计。
- `average_score`: `total_score / included_task_count`,没有计入任务时为 `null`。
- `submitted_task_count`: 有提交的计入任务数。
- `missing_task_count`: 没有提交的计入任务数。
## 等级规则
等级只在当前班级内计算。按 `total_score` 降序排名,同分按 `username` 升序稳定排序。
设班级人数为 `N`,排名从 1 开始:
- `A`: `rank <= ceil(0.30 * N)`
- `B`: `rank <= ceil(0.70 * N)`
- `C`: `rank <= ceil(0.90 * N)`
- `D`: `rank <= ceil(0.95 * N)`
- `E`: 其余
返回字段包含:
- `rank`
- `grade`
## 后端设计
### API
新增管理员接口:
```http
GET /api/submission/gradebook/
```
参数:
- `classname`: 必填,班级名。
- `task_type`: 可选,`tutorial` / `challenge`。
- `username`: 可选,按用户名片段搜索学生。
- `include_all_tasks`: 可选布尔值,默认 `false`。
权限:
- 仅 `admin` 和 `super` 可以访问。
### 返回结构
```ts
interface GradebookTask {
id: number
display: number
title: string
task_type: "tutorial" | "challenge"
submitted_count: number
coverage: number
included: boolean
}
interface GradebookCell {
score: number
submitted: boolean
submission_id: string | null
}
interface GradebookRow {
user_id: number
username: string
classname: string
rank: number
grade: "A" | "B" | "C" | "D" | "E"
scores: Record<number, GradebookCell>
tutorial_total: number
challenge_total: number
total_score: number
average_score: number | null
submitted_task_count: number
missing_task_count: number
}
interface GradebookOut {
classname: string
classes: string[]
task_count: number
included_task_count: number
student_count: number
coverage_threshold_count: number
tasks: GradebookTask[]
rows: GradebookRow[]
}
```
### 查询策略
1. 查询班级列表,用于前端选择器。
2. 查询当前班级普通学生,得到 `student_ids` 和 `student_count`。
3. 查询这些学生所有提交涉及的任务,按 `task_type` 过滤。
4. 对每个任务统计有提交的学生数,判断是否达到 50% 覆盖率。
5. 根据 `include_all_tasks` 决定返回任务集合。
6. 查询 `(user_id, task_id)` 的最高分提交。
7. 组装每个学生的任务分数和汇总字段。
8. 按 `total_score desc, username asc` 排名并分配等级。
## 前端设计
### 路由和入口
在管理后台 `/dashboard` 下新增页面:
```text
/dashboard/gradebook
```
侧边栏或管理入口新增“平时成绩”。
### 页面布局
顶部筛选区:
- 班级选择,默认选择第一个班级,不提供“全部班级”。
- 任务类型:全部 / 教程 / 挑战。
- 学生搜索。
- “显示所有有提交任务”开关,默认关闭。
- 导出 CSV 按钮。
主体表格:
- 左侧固定列:排名、等级、学生、班级。
- 中间动态列:每个任务一列,显示最高分。
- 右侧固定汇总列:教程合计、挑战合计、总分、平均分、已提交、缺交。
显示规则:
- 缺交单元格在页面显示 `缺交`,使用轻微警示样式;CSV 中按 `0` 导出。
- 未计入成绩但被显示的任务列,用较弱样式并在表头标注未计入。
- 点击有提交的分数,打开该最高分提交详情页。
## 导出设计
新增导出接口:
```http
GET /api/submission/gradebook/export/
```
参数与成绩册接口一致。
CSV 列:
- 排名
- 等级
- 用户名
- 班级
- 每个任务得分
- 教程合计
- 挑战合计
- 总分
- 平均分
- 已提交任务数
- 未提交任务数
导出内容与当前页面筛选保持一致。
## 错误处理
- 未登录:沿用现有登录拦截。
- 非管理员访问:返回 `403`。
- 未选择班级:返回 `400`。
- 班级不存在或没有学生:返回空表格,并保留班级选择列表。
- 当前班级没有达到覆盖率门槛的任务:表格仍显示学生和汇总列,任务列为空,汇总为 0 或 `null`。
## 测试计划
后端测试:
- 任务覆盖率达到 50% 时被计入,低于 50% 时不计入。
- 教程和挑战都能参与计算。
- 同一学生同一任务取最高分。
- 缺交任务按 0 计入,并增加缺交数。
- A/B/C/D/E 按累计 `ceil` 边界分配。
- `classname` 缺失返回 `400`。
- 普通用户访问返回 `403`。
前端检查:
- 页面默认选择第一个班级并加载成绩。
- 任务类型筛选正确刷新表格。
- “显示所有有提交任务”能显示未计入任务。
- 点击分数能打开对应提交详情。
- CSV 导出与当前筛选一致。
@@ -0,0 +1,229 @@
# 作品广场管理页设计
## 背景
作品广场当前已有学生浏览页 `/showcase` 和详情页 `/showcase/:id`。奖项与获奖作品的数据模型已经存在:
- `Award`:奖项名称、简介、启用状态、奖项排序、作品排序方式。
- `SubmissionAward`:提交作品与奖项的关联、手动排序值、授奖时间。
目前日常管理只能通过 Django Admin 完成,不适合老师在前端工作流中维护作品广场。
## 目标
在现有 Dashboard 中新增“作品广场”管理页,让管理员可以完成作品广场的日常维护:
- 创建、编辑、启停、删除奖项。
- 配置奖项在广场中的顺序。
- 配置奖项内作品排序方式。
- 通过复制来的 `submission_id` 查找提交作品,并把作品加入指定奖项。
- 查看、移除、调整奖项内作品。
## 非目标
- 不做公开代码查看。学生侧仍只展示运行效果和创作过程。
- 不做拖拽排序,第一版使用数字排序值。
- 不做批量导入或批量授奖。
- 不改变现有学生广场接口返回结构,避免影响 `/showcase`。
## 权限
管理页只对 `admin` 和 `super` 可见。
后端管理 API 使用 `admin_required` 限制。普通学生访问管理 API 返回 403,未登录用户返回登录态错误。
学生浏览页保持现状:登录用户可访问 `/showcase` 和 `/showcase/:id`。
## 前端入口
在 `Dashboard.vue` 侧栏新增菜单:
- 标签:`作品广场`
- 路由:`/dashboard/showcase`
- 可见角色:`admin`、`super`
新增页面文件:
- `client/src/pages/ShowcaseManage.vue`
## 页面结构
页面采用左右分栏,延续现有 Dashboard 的工具型界面风格。
左侧为奖项列表:
- 新建奖项按钮。
- 奖项名称。
- 启用状态。
- 展示排序值。
- 作品排序方式。
- 当前作品数量。
右侧为选中奖项详情:
- 奖项编辑表单:名称、简介、排序值、是否启用、作品排序方式。
- 保存按钮。
- 删除奖项按钮。
- 已授奖作品表格:作者、任务、分数、浏览量、手动排序值、授奖时间。
- 每行支持修改手动排序值和移除作品。
- 添加作品按钮:打开添加作品弹窗,粘贴 `submission_id` 后查找对应提交,确认无误后加入当前奖项。
当没有选中奖项时,右侧显示空状态。创建第一个奖项后自动选中。
## 后端 API
新增管理接口,放在 `submission/api.py`,路径位于现有 `/submission/showcase/` 下。
### 奖项管理
`GET /api/submission/showcase/manage/awards`
返回所有奖项,包括未启用和空奖项。每个奖项包含:
- `id`
- `name`
- `description`
- `sort_order`
- `is_active`
- `item_ordering`
- `item_count`
`POST /api/submission/showcase/manage/awards`
创建奖项。请求字段:
- `name`
- `description`
- `sort_order`
- `is_active`
- `item_ordering`
`PUT /api/submission/showcase/manage/awards/{award_id}`
更新奖项。字段同创建接口。
`DELETE /api/submission/showcase/manage/awards/{award_id}`
删除奖项。级联删除该奖项下的 `SubmissionAward` 关联,不删除提交作品本身。
### 作品查找
`GET /api/submission/showcase/manage/submissions/{submission_id}`
按提交 ID 查找一条提交,用于添加作品前确认。该接口只返回管理页需要的摘要,不返回 HTML/CSS/JS 源码。返回字段:
- `submission_id`
- `username`
- `task_title`
- `task_display`
- `score`
- `view_count`
- `has_prompt_chain`
提交不存在时返回 404。普通用户不能访问该接口。
### 奖项作品管理
`GET /api/submission/showcase/manage/awards/{award_id}/items`
返回该奖项下所有获奖作品。每项包含:
- `id`:`SubmissionAward.id`
- `submission_id`
- `username`
- `task_title`
- `task_display`
- `score`
- `view_count`
- `sort_order`
- `awarded_at`
- `has_prompt_chain`
排序规则沿用奖项的 `item_ordering`:
- `manual`:按 `SubmissionAward.sort_order`。
- `awarded_at`:按授奖时间倒序。
- `score`:按提交评分倒序。
- `view_count`:按浏览量倒序。
`POST /api/submission/showcase/manage/awards/{award_id}/items`
把提交作品加入奖项。请求字段:
- `submission_id`
- `sort_order`
如果同一作品已在该奖项中,返回现有关联,不重复创建。
`PUT /api/submission/showcase/manage/items/{item_id}`
更新获奖作品关联,目前只更新 `sort_order`。
`DELETE /api/submission/showcase/manage/items/{item_id}`
移除获奖作品关联,不删除提交作品。
## 前端 API 客户端
在 `client/src/api.ts` 中扩展 `Showcase`:
- `listManageAwards()`
- `createAward(payload)`
- `updateAward(id, payload)`
- `deleteAward(id)`
- `listAwardItems(id)`
- `findSubmissionForAward(submissionId)`
- `addAwardItem(id, payload)`
- `updateAwardItem(itemId, payload)`
- `deleteAwardItem(itemId)`
在 `client/src/utils/type.ts` 中新增管理页使用的类型:
- `AwardManageOut`
- `AwardManageIn`
- `AwardItemManageOut`
- `AwardItemIn`
- `AwardItemUpdateIn`
## 数据流
1. 进入 `/dashboard/showcase` 后加载奖项列表。
2. 自动选中第一个奖项,并加载该奖项作品。
3. 保存奖项后刷新奖项列表,并保留当前选中项。
4. 点击“添加作品”后打开弹窗,粘贴 `submission_id` 并查找提交。
5. 找到提交后展示提交者、任务、分数、浏览量和提示词状态。
6. 确认添加后刷新当前奖项作品,同时刷新左侧作品数量。
7. 修改作品手动排序值后刷新当前奖项作品。
## 错误处理
- 奖项名称重复时后端返回 400,前端显示错误消息。
- 奖项、作品关联或待添加提交不存在时返回 404,前端显示错误消息。
- 普通用户访问管理 API 返回 403。
- 网络或未知错误沿用现有 Axios 拦截器,同时管理页局部停止 loading 状态。
## 测试
后端测试覆盖:
- 普通用户不能访问作品广场管理接口。
- 管理员可以创建、编辑、启停奖项。
- 管理员可以通过 `submission_id` 查找提交作品摘要。
- 管理员可以给提交作品授奖。
- 重复授奖不会创建重复关联。
- 移除授奖后,学生广场列表不再返回该作品。
- 奖项停用后,学生广场列表不返回该奖项。
前端验证覆盖:
- Dashboard 中管理员能看到“作品广场”菜单,普通学生看不到。
- 奖项列表、奖项编辑、作品列表能正常加载。
- 粘贴 `submission_id` 查找作品并加入奖项后,已授奖作品列表更新。
- 删除或停用奖项后,学生 `/showcase` 表现符合预期。
## 后续增强
- 拖拽排序。
- 批量授奖。
- 按班级、挑战、作者筛选广场。
- 在提交列表中提供“加入作品广场”快捷入口。
@@ -0,0 +1,129 @@
# 提示词引导功能设计文档
**日期:** 2026-05-07
**状态:** 已审批
## 功能概述
在现有 AI 对话(PromptPanel)旁新增"调教提示词"入口,学生可选择性地进入多轮引导流程,由 AI 以苏格拉底式提问帮助其优化提示词,满意后再生成代码。原有"发送"行为完全不变。
## 用户流程
```
学生在 PromptPanel 输入框写提示词
↓
点"调教提示词"按钮(灯泡图标)
↓
GuidancePanel 弹出,携带初始提示词
↓
AI 分析提示词质量
├─ 不够好 → 返回 1-2 个启发性问题,学生修改后继续发
└─ 够好 → 响应首部加 [READY],"生成代码"按钮高亮
↓
学生点"生成代码"
↓
GuidancePanel 关闭,最终提示词直接通过 sendPrompt() 发送
PromptPanel 走原有 ws/prompt/<task_id>/ 生成代码(流程不变)
```
**备注:** 学生对自己的提示词有信心时,可直接点"发送",完全跳过引导流程。
## 后端设计
### llm.py
新增 `GUIDANCE_SYSTEM_PROMPT`:
```python
GUIDANCE_SYSTEM_PROMPT = """你是一个提示词写作教练,帮助学生写出清晰、具体的网页需求描述。
判断标准——满足以下条件视为"够好":
- 有明确主题(例如:登录页、计时器、商品卡片列表)
- 至少包含以下一项具体描述:颜色/布局/风格、交互行为(按钮点击、动画等)、页面内容(文字、数量、图标)
判断为"不够好"的典型情况:
- 目标太泛,如"做一个好看的页面"
- 只有主题,完全没有任何视觉、交互或内容描述
规则:
1. 如果提示词不够好,用 1-2 个启发性问题引导学生补充细节,不要直接给出答案
2. 如果提示词已经够好,以 [READY] 开头回复,简短夸奖学生并说明可以生成了
3. 用中文回复,语气鼓励,简洁明了
4. 不要生成任何代码"""
```
新增 `stream_guidance(history)` 函数(使用 `GUIDANCE_SYSTEM_PROMPT`,默认 DeepSeek 模型)。
### consumers.py
新增 `GuidanceConsumer(AsyncWebsocketConsumer)`:
- 复用 `PromptConsumer` 的连接/断开/历史加载逻辑
- `save_message` 时 `source="guidance"`
- streaming 完成后检测 `full_response` 是否以 `[READY]` 开头:
- 是:剥离 `[READY]\n`,保存清理后内容,`complete` 事件加 `"is_ready": true`
- 否:正常保存,`complete` 事件 `"is_ready": false`
### url.py / asgi.py
新增 WebSocket 路由:
```python
re_path(r"ws/guidance/(?P<task_id>\d+)/$", GuidanceConsumer.as_asgi())
```
### 数据库
无新 migration。引导消息使用现有 `Message` 模型,`source="guidance"`(现有字段,现有值为 `"conversation"` / `"manual"`)。同一个 `Conversation` 下,通过 `source` 区分引导轮次与代码生成轮次。
## 前端设计
### 新增:src/store/guidance.ts
状态:
- `messages` — 引导对话消息列表
- `streaming` — 是否正在流式输出
- `streamingContent` — 当前流式内容
- `connected` — WebSocket 是否已连接
- `isReady` — AI 是否判断提示词已足够好
- `isOpen` — 面板是否展开
方法:
- `openGuidance(taskId, initialPrompt)` — 建立 WebSocket,发送初始提示词
- `sendGuidance(text)` — 发送后续消息
- `stopGuidance()` — 中止当前流
- `closeGuidance()` — 关闭面板,断开 WebSocket
### 新增:src/components/ai/GuidancePanel.vue
UI 形式:`<n-modal>` 或右侧抽屉。
结构:
- **对话区**:显示引导轮次消息
- 引导消息样式:蓝色左边框 + 灯泡图标,与普通代码生成消息视觉区分
- streaming 指示器(与 PromptPanel 一致)
- **输入区**:学生修改提示词并继续对话,Enter 发送
- **底部按钮**:"生成代码"
- 默认:可点击但不高亮
- `isReady === true`:高亮(primary 样式)+ 提示文字"提示词已优化,可以生成了!"
事件:
- `@generate(finalPrompt: string)` — 学生点"生成代码"时 emit,携带输入框当前内容作为最终提示词
### 改动:src/components/ai/PromptPanel.vue
- 输入框旁加"调教提示词"按钮(`lucide:lightbulb` 图标,secondary 样式)
- 点击:调用 `openGuidance(taskId, input.value)`
- 监听 `GuidancePanel` 的 `@generate` 事件:
- 收到最终提示词 → 填入 `input.value` → 调用 `sendPrompt()` 直接发送
## 错误处理
- GuidancePanel WebSocket 断开:显示重连提示,与 PromptPanel 保持一致
- AI 服务出错:`error` 事件在引导对话中显示,不影响主对话
## 不在范围内
- 引导功能的任务级开关(全局开启)
- 引导质量的统计/分析面板(可后续迭代)
- 引导轮次上限(由 AI 自行判断,不做硬性限制)
@@ -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` 过滤随机作品来源
- 排除"当前挑战"的其他提交(已决定不排除)
- 跳过按钮(已决定移除,强制打分为唯一主操作)