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:
1 parent
70983a12a5
commit
47d4679984
40 files changed
+13723
-34
No files matched your search
@@ -0,0 +1,761 @@
|
||||
# Ranking Zones Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a zone-based ranking page where students nominate their best submission per challenge, scored by a Bayesian weighted formula, displayed in four quality tiers.
|
||||
|
||||
**Architecture:** Three sequential layers — (1) backend model + Bayesian scoring, (2) nomination API + schema updates, (3) new Ranking frontend page. Each layer is independently testable before moving on.
|
||||
|
||||
**Tech Stack:** Django + django-ninja (backend), Vue 3 + Naive UI + TypeScript (frontend), SQLite/PostgreSQL, Axios
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Backend — Model, Scoring, Migration
|
||||
|
||||
### Task 1: Add `raw_score` and `nominated` fields to Submission model
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/submission/models.py`
|
||||
|
||||
- [ ] **Step 1: Add imports and new fields**
|
||||
|
||||
Open `api/submission/models.py`. At the top, add `Avg` to the django.db.models import:
|
||||
|
||||
```python
|
||||
from django.db.models import Avg
|
||||
```
|
||||
|
||||
Add two new fields inside the `Submission` class, after the `flag` field:
|
||||
|
||||
```python
|
||||
raw_score = models.FloatField(default=0.0, verbose_name="原始加权分")
|
||||
nominated = models.BooleanField(default=False, db_index=True, verbose_name="参与排名")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace `update_score()` entirely**
|
||||
|
||||
Replace the existing `update_score` method with:
|
||||
|
||||
```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
|
||||
global_mean = (
|
||||
Submission.objects.filter(raw_score__gt=0)
|
||||
.exclude(pk=self.pk)
|
||||
.aggregate(Avg("raw_score"))["raw_score__avg"]
|
||||
) or self.raw_score
|
||||
|
||||
self.score = (C * global_mean + n * self.raw_score) / (C + n)
|
||||
self.save(update_fields=["raw_score", "score"])
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the file looks correct**
|
||||
|
||||
```bash
|
||||
cd api && uv run python -c "from submission.models import Submission; print('OK')"
|
||||
```
|
||||
|
||||
Expected: `OK` (no import errors)
|
||||
|
||||
- [ ] **Step 4: Generate migration**
|
||||
|
||||
```bash
|
||||
cd api && uv run manage.py makemigrations submission --name "add_raw_score_nominated"
|
||||
```
|
||||
|
||||
Expected: `Migrations for 'submission': api/submission/migrations/0006_add_raw_score_nominated.py`
|
||||
|
||||
- [ ] **Step 5: Apply migration**
|
||||
|
||||
```bash
|
||||
uv run manage.py migrate
|
||||
```
|
||||
|
||||
Expected: `Applying submission.0006_add_raw_score_nominated... OK`
|
||||
|
||||
- [ ] **Step 6: Smoke-test Bayesian scoring in Django shell**
|
||||
|
||||
```bash
|
||||
uv run manage.py shell
|
||||
```
|
||||
|
||||
```python
|
||||
from submission.models import Submission, Rating
|
||||
# Pick any submission that has at least one rating
|
||||
s = Submission.objects.filter(score__gt=0).first()
|
||||
if s:
|
||||
old_score = s.score
|
||||
s.update_score()
|
||||
print(f"old={old_score:.4f} new={s.score:.4f} raw={s.raw_score:.4f}")
|
||||
else:
|
||||
print("No rated submissions yet — will test after first rating")
|
||||
```
|
||||
|
||||
Expected: prints three float values without errors; `score` ≈ bayesian-shifted version of `raw_score`.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
cd api
|
||||
git add submission/models.py submission/migrations/0006_add_raw_score_nominated.py
|
||||
git commit -m "feat: add raw_score/nominated fields and Bayesian update_score"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update schemas — `SubmissionOut` and `SubmissionFilter`
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/submission/schemas.py`
|
||||
|
||||
- [ ] **Step 1: Add `nominated` to `SubmissionOut`**
|
||||
|
||||
In `SubmissionOut`, add after `flag`:
|
||||
|
||||
```python
|
||||
nominated: bool = False
|
||||
```
|
||||
|
||||
No resolver is needed — the field maps directly from the model.
|
||||
|
||||
Also add `"nominated": submission.nominated` to the static `get()` method dict (around line 69), after `"flag"`:
|
||||
|
||||
```python
|
||||
"flag": submission.flag,
|
||||
"nominated": submission.nominated, # ← add this line
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add new filter fields to `SubmissionFilter`**
|
||||
|
||||
Replace the existing `SubmissionFilter` class with:
|
||||
|
||||
```python
|
||||
class SubmissionFilter(Schema):
|
||||
task_id: Optional[int] = None
|
||||
task_type: Optional[Literal["tutorial", "challenge"]] = None
|
||||
username: Optional[str] = None
|
||||
flag: Optional[Literal["red", "blue", "green", "yellow"]] = None
|
||||
score_min: Optional[float] = None
|
||||
score_max_exclusive: Optional[float] = None
|
||||
score_lt_threshold: Optional[float] = None
|
||||
nominated: Optional[bool] = None
|
||||
ordering: Optional[str] = None
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify schema imports**
|
||||
|
||||
Check the top of `schemas.py` — `Optional` and `Literal` are already imported. No new imports needed.
|
||||
|
||||
- [ ] **Step 4: Quick check**
|
||||
|
||||
```bash
|
||||
uv run manage.py check
|
||||
```
|
||||
|
||||
Expected: `System check identified no issues (0 silenced).`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add submission/schemas.py
|
||||
git commit -m "feat: add nominated to SubmissionOut, add score/ordering filters to SubmissionFilter"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add filter logic to `list_submissions` + new `nominate` endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/submission/api.py`
|
||||
|
||||
- [ ] **Step 1: Apply new filters in `list_submissions`**
|
||||
|
||||
In `list_submissions`, after the existing `if filters.flag:` block and before `user_rating_subquery`, add:
|
||||
|
||||
```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)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the `nominate` endpoint**
|
||||
|
||||
Append to `api/submission/api.py`:
|
||||
|
||||
```python
|
||||
@router.put("/{submission_id}/nominate")
|
||||
@login_required
|
||||
def nominate_submission(request, submission_id: UUID):
|
||||
"""
|
||||
学生将某条提交标记为"参与排名"。
|
||||
同一用户同一题目只能有一条参与排名,旧的自动取消。
|
||||
"""
|
||||
submission = get_object_or_404(Submission, id=submission_id)
|
||||
|
||||
if submission.user != request.user:
|
||||
raise HttpError(403, "只能提名自己的提交")
|
||||
|
||||
# Clear any existing nomination for same user + same task
|
||||
Submission.objects.filter(
|
||||
user=request.user,
|
||||
task=submission.task,
|
||||
nominated=True,
|
||||
).exclude(pk=submission.pk).update(nominated=False)
|
||||
|
||||
submission.nominated = True
|
||||
submission.save(update_fields=["nominated"])
|
||||
|
||||
return {"nominated": True}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Check the server starts without errors**
|
||||
|
||||
```bash
|
||||
uv run manage.py check
|
||||
```
|
||||
|
||||
Expected: `System check identified no issues (0 silenced).`
|
||||
|
||||
- [ ] **Step 4: Manual API test (optional — requires dev server)**
|
||||
|
||||
Start the server: `uv run manage.py runserver`
|
||||
|
||||
Test the nominate endpoint with curl (replace `<uuid>` and cookie):
|
||||
```bash
|
||||
curl -X PUT http://localhost:8000/api/submission/<uuid>/nominate \
|
||||
-H "Cookie: sessionid=<your-session>"
|
||||
```
|
||||
Expected: `{"nominated": true}`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add submission/api.py
|
||||
git commit -m "feat: add score/nominated filters to list endpoint and PUT nominate endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Frontend — Types and API Client
|
||||
|
||||
### Task 4: Update TypeScript types
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/utils/type.ts`
|
||||
|
||||
- [ ] **Step 1: Add `nominated` to `SubmissionOut` interface**
|
||||
|
||||
Find the `SubmissionOut` interface (around line 59) and add `nominated`:
|
||||
|
||||
```ts
|
||||
export interface SubmissionOut {
|
||||
id: string
|
||||
userid: number
|
||||
username: string
|
||||
task_display: number
|
||||
task_type: TASK_TYPE
|
||||
task_title: string
|
||||
score: number
|
||||
my_score: number
|
||||
conversation_id?: string
|
||||
flag?: FlagType
|
||||
nominated: boolean // ← add this
|
||||
created: Date
|
||||
modified: Date
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
cd client
|
||||
git add src/utils/type.ts
|
||||
git commit -m "feat: add nominated field to SubmissionOut interface"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update `api.ts` — `Submission.list` signature and `nominate` method
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/api.ts`
|
||||
|
||||
- [ ] **Step 1: Expand `Submission.list` signature**
|
||||
|
||||
Find the existing `list` method in the `Submission` object:
|
||||
|
||||
```ts
|
||||
async list(query: { page: number }) {
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```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
|
||||
}) {
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `nominate` method**
|
||||
|
||||
After `updateFlag`, add:
|
||||
|
||||
```ts
|
||||
async nominate(id: string) {
|
||||
const res = await http.put(`/submission/${id}/nominate`)
|
||||
return res.data as { nominated: boolean }
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
npm run build 2>&1 | grep -E "error|Error" | head -20
|
||||
```
|
||||
|
||||
Expected: no TypeScript errors related to `Submission.list` or `SubmissionOut`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/api.ts
|
||||
git commit -m "feat: expand Submission.list params and add Submission.nominate"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Frontend — Nomination UI + Ranking Page
|
||||
|
||||
### Task 6: Add nomination button to Submissions.vue
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/pages/Submissions.vue`
|
||||
|
||||
- [ ] **Step 1: Update the user store import in `Submissions.vue`**
|
||||
|
||||
The file currently imports only `{ roleAdmin, roleSuper }` from `"../store/user"` (line 145). Add `user` to that import:
|
||||
|
||||
```ts
|
||||
import { roleAdmin, roleSuper, user } from "../store/user"
|
||||
```
|
||||
|
||||
(`user` has a `username` property which is used to identify the current user's rows, since the user store has no `id` field.)
|
||||
|
||||
- [ ] **Step 2: Add nomination column to `columns` array**
|
||||
|
||||
In the `columns` array (around line 224), add a new column after the `flag` column:
|
||||
|
||||
```ts
|
||||
{
|
||||
title: "排名",
|
||||
key: "nominated",
|
||||
width: 60,
|
||||
render: (row) => {
|
||||
// Only show nomination button for the current user's own submissions
|
||||
if (row.username !== user.username) {
|
||||
return row.nominated
|
||||
? h("span", { style: { color: "#f0a020" } }, "🏅")
|
||||
: null
|
||||
}
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
title: row.nominated ? "已参与排名(点击可重新提名)" : "参与排名",
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
handleNominate(row)
|
||||
},
|
||||
},
|
||||
() => (row.nominated ? "🏅" : "☆"),
|
||||
)
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `handleNominate` function**
|
||||
|
||||
After the `afterScore` function, add:
|
||||
|
||||
```ts
|
||||
async function handleNominate(row: SubmissionOut) {
|
||||
await Submission.nominate(row.id)
|
||||
// Update local state: clear other nominations for same task by same user, set this one
|
||||
data.value = data.value.map((d) => {
|
||||
if (d.username === user.username && d.task_display === row.task_display) {
|
||||
d.nominated = d.id === row.id
|
||||
}
|
||||
return d
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify the page renders without errors**
|
||||
|
||||
Start the frontend dev server: `npm run start`
|
||||
|
||||
Open Submissions page. Verify:
|
||||
- 排名 column appears
|
||||
- Your own submissions show ☆ / 🏅 button
|
||||
- Other users' submissions show 🏅 if nominated, nothing if not
|
||||
- Clicking ☆ calls the API and changes to 🏅 (and clears 🏅 from others in same task)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/Submissions.vue
|
||||
git commit -m "feat: add nomination column to Submissions page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Create `Ranking.vue`
|
||||
|
||||
**Files:**
|
||||
- Create: `client/src/pages/Ranking.vue`
|
||||
|
||||
- [ ] **Step 1: Create the file**
|
||||
|
||||
Create `client/src/pages/Ranking.vue` with the following content:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<n-split class="container" direction="horizontal" :default-size="0.333" :min="0.2" :max="0.8">
|
||||
<template #1>
|
||||
<n-flex vertical style="height: 100%; padding-right: 10px">
|
||||
<n-flex justify="space-between" align="center">
|
||||
<n-button secondary @click="$router.back()">返回</n-button>
|
||||
<span style="font-weight: bold; font-size: 16px">排名榜</span>
|
||||
<div style="width: 60px" />
|
||||
</n-flex>
|
||||
|
||||
<!-- Zone tabs -->
|
||||
<n-tabs v-model:value="activeZone" type="line" animated @update:value="onZoneChange">
|
||||
<n-tab v-for="zone in ZONES" :key="zone.key" :name="zone.key">
|
||||
<n-flex align="center" :style="{ gap: '4px' }">
|
||||
<span>{{ zone.label }}</span>
|
||||
<n-badge
|
||||
v-if="counts[zone.key] !== undefined"
|
||||
:value="counts[zone.key]"
|
||||
:max="999"
|
||||
:show-zero="true"
|
||||
style="margin-left: 4px"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-tab>
|
||||
</n-tabs>
|
||||
|
||||
<!-- Ranked table -->
|
||||
<n-empty
|
||||
v-if="!loading && data.length === 0"
|
||||
description="该区暂无提交"
|
||||
style="margin: auto"
|
||||
/>
|
||||
<n-data-table
|
||||
v-else
|
||||
striped
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:loading="loading"
|
||||
:row-props="rowProps"
|
||||
:row-class-name="rowClassName"
|
||||
/>
|
||||
|
||||
<n-pagination
|
||||
v-model:page="page"
|
||||
:page-size="PAGE_SIZE"
|
||||
:item-count="counts[activeZone] ?? 0"
|
||||
simple
|
||||
style="align-self: flex-end"
|
||||
/>
|
||||
</n-flex>
|
||||
</template>
|
||||
<template #2>
|
||||
<div style="height: 100%; padding-left: 10px">
|
||||
<Preview
|
||||
v-if="selectedSubmission.id"
|
||||
:html="selectedSubmission.html"
|
||||
:css="selectedSubmission.css"
|
||||
:js="selectedSubmission.js"
|
||||
:submission-id="selectedSubmission.id"
|
||||
@after-score="afterScore"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</n-split>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, watch, h } from "vue"
|
||||
import type { DataTableColumn } from "naive-ui"
|
||||
import { Submission } from "../api"
|
||||
import type { SubmissionOut } from "../utils/type"
|
||||
import { parseTime } from "../utils/helper"
|
||||
import Preview from "../components/Preview.vue"
|
||||
import { submission as submissionStore } from "../store/submission"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
interface Zone {
|
||||
key: string
|
||||
label: string
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
|
||||
const ZONES: Zone[] = [
|
||||
{
|
||||
key: "top",
|
||||
label: "🏆 精华",
|
||||
params: { score_min: 4.5, ordering: "-score" },
|
||||
},
|
||||
{
|
||||
key: "good",
|
||||
label: "⭐ 优秀",
|
||||
params: { score_min: 3.5, score_max_exclusive: 4.5, ordering: "-score" },
|
||||
},
|
||||
{
|
||||
key: "normal",
|
||||
label: "📝 普通",
|
||||
params: { score_min: 0.001, score_max_exclusive: 3.5, ordering: "-score" },
|
||||
},
|
||||
{
|
||||
key: "unrated",
|
||||
label: "⏳ 待评",
|
||||
params: { score_lt_threshold: 0.001, ordering: "-created" },
|
||||
},
|
||||
]
|
||||
|
||||
const activeZone = ref("top")
|
||||
const page = ref(1)
|
||||
const data = ref<SubmissionOut[]>([])
|
||||
const loading = ref(false)
|
||||
const counts = reactive<Record<string, number>>({})
|
||||
|
||||
const selectedSubmission = computed(() => submissionStore.value)
|
||||
|
||||
const columns: DataTableColumn<SubmissionOut>[] = [
|
||||
{
|
||||
title: "#",
|
||||
key: "rank",
|
||||
width: 45,
|
||||
render: (_, index) => (page.value - 1) * PAGE_SIZE + index + 1,
|
||||
},
|
||||
{
|
||||
title: "得分",
|
||||
key: "score",
|
||||
width: 65,
|
||||
render: (row) =>
|
||||
row.score > 0
|
||||
? h("span", { style: { fontWeight: "bold" } }, row.score.toFixed(2))
|
||||
: h("span", { style: { color: "#999" } }, "—"),
|
||||
},
|
||||
{ title: "提交者", key: "username", width: 80, render: (row) => row.username },
|
||||
{ title: "任务", key: "task_title", render: (row) => row.task_title },
|
||||
{
|
||||
title: "时间",
|
||||
key: "created",
|
||||
width: 110,
|
||||
render: (row) => parseTime(row.created, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
]
|
||||
|
||||
function rowProps(row: SubmissionOut) {
|
||||
return {
|
||||
style: { cursor: "pointer" },
|
||||
onClick: () => loadSubmission(row.id),
|
||||
}
|
||||
}
|
||||
|
||||
function rowClassName(row: SubmissionOut) {
|
||||
return submissionStore.value.id === row.id ? "row-active" : ""
|
||||
}
|
||||
|
||||
async function loadSubmission(id: string) {
|
||||
submissionStore.value = await Submission.get(id)
|
||||
}
|
||||
|
||||
function afterScore() {
|
||||
data.value = data.value.map((d) => {
|
||||
if (d.id === submissionStore.value.id) {
|
||||
d.my_score = submissionStore.value.my_score
|
||||
}
|
||||
return d
|
||||
})
|
||||
}
|
||||
|
||||
function currentZone(): Zone {
|
||||
return ZONES.find((z) => z.key === activeZone.value)!
|
||||
}
|
||||
|
||||
async function fetchPage() {
|
||||
loading.value = true
|
||||
try {
|
||||
const zone = currentZone()
|
||||
const res = await Submission.list({
|
||||
page: page.value,
|
||||
task_type: "challenge",
|
||||
nominated: true,
|
||||
...zone.params,
|
||||
} as Parameters<typeof Submission.list>[0])
|
||||
data.value = res.items
|
||||
counts[zone.key] = res.count
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllCounts() {
|
||||
await Promise.all(
|
||||
ZONES.map(async (zone) => {
|
||||
const res = await Submission.list({
|
||||
page: 1,
|
||||
task_type: "challenge",
|
||||
nominated: true,
|
||||
...zone.params,
|
||||
} as Parameters<typeof Submission.list>[0])
|
||||
counts[zone.key] = res.count
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function onZoneChange() {
|
||||
page.value = 1
|
||||
fetchPage()
|
||||
}
|
||||
|
||||
watch(page, fetchPage)
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchAllCounts()
|
||||
await fetchPage()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
height: calc(100% - 43px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.row-active td) {
|
||||
background-color: rgba(24, 160, 80, 0.1) !important;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify no TypeScript errors**
|
||||
|
||||
```bash
|
||||
npm run build 2>&1 | grep -E "error TS|Error" | head -20
|
||||
```
|
||||
|
||||
Expected: no TypeScript errors in `Ranking.vue`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/Ranking.vue
|
||||
git commit -m "feat: add Ranking page with four-zone display"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Register route and add nav entry
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/router.ts`
|
||||
- Modify: `client/src/components/Toolbar.vue`
|
||||
|
||||
- [ ] **Step 1: Add route to `router.ts`**
|
||||
|
||||
After the `leaderboard` route entry, add:
|
||||
|
||||
```ts
|
||||
{
|
||||
path: "/ranking",
|
||||
name: "ranking",
|
||||
component: () => import("./pages/Ranking.vue"),
|
||||
meta: { auth: true },
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add "排名" menu item to `Toolbar.vue`**
|
||||
|
||||
In the `menu` computed array, after the `"我的提交"` entry, add:
|
||||
|
||||
```ts
|
||||
{
|
||||
label: "排名榜",
|
||||
key: "ranking",
|
||||
icon: () =>
|
||||
h(Icon, {
|
||||
icon: "streamline-emojis:trophy",
|
||||
}),
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Handle the new menu key in `clickMenu`**
|
||||
|
||||
In the `clickMenu` switch, after the `submissions` case, add:
|
||||
|
||||
```ts
|
||||
case "ranking":
|
||||
router.push({ name: "ranking" })
|
||||
break
|
||||
```
|
||||
|
||||
- [ ] **Step 4: End-to-end verification**
|
||||
|
||||
Start both servers (`uv run manage.py runserver` + `npm run start`).
|
||||
|
||||
Checklist:
|
||||
- [ ] Log in, open user menu → "排名榜" appears
|
||||
- [ ] Clicking "排名榜" navigates to `/ranking`
|
||||
- [ ] All four zone tabs render with count badges
|
||||
- [ ] 待评 tab shows submissions with score = 0
|
||||
- [ ] Click a row → right panel shows the preview
|
||||
- [ ] On Submissions page, own submissions show ☆ / 🏅 in "排名" column
|
||||
- [ ] Clicking ☆ calls nominate, row shows 🏅, previous nomination clears
|
||||
- [ ] After nominating, go to Ranking page → submission appears in correct zone
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/router.ts src/components/Toolbar.vue
|
||||
git commit -m "feat: register /ranking route and add nav entry"
|
||||
```
|
||||
@@ -0,0 +1,344 @@
|
||||
# Load Chat History on Challenge Entry — 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:** When a user opens a challenge's AI chat tab, their previous conversation messages load immediately via HTTP API before the WebSocket connects.
|
||||
|
||||
**Architecture:** Call `Prompt.listConversations(taskId)` + `Prompt.getMessages(id)` in `ChallengeHome.vue` alongside `connectPrompt()`. The HTTP response populates the store immediately; the WebSocket `init` handler skips overwriting messages if the conversation ID already matches (preventing stale overwrite without breaking "新对话"). A cancellation token prevents in-flight HTTP responses from writing to the store after the user navigates away.
|
||||
|
||||
**Tech Stack:** Vue 3, TypeScript, Naive UI (`n-spin`), existing `Prompt` API client
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `client/src/utils/type.ts` | Add `created?: string` to `PromptMessage` interface |
|
||||
| `client/src/store/prompt.ts` | Add `historyLoading` ref, cancellation token, `loadHistory()` function; update `init` handler and `disconnectPrompt` |
|
||||
| `client/src/pages/ChallengeHome.vue` | Call `loadHistory()` alongside `connectPrompt()` in `loadChallenge()` |
|
||||
| `client/src/components/PromptPanel.vue` | Import `historyLoading`; show `n-spin` while loading |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `created` to `PromptMessage` in `utils/type.ts`
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/utils/type.ts`
|
||||
|
||||
The `PromptMessage` interface represents the raw HTTP response shape from
|
||||
`GET /prompt/conversations/{id}/messages/`. The backend already returns a `created` field
|
||||
but the frontend type is missing it, which would cause a TypeScript error when mapping
|
||||
the HTTP response in `loadHistory`.
|
||||
|
||||
- [ ] **Step 1: Add `created` field**
|
||||
|
||||
In `client/src/utils/type.ts`, change:
|
||||
|
||||
```typescript
|
||||
export interface PromptMessage {
|
||||
id: number
|
||||
role: string
|
||||
content: string
|
||||
code_html: string | null
|
||||
code_css: string | null
|
||||
code_js: string | null
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```typescript
|
||||
export interface PromptMessage {
|
||||
id: number
|
||||
role: string
|
||||
content: string
|
||||
code_html: string | null
|
||||
code_css: string | null
|
||||
code_js: string | null
|
||||
created?: string
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd /home/xuyue/Projects/webpreview/client && npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no new errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/xuyue/Projects/webpreview/client
|
||||
git add src/utils/type.ts
|
||||
git commit -m "fix: add created field to PromptMessage type"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add `historyLoading` and `loadHistory` to `prompt.ts`
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/store/prompt.ts`
|
||||
|
||||
Key design decisions:
|
||||
- Use a `_historyLoadId` counter as a cancellation token: `disconnectPrompt` increments it,
|
||||
and `loadHistory` checks it before writing to the store — preventing in-flight responses
|
||||
from corrupting state after the user navigates away.
|
||||
- Filter conversations by `conv.username === user.username` so admin/super users only load
|
||||
their own chat history (the API returns all users' conversations for non-normal roles).
|
||||
- No new local type needed: `Prompt.getMessages` already returns `PromptMessage[]`
|
||||
(from `utils/type.ts`) which now includes `created`.
|
||||
|
||||
- [ ] **Step 1: Add import for `user` store and `PromptMessage` type**
|
||||
|
||||
At the top of `client/src/store/prompt.ts`, add to the existing imports:
|
||||
|
||||
```typescript
|
||||
import { Prompt } from "../api"
|
||||
import type { PromptMessage as RawMessage } from "../utils/type"
|
||||
import { user } from "./user"
|
||||
```
|
||||
|
||||
> Note: `RawMessage` is an alias to avoid collision with the store's own `PromptMessage`
|
||||
> interface (which uses the nested `code: {html, css, js}` shape).
|
||||
|
||||
- [ ] **Step 2: Add `historyLoading` export and cancellation token**
|
||||
|
||||
After the existing `export const streaming = ref(false)` line, add:
|
||||
|
||||
```typescript
|
||||
export const historyLoading = ref(false)
|
||||
let _historyLoadId = 0
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `loadHistory` function**
|
||||
|
||||
Add this function after `disconnectPrompt`:
|
||||
|
||||
```typescript
|
||||
export async function loadHistory(taskId: number) {
|
||||
const loadId = ++_historyLoadId
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const convs = await Prompt.listConversations(taskId)
|
||||
if (loadId !== _historyLoadId) return // navigated away, abort
|
||||
const active = convs.find(
|
||||
(c: { is_active: boolean; message_count: number; username: string }) =>
|
||||
c.is_active && c.message_count > 0 && c.username === user.username
|
||||
)
|
||||
if (!active) return
|
||||
const raw: RawMessage[] = await Prompt.getMessages(active.id)
|
||||
if (loadId !== _historyLoadId) return // navigated away, abort
|
||||
conversationId.value = active.id
|
||||
messages.value = raw.map((m) => ({
|
||||
role: m.role as "user" | "assistant",
|
||||
content: m.content,
|
||||
code:
|
||||
m.role === "assistant"
|
||||
? { html: m.code_html, css: m.code_css, js: m.code_js }
|
||||
: undefined,
|
||||
created: m.created,
|
||||
}))
|
||||
} catch {
|
||||
// 静默失败,不影响 WebSocket 正常流程
|
||||
} finally {
|
||||
if (loadId === _historyLoadId) historyLoading.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Fix `disconnectPrompt` to reset `historyLoading` and cancel in-flight loads**
|
||||
|
||||
Find `disconnectPrompt`. Add two lines at the top of the function body:
|
||||
|
||||
```typescript
|
||||
export function disconnectPrompt() {
|
||||
_historyLoadId++ // cancel any in-flight loadHistory
|
||||
historyLoading.value = false
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
messages.value = []
|
||||
conversationId.value = ""
|
||||
connected.value = false
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
_onCodeComplete = null
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Fix the `init` handler guard**
|
||||
|
||||
Find the `if (data.type === "init")` block inside `connectPrompt`. Replace the whole block:
|
||||
|
||||
```typescript
|
||||
if (data.type === "init") {
|
||||
// Skip overwriting messages if HTTP preload already loaded this conversation.
|
||||
// If conversation_id differs (e.g. after "新对话"), always overwrite.
|
||||
const alreadyLoaded = conversationId.value === data.conversation_id
|
||||
conversationId.value = data.conversation_id
|
||||
if (!alreadyLoaded) {
|
||||
messages.value = data.messages || []
|
||||
}
|
||||
// Apply code from last assistant message if exists
|
||||
const lastAssistant = [...messages.value]
|
||||
.reverse()
|
||||
.find((m) => m.role === "assistant" && m.code)
|
||||
if (lastAssistant?.code) {
|
||||
applyCode(lastAssistant.code)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd /home/xuyue/Projects/webpreview/client && npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no errors in `src/store/prompt.ts`
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/xuyue/Projects/webpreview/client
|
||||
git add src/store/prompt.ts
|
||||
git commit -m "feat: add loadHistory for HTTP-based chat history preload"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Call `loadHistory` in `ChallengeHome.vue`
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/pages/ChallengeHome.vue`
|
||||
|
||||
- [ ] **Step 1: Add `loadHistory` to the import**
|
||||
|
||||
In the existing prompt import line, add `loadHistory`:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
connectPrompt,
|
||||
disconnectPrompt,
|
||||
conversationId,
|
||||
streaming,
|
||||
setOnCodeComplete,
|
||||
loadHistory,
|
||||
} from "../store/prompt"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Call `loadHistory` alongside `connectPrompt` in `loadChallenge`**
|
||||
|
||||
In `loadChallenge()`, after `taskId.value = data.task_ptr`, find the line:
|
||||
|
||||
```typescript
|
||||
connectPrompt(data.task_ptr)
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```typescript
|
||||
loadHistory(data.task_ptr) // HTTP preload — async, non-blocking
|
||||
connectPrompt(data.task_ptr) // WebSocket — synchronous open
|
||||
```
|
||||
|
||||
Both fire without `await` so they run concurrently. HTTP is typically faster than
|
||||
WebSocket handshake + backend DB query, so the user usually sees history before
|
||||
the `init` message arrives. The `init` guard handles deduplication if WS wins the race.
|
||||
|
||||
- [ ] **Step 3: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd /home/xuyue/Projects/webpreview/client && npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/xuyue/Projects/webpreview/client
|
||||
git add src/pages/ChallengeHome.vue
|
||||
git commit -m "feat: preload chat history via HTTP on challenge entry"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Show loading indicator in `PromptPanel.vue`
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/components/PromptPanel.vue`
|
||||
|
||||
- [ ] **Step 1: Import `historyLoading`**
|
||||
|
||||
In the script block, add `historyLoading` to the existing import:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
messages,
|
||||
streaming,
|
||||
streamingContent,
|
||||
sendPrompt,
|
||||
newConversation,
|
||||
historyLoading,
|
||||
} from "../store/prompt"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add loading indicator to template**
|
||||
|
||||
Inside `<div class="messages" ref="messagesRef">`, add as first child:
|
||||
|
||||
```html
|
||||
<div class="messages" ref="messagesRef">
|
||||
<div v-if="historyLoading" class="history-loading">
|
||||
<n-spin size="small" />
|
||||
<span>加载历史记录…</span>
|
||||
</div>
|
||||
<div v-for="(msg, i) in messages" :key="i" :class="['message', msg.role]">
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add loading style**
|
||||
|
||||
In `<style scoped>`, add:
|
||||
|
||||
```css
|
||||
.history-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
color: #aaa;
|
||||
font-size: 13px;
|
||||
justify-content: center;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify TypeScript compiles**
|
||||
|
||||
```bash
|
||||
cd /home/xuyue/Projects/webpreview/client && npx tsc --noEmit
|
||||
```
|
||||
|
||||
Expected: no errors
|
||||
|
||||
- [ ] **Step 5: Manual smoke test**
|
||||
1. Start Django: `uv run manage.py runserver` (from `api/`)
|
||||
2. Start dev server: `npm run start` (from `client/`)
|
||||
3. Log in as a user who has previous AI chat on a challenge
|
||||
4. Open that challenge → switch to "AI 对话" tab → previous messages appear (with brief loading spinner)
|
||||
5. Navigate away and back → same result
|
||||
6. Open a challenge with no prior AI chat → empty chat, no spinner
|
||||
7. During a chat, click "新对话" → messages clear correctly
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/xuyue/Projects/webpreview/client
|
||||
git add src/components/PromptPanel.vue
|
||||
git commit -m "feat: show loading indicator while chat history preloads"
|
||||
```
|
||||
@@ -0,0 +1,538 @@
|
||||
# 登录框重构:班级+姓名选择登录 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:** 重构登录框,学生通过选班级→选姓名→输密码登录,管理员通过 Tab 切换使用用户名+密码登录。
|
||||
|
||||
**Architecture:** 后端在 User 模型上新增 `classname` 字段,并提供两个无鉴权端点返回班级列表和按班级的学生列表。前端 Login.vue 重构为两个 Tab,学生 Tab 使用级联下拉(班级→姓名),管理员 Tab 保留原有表单。
|
||||
|
||||
**Tech Stack:** Django + django-ninja (backend), Vue 3 + TypeScript + Naive UI (frontend), Axios (HTTP client)
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
**Modified:**
|
||||
- `api/account/models.py` — User 模型加 `classname` 字段
|
||||
- `api/account/api.py` — `batch_create` 保存 classname,新增两个 GET 端点
|
||||
- `api/account/schemas.py` — 新增 `ClassNameEntry` schema
|
||||
- `client/src/api.ts` — Account 对象加两个新方法
|
||||
- `client/src/components/Login.vue` — 完整重构为 Tab 布局
|
||||
|
||||
**Created:**
|
||||
- `api/account/migrations/0003_user_classname.py` — 迁移文件(由 `makemigrations` 自动生成)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 后端 — 给 User 模型加 classname 字段并迁移
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/account/models.py`
|
||||
|
||||
- [ ] **Step 1: 在 User 模型里加 classname 字段**
|
||||
|
||||
在 `api/account/models.py` 的 `User` 类中,在 `raw_password` 字段之后添加:
|
||||
|
||||
```python
|
||||
classname = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name="班级",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 生成迁移文件**
|
||||
|
||||
```bash
|
||||
cd api && uv run manage.py makemigrations account
|
||||
```
|
||||
|
||||
期望输出:`Migrations for 'account': account/migrations/0003_user_classname.py`
|
||||
|
||||
- [ ] **Step 3: 执行迁移**
|
||||
|
||||
```bash
|
||||
uv run manage.py migrate
|
||||
```
|
||||
|
||||
期望输出:`Applying account.0003_user_classname... OK`
|
||||
|
||||
- [ ] **Step 4: 验证字段已加入**
|
||||
|
||||
```bash
|
||||
uv run manage.py shell -c "from account.models import User; print(User._meta.get_field('classname'))"
|
||||
```
|
||||
|
||||
期望输出:`<django.db.models.fields.CharField: classname>`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd api && git add account/models.py account/migrations/0003_user_classname.py
|
||||
git commit -m "feat: add classname field to User model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 后端 — batch_create 保存 classname
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/account/api.py:97-101`
|
||||
|
||||
- [ ] **Step 1: 修改 batch_create 中构造 User 的代码**
|
||||
|
||||
找到 `api/account/api.py` 中的 `batch_create` 函数,将:
|
||||
|
||||
```python
|
||||
user = User(username=username)
|
||||
user.set_password(password)
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```python
|
||||
user = User(username=username, classname=payload.classname)
|
||||
user.set_password(password)
|
||||
```
|
||||
|
||||
(必须在 `set_password` 之前设置 `classname`,因为 `set_password` 内部会触发 `save()`)
|
||||
|
||||
- [ ] **Step 2: 手动测试批量创建保存了 classname**
|
||||
|
||||
```bash
|
||||
cd api && uv run manage.py shell -c "
|
||||
from account.models import User
|
||||
# 测试用:直接创建一个带 classname 的 user
|
||||
u = User(username='webtest班级测试', classname='test班')
|
||||
u.set_password('123456')
|
||||
print('classname:', u.classname)
|
||||
u.delete()
|
||||
"
|
||||
```
|
||||
|
||||
期望输出:`classname: test班`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add api/account/api.py
|
||||
git commit -m "feat: save classname in batch_create endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 后端 — 新增 /classes 和 /names 端点
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/account/api.py`
|
||||
- Modify: `api/account/schemas.py`
|
||||
|
||||
- [ ] **Step 1: 在 schemas.py 新增返回 schema**
|
||||
|
||||
在 `api/account/schemas.py` 末尾添加:
|
||||
|
||||
```python
|
||||
class ClassStudentEntry(Schema):
|
||||
name: str
|
||||
username: str
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 api.py 新增两个端点**
|
||||
|
||||
在 `api/account/api.py` 中,在 `router = Router()` 后的 import 区域加入 `ClassStudentEntry`:
|
||||
|
||||
```python
|
||||
from .schemas import (
|
||||
BatchUsersIn,
|
||||
ClassStudentEntry,
|
||||
LeaderboardEntry,
|
||||
UserListSchema,
|
||||
UserRegistrationSchema,
|
||||
UserLoginSchema,
|
||||
)
|
||||
```
|
||||
|
||||
然后在文件末尾(`leaderboard` 函数之后)添加:
|
||||
|
||||
```python
|
||||
@router.get("/classes", response=List[str])
|
||||
def list_classes(request):
|
||||
"""返回所有不重复的非空班级名列表,按字典序升序"""
|
||||
return list(
|
||||
User.objects.filter(classname__gt="")
|
||||
.values_list("classname", flat=True)
|
||||
.distinct()
|
||||
.order_by("classname")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/names", response=List[ClassStudentEntry])
|
||||
def list_names_by_class(request, classname: str):
|
||||
"""返回指定班级的学生姓名和用户名列表"""
|
||||
prefix = "web" + classname
|
||||
users = User.objects.filter(
|
||||
classname=classname,
|
||||
username__startswith=prefix,
|
||||
).order_by("username")
|
||||
return [
|
||||
ClassStudentEntry(name=u.username[len(prefix):], username=u.username)
|
||||
for u in users
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 启动开发服务器测试端点**
|
||||
|
||||
```bash
|
||||
cd api && uv run manage.py runserver
|
||||
```
|
||||
|
||||
在另一个终端测试:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/account/classes
|
||||
# 期望:[] (无 classname 数据时)
|
||||
|
||||
curl "http://localhost:8000/api/account/names?classname=301"
|
||||
# 期望:[]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add api/account/api.py api/account/schemas.py
|
||||
git commit -m "feat: add /classes and /names endpoints for student login"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 前端 — api.ts 新增两个方法
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/api.ts`
|
||||
|
||||
- [ ] **Step 1: 在 Account 对象末尾加两个方法**
|
||||
|
||||
在 `client/src/api.ts` 的 `Account` 对象中,`leaderboard` 方法之后添加:
|
||||
|
||||
```typescript
|
||||
async listClasses(): Promise<string[]> {
|
||||
const res = await http.get("/account/classes")
|
||||
return res.data
|
||||
},
|
||||
|
||||
async listNamesByClass(
|
||||
classname: string,
|
||||
): Promise<{ name: string; username: string }[]> {
|
||||
const res = await http.get("/account/names", { params: { classname } })
|
||||
return res.data
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
cd client && git add src/api.ts
|
||||
git commit -m "feat: add listClasses and listNamesByClass to Account API client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 前端 — 重构 Login.vue
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/components/Login.vue`
|
||||
|
||||
- [ ] **Step 1: 用以下完整内容替换 Login.vue**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<n-modal
|
||||
preset="card"
|
||||
title="登录"
|
||||
style="width: 420px"
|
||||
v-model:show="loginModal"
|
||||
>
|
||||
<n-tabs v-model:value="activeTab" @update:value="onTabChange">
|
||||
<n-tab-pane name="student" tab="学生登录">
|
||||
<n-form>
|
||||
<n-form-item label="班级">
|
||||
<n-select
|
||||
v-model:value="selectedClass"
|
||||
:options="classOptions"
|
||||
placeholder="选择班级"
|
||||
:loading="classesLoading"
|
||||
@update:value="onClassChange"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="姓名">
|
||||
<n-select
|
||||
v-model:value="selectedUsername"
|
||||
:options="nameOptions"
|
||||
placeholder="选择姓名"
|
||||
:loading="namesLoading"
|
||||
:disabled="!selectedClass || namesLoading"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="密码">
|
||||
<n-input
|
||||
type="password"
|
||||
v-model:value="studentPassword"
|
||||
name="password"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-alert
|
||||
type="error"
|
||||
v-if="classesError"
|
||||
class="message"
|
||||
title="加载班级列表失败,请刷新重试"
|
||||
/>
|
||||
<n-alert
|
||||
type="error"
|
||||
v-if="namesError"
|
||||
class="message"
|
||||
title="加载姓名列表失败,请重新选择班级"
|
||||
/>
|
||||
<n-alert
|
||||
type="error"
|
||||
v-if="showStudentError"
|
||||
class="message"
|
||||
title="登录失败,请检查密码"
|
||||
/>
|
||||
<n-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="studentLoading"
|
||||
:disabled="!selectedClass || !selectedUsername || !studentPassword"
|
||||
@click="submitStudent"
|
||||
>
|
||||
登录
|
||||
</n-button>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="admin" tab="管理员登录">
|
||||
<n-form>
|
||||
<n-form-item label="用户名">
|
||||
<n-input v-model:value="adminName" name="username" />
|
||||
</n-form-item>
|
||||
<n-form-item label="密码">
|
||||
<n-input
|
||||
type="password"
|
||||
v-model:value="adminPassword"
|
||||
name="password"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-alert
|
||||
type="error"
|
||||
v-if="showAdminError"
|
||||
class="message"
|
||||
title="登录失败,请检查用户名和密码"
|
||||
/>
|
||||
<n-button
|
||||
block
|
||||
type="primary"
|
||||
:loading="adminLoading"
|
||||
:disabled="!adminName || !adminPassword"
|
||||
@click="submitAdmin"
|
||||
>
|
||||
登录
|
||||
</n-button>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted } from "vue"
|
||||
import { Account } from "../api"
|
||||
import { loginModal } from "../store/modal"
|
||||
import { user } from "../store/user"
|
||||
|
||||
// Tab state
|
||||
const activeTab = ref("student")
|
||||
|
||||
// Student tab state
|
||||
const selectedClass = ref<string | null>(null)
|
||||
const selectedUsername = ref<string | null>(null)
|
||||
const studentPassword = ref("")
|
||||
const studentLoading = ref(false)
|
||||
const showStudentError = ref(false)
|
||||
|
||||
// Classes data
|
||||
const classes = ref<string[]>([])
|
||||
const classesLoading = ref(false)
|
||||
const classesError = ref(false)
|
||||
const classOptions = computed(() =>
|
||||
classes.value.map((c) => ({ label: c, value: c })),
|
||||
)
|
||||
|
||||
// Names data
|
||||
const names = ref<{ name: string; username: string }[]>([])
|
||||
const namesLoading = ref(false)
|
||||
const namesError = ref(false)
|
||||
const nameOptions = computed(() =>
|
||||
names.value.map((n) => ({ label: n.name, value: n.username })),
|
||||
)
|
||||
|
||||
// Admin tab state
|
||||
const adminName = ref("")
|
||||
const adminPassword = ref("")
|
||||
const adminLoading = ref(false)
|
||||
const showAdminError = ref(false)
|
||||
|
||||
// Load classes on mount (cached — not reloaded on tab switch)
|
||||
onMounted(async () => {
|
||||
classesLoading.value = true
|
||||
classesError.value = false
|
||||
try {
|
||||
classes.value = await Account.listClasses()
|
||||
} catch {
|
||||
classesError.value = true
|
||||
} finally {
|
||||
classesLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function onTabChange() {
|
||||
// Reset student tab state
|
||||
selectedClass.value = null
|
||||
selectedUsername.value = null
|
||||
studentPassword.value = ""
|
||||
showStudentError.value = false
|
||||
namesError.value = false
|
||||
names.value = []
|
||||
// Reset admin tab state
|
||||
adminName.value = ""
|
||||
adminPassword.value = ""
|
||||
showAdminError.value = false
|
||||
}
|
||||
|
||||
async function onClassChange(classname: string) {
|
||||
selectedUsername.value = null
|
||||
names.value = []
|
||||
namesError.value = false
|
||||
namesLoading.value = true
|
||||
try {
|
||||
names.value = await Account.listNamesByClass(classname)
|
||||
} catch {
|
||||
namesError.value = true
|
||||
} finally {
|
||||
namesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitStudent() {
|
||||
if (!selectedUsername.value || !studentPassword.value) return
|
||||
studentLoading.value = true
|
||||
showStudentError.value = false
|
||||
try {
|
||||
const data = await Account.login(selectedUsername.value, studentPassword.value)
|
||||
user.username = data.username
|
||||
user.role = data.role
|
||||
user.loaded = true
|
||||
loginModal.value = false
|
||||
} catch {
|
||||
showStudentError.value = true
|
||||
} finally {
|
||||
studentLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAdmin() {
|
||||
if (!adminName.value || !adminPassword.value) return
|
||||
adminLoading.value = true
|
||||
showAdminError.value = false
|
||||
try {
|
||||
const data = await Account.login(adminName.value, adminPassword.value)
|
||||
user.username = data.username
|
||||
user.role = data.role
|
||||
user.loaded = true
|
||||
loginModal.value = false
|
||||
} catch {
|
||||
showAdminError.value = true
|
||||
} finally {
|
||||
adminLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 启动前端开发服务器验证**
|
||||
|
||||
```bash
|
||||
cd client && npm run start
|
||||
```
|
||||
|
||||
打开浏览器,点击登录按钮,检查:
|
||||
- [ ] 显示两个 Tab:学生登录 / 管理员登录
|
||||
- [ ] 学生登录 Tab:班级下拉加载数据(若无数据则为空列表)
|
||||
- [ ] 选择班级后,姓名下拉变为可用并加载数据
|
||||
- [ ] 三个字段未全填时,登录按钮 disabled
|
||||
- [ ] 管理员登录 Tab:用户名+密码表单,两个字段未填时按钮 disabled
|
||||
- [ ] 切换 Tab 时,两个 Tab 的所有字段清空
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/components/Login.vue
|
||||
git commit -m "feat: refactor Login.vue with class/name dropdown for students and admin tab"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 端到端验证
|
||||
|
||||
- [ ] **Step 1: 通过 Django admin 或 batch_create 创建测试用户**
|
||||
|
||||
```bash
|
||||
cd api && uv run manage.py shell -c "
|
||||
from account.models import User
|
||||
u = User(username='web测试班张三', classname='测试班')
|
||||
u.set_password('123456')
|
||||
print('created:', u.username, u.classname)
|
||||
"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 /classes 返回新班级**
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/account/classes
|
||||
# 期望:["测试班"]
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证 /names 返回学生**
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8000/api/account/names?classname=测试班"
|
||||
# 期望:[{"name": "张三", "username": "web测试班张三"}]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 在浏览器中完整登录流程测试**
|
||||
|
||||
1. 打开登录框,选学生登录 Tab
|
||||
2. 班级下拉选"测试班"
|
||||
3. 姓名下拉选"张三"
|
||||
4. 输入密码 `123456`
|
||||
5. 点击登录,验证成功跳转
|
||||
|
||||
- [ ] **Step 5: 测试管理员 Tab 登录**
|
||||
|
||||
用现有管理员账号在管理员 Tab 登录,验证正常。
|
||||
|
||||
- [ ] **Step 6: 清理测试数据**
|
||||
|
||||
```bash
|
||||
cd api && uv run manage.py shell -c "
|
||||
from account.models import User
|
||||
User.objects.filter(username='web测试班张三').delete()
|
||||
print('cleaned')
|
||||
"
|
||||
```
|
||||
@@ -0,0 +1,244 @@
|
||||
# Submission View Count 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:** 在 `GET /api/submission/{id}` 时原子递增 `view_count` 字段,并在响应中返回该值,供管理员了解哪些提交热度最高。
|
||||
|
||||
**Architecture:** 在 `Submission` 模型新增 `view_count` 整数字段;`get_submission` 视图在查询后、返回前执行一次 `UPDATE ... SET view_count = view_count + 1`(使用 `F()` 表达式保证原子性);`SubmissionOut` schema 暴露该字段。
|
||||
|
||||
**Tech Stack:** Django 6, django-ninja, SQLite(开发)/ PostgreSQL(生产),uv
|
||||
|
||||
---
|
||||
|
||||
## 文件变更概览
|
||||
|
||||
| 文件 | 操作 |
|
||||
|------|------|
|
||||
| `api/submission/models.py` | 新增 `view_count` 字段 |
|
||||
| `api/submission/migrations/0009_add_view_count.py` | 新建 migration |
|
||||
| `api/submission/schemas.py` | `SubmissionOut` 新增 `view_count: int` |
|
||||
| `api/submission/api.py` | `get_submission` 中递增 `view_count` |
|
||||
| `api/submission/tests.py` | 新建测试文件 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 写失败测试
|
||||
|
||||
**Files:**
|
||||
- Create: `api/submission/tests.py`
|
||||
|
||||
- [ ] **Step 1: 创建测试文件**
|
||||
|
||||
```python
|
||||
# api/submission/tests.py
|
||||
import uuid
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from ninja.testing import TestClient
|
||||
|
||||
from task.models import Task
|
||||
from submission.models import Submission
|
||||
from api.api import api # django-ninja 的 NinjaAPI 实例
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ViewCountTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="testuser", password="pass"
|
||||
)
|
||||
self.task = Task.objects.create(
|
||||
title="Test Task",
|
||||
task_type="challenge",
|
||||
display=1,
|
||||
)
|
||||
self.submission = Submission.objects.create(
|
||||
user=self.user,
|
||||
task=self.task,
|
||||
html="<p>hi</p>",
|
||||
)
|
||||
|
||||
def test_get_submission_increments_view_count(self):
|
||||
"""每次 GET /{id} 后 view_count 应加 1"""
|
||||
client = TestClient(api)
|
||||
client.force_login(self.user) # ninja TestClient auth
|
||||
|
||||
assert self.submission.view_count == 0
|
||||
|
||||
client.get(f"/submission/{self.submission.id}")
|
||||
self.submission.refresh_from_db()
|
||||
assert self.submission.view_count == 1
|
||||
|
||||
client.get(f"/submission/{self.submission.id}")
|
||||
self.submission.refresh_from_db()
|
||||
assert self.submission.view_count == 2
|
||||
|
||||
def test_get_submission_response_includes_view_count(self):
|
||||
"""响应 JSON 中应包含 view_count 字段"""
|
||||
client = TestClient(api)
|
||||
client.force_login(self.user)
|
||||
|
||||
response = client.get(f"/submission/{self.submission.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "view_count" in data
|
||||
assert data["view_count"] == 1
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试,确认失败**
|
||||
|
||||
```bash
|
||||
cd api && uv run manage.py test submission.tests.ViewCountTest -v 2
|
||||
```
|
||||
|
||||
预期:`AttributeError: 'Submission' object has no attribute 'view_count'`(字段不存在)
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 新增模型字段与 migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/submission/models.py`
|
||||
- Create: `api/submission/migrations/0009_add_view_count.py`
|
||||
|
||||
- [ ] **Step 1: 在 `Submission` 模型添加字段**
|
||||
|
||||
在 `api/submission/models.py` 的 `zone` 字段后面插入:
|
||||
|
||||
```python
|
||||
view_count = models.PositiveIntegerField(default=0, verbose_name="查看次数")
|
||||
```
|
||||
|
||||
完整的字段列表变为(只展示新增行的上下文):
|
||||
|
||||
```python
|
||||
zone = models.CharField(
|
||||
max_length=10,
|
||||
choices=ZoneChoices.choices,
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
db_index=True,
|
||||
verbose_name="分区",
|
||||
)
|
||||
view_count = models.PositiveIntegerField(default=0, verbose_name="查看次数")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 生成 migration**
|
||||
|
||||
```bash
|
||||
cd api && uv run manage.py makemigrations submission --name add_view_count
|
||||
```
|
||||
|
||||
预期输出:`Migrations for 'submission': submission/migrations/0009_add_view_count.py`
|
||||
|
||||
- [ ] **Step 3: 执行 migration**
|
||||
|
||||
```bash
|
||||
cd api && uv run manage.py migrate
|
||||
```
|
||||
|
||||
预期:`Applying submission.0009_add_view_count... OK`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 更新 schema
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/submission/schemas.py`
|
||||
|
||||
- [ ] **Step 1: 在 `SubmissionOut` 中添加 `view_count` 字段**
|
||||
|
||||
在 `submit_count: int = 0` 这行之后添加:
|
||||
|
||||
```python
|
||||
view_count: int = 0
|
||||
```
|
||||
|
||||
同时在 `resolve_submit_count` 方法之后添加解析器:
|
||||
|
||||
```python
|
||||
@staticmethod
|
||||
def resolve_view_count(obj):
|
||||
return getattr(obj, "view_count", 0) or 0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试,确认仍失败(API 还未递增)**
|
||||
|
||||
```bash
|
||||
cd api && uv run manage.py test submission.tests.ViewCountTest -v 2
|
||||
```
|
||||
|
||||
预期:`AssertionError: assert 0 == 1`(字段存在但没有递增)
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 在 API 中递增 view_count
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/submission/api.py`
|
||||
|
||||
- [ ] **Step 1: 在 `get_submission` 中添加原子递增**
|
||||
|
||||
找到 `api/submission/api.py` 的 `get_submission` 函数(第 311 行附近),将其改为:
|
||||
|
||||
```python
|
||||
@router.get("/{submission_id}", response=SubmissionOut)
|
||||
@login_required
|
||||
def get_submission(request, submission_id: UUID):
|
||||
"""
|
||||
获取单个提交的详细信息
|
||||
"""
|
||||
user_rating_subquery = Subquery(
|
||||
Rating.objects.filter(user=request.user, submission=OuterRef("pk")).values(
|
||||
"score"
|
||||
)[:1],
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
submission = get_object_or_404(
|
||||
Submission.objects.select_related("task", "user").annotate(
|
||||
my_score=user_rating_subquery
|
||||
),
|
||||
id=submission_id,
|
||||
)
|
||||
Submission.objects.filter(pk=submission.pk).update(
|
||||
view_count=F("view_count") + 1
|
||||
)
|
||||
submission.view_count += 1 # 更新内存中的值,避免再次查询
|
||||
return submission
|
||||
```
|
||||
|
||||
同时将 `api/submission/api.py` 顶部的导入行:
|
||||
|
||||
```python
|
||||
from django.db.models import Avg, Count, IntegerField, Max, OuterRef, Q, Subquery
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```python
|
||||
from django.db.models import Avg, Count, F, IntegerField, Max, OuterRef, Q, Subquery
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行全部测试,确认通过**
|
||||
|
||||
```bash
|
||||
cd api && uv run manage.py test submission.tests.ViewCountTest -v 2
|
||||
```
|
||||
|
||||
预期:
|
||||
```
|
||||
test_get_submission_increments_view_count ... ok
|
||||
test_get_submission_response_includes_view_count ... ok
|
||||
----------------------------------------------------------------------
|
||||
Ran 2 tests in X.XXXs
|
||||
OK
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
cd api && git add submission/models.py submission/migrations/0009_add_view_count.py submission/schemas.py submission/api.py submission/tests.py
|
||||
git commit -m "feat: track view_count on Submission, increment on GET /{id}"
|
||||
```
|
||||
@@ -0,0 +1,304 @@
|
||||
# 人气前五 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:** 在提交统计弹窗中新增"人气前五"区块,展示当前 task 下 view_count 最高的前 5 条提交,跟随班级筛选。
|
||||
|
||||
**Architecture:** 后端在现有 `get_task_stats` 端点中新增 `top_viewed` 查询,前端在 `TaskStatsModal` 底部新增对应 UI 区块。全程复用已有数据流,不新增 API 路由。
|
||||
|
||||
**Tech Stack:** Python/Django-Ninja (backend schema + query), TypeScript (type definition), Vue 3 + Naive UI (frontend component)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 后端 Schema — 新增 `TopViewedItem`,更新 `TaskStatsOut`
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/submission/schemas.py:134-151`
|
||||
|
||||
- [ ] **Step 1: 在 `FlagStats` 之前插入 `TopViewedItem` class**
|
||||
|
||||
在 `api/submission/schemas.py` 第 134 行(`class FlagStats` 之前)插入:
|
||||
|
||||
```python
|
||||
class TopViewedItem(Schema):
|
||||
username: str
|
||||
classname: str
|
||||
view_count: int
|
||||
submission_id: str
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 `TaskStatsOut` 末尾新增 `top_viewed` 字段**
|
||||
|
||||
将 `TaskStatsOut`(当前第 141-151 行)更新为:
|
||||
|
||||
```python
|
||||
class TaskStatsOut(Schema):
|
||||
submitted_count: int
|
||||
unsubmitted_count: int
|
||||
average_score: Optional[float]
|
||||
unrated_count: int
|
||||
unsubmitted_users: list[UserTag]
|
||||
unrated_users: list[UserTag]
|
||||
submission_count_distribution: SubmissionCountBucket
|
||||
flag_stats: FlagStats
|
||||
classes: list[str]
|
||||
top_viewed: list[TopViewedItem]
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证 Schema 无语法错误**
|
||||
|
||||
```bash
|
||||
cd /home/xuyue/Projects/webpreview/api
|
||||
uv run python -c "from submission.schemas import TaskStatsOut, TopViewedItem; print('OK')"
|
||||
```
|
||||
|
||||
Expected output: `OK`
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add api/submission/schemas.py
|
||||
git commit -m "feat: add TopViewedItem schema and top_viewed field to TaskStatsOut"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 后端查询 — 在 `get_task_stats` 中计算并返回 `top_viewed`
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/submission/api.py:13-23` (imports)
|
||||
- Modify: `api/submission/api.py:306-330` (flag_stats 之后 → return 之前,以及 return 语句)
|
||||
|
||||
- [ ] **Step 1: 在 import 中新增 `TopViewedItem`**
|
||||
|
||||
将 `api/submission/api.py` 顶部的 import(第 13-23 行)更新为:
|
||||
|
||||
```python
|
||||
from .schemas import (
|
||||
FlagIn,
|
||||
FlagStats,
|
||||
SubmissionCountBucket,
|
||||
SubmissionFilter,
|
||||
SubmissionIn,
|
||||
SubmissionOut,
|
||||
RatingScoreIn,
|
||||
TaskStatsOut,
|
||||
TopViewedItem,
|
||||
UserTag,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 `flag_stats =` 赋值之后、`return TaskStatsOut(...)` 之前插入 top_viewed 查询**
|
||||
|
||||
在 `api/submission/api.py` 第 318 行(`return TaskStatsOut(` 之前)插入:
|
||||
|
||||
```python
|
||||
# Top 5 submissions by view_count (within filtered student_ids)
|
||||
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
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 在 `return TaskStatsOut(...)` 中新增 `top_viewed=top_viewed`**
|
||||
|
||||
将返回语句(当前第 320-330 行)更新为:
|
||||
|
||||
```python
|
||||
return TaskStatsOut(
|
||||
submitted_count=submitted_count,
|
||||
unsubmitted_count=unsubmitted_count,
|
||||
average_score=average_score,
|
||||
unrated_count=unrated_count,
|
||||
unsubmitted_users=unsubmitted_users,
|
||||
unrated_users=unrated_users,
|
||||
submission_count_distribution=SubmissionCountBucket(**dist),
|
||||
flag_stats=flag_stats,
|
||||
classes=all_classes,
|
||||
top_viewed=top_viewed,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 启动开发服务器,验证接口返回**
|
||||
|
||||
```bash
|
||||
cd /home/xuyue/Projects/webpreview/api
|
||||
uv run manage.py runserver
|
||||
```
|
||||
|
||||
另开终端,用已有管理员账号 cookie 或 session 测试(替换 `<task_id>` 和 `<session>`):
|
||||
|
||||
```bash
|
||||
curl -s -H "Cookie: sessionid=<session>" \
|
||||
http://127.0.0.1:8000/api/submission/stats/<task_id> \
|
||||
| python3 -m json.tool | grep -A 20 '"top_viewed"'
|
||||
```
|
||||
|
||||
Expected: 看到 `"top_viewed": [...]`,每项含 `username`、`classname`、`view_count`、`submission_id`。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add api/submission/api.py
|
||||
git commit -m "feat: query top 5 by view_count in get_task_stats"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 前端类型 — 新增 `TopViewedItem` 接口,更新 `TaskStatsOut`
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/utils/type.ts:136-148`
|
||||
|
||||
- [ ] **Step 1: 在 `TaskStatsOut` 之前插入 `TopViewedItem` 接口**
|
||||
|
||||
在 `client/src/utils/type.ts` 第 136 行(`export interface TaskStatsOut` 之前)插入:
|
||||
|
||||
```ts
|
||||
export interface TopViewedItem {
|
||||
username: string
|
||||
classname: string
|
||||
view_count: number
|
||||
submission_id: string
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 `TaskStatsOut` 末尾新增 `top_viewed` 字段**
|
||||
|
||||
将 `TaskStatsOut`(当前第 137-148 行)更新为:
|
||||
|
||||
```ts
|
||||
export interface TaskStatsOut {
|
||||
submitted_count: number
|
||||
unsubmitted_count: number
|
||||
average_score: number | null
|
||||
unrated_count: number
|
||||
unsubmitted_users: UserTag[]
|
||||
unrated_users: UserTag[]
|
||||
submission_count_distribution: SubmissionCountBucket
|
||||
score_distribution: ScoreBucket
|
||||
flag_stats: FlagStats
|
||||
classes: string[]
|
||||
top_viewed: TopViewedItem[]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/utils/type.ts
|
||||
git commit -m "feat: add TopViewedItem type and top_viewed to TaskStatsOut"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 前端 UI — 在 `TaskStatsModal` 中新增"人气前五"区块
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/components/TaskStatsModal.vue`
|
||||
|
||||
- [ ] **Step 1: 在模板的"标记统计"区块(`<!-- 标记统计 -->`,第 282 行附近)之后新增"人气前五"区块**
|
||||
|
||||
找到 `</n-spin>` 之前的 `<!-- 标记统计 -->` 区块末尾的 `</div>`(第 325 行附近),在它后面、`</n-spin>` 之前插入:
|
||||
|
||||
```html
|
||||
<!-- 人气前五 -->
|
||||
<div style="margin-top: 12px">
|
||||
<div
|
||||
style="
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
color: #333;
|
||||
"
|
||||
>
|
||||
人气前五
|
||||
</div>
|
||||
<div
|
||||
v-if="stats.top_viewed.length === 0"
|
||||
style="color: #aaa; font-size: 12px"
|
||||
>
|
||||
暂无
|
||||
</div>
|
||||
<div v-else style="display: flex; flex-direction: column; gap: 6px">
|
||||
<div
|
||||
v-for="(item, i) in stats.top_viewed"
|
||||
:key="item.submission_id"
|
||||
style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
background: #fafafa;
|
||||
"
|
||||
@click="viewSubmission(item.submission_id)"
|
||||
>
|
||||
<span
|
||||
:style="{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
background: i < 3 ? ['#f0a020', '#888', '#a07040'][i] : '#ddd',
|
||||
color: '#fff',
|
||||
fontSize: '11px',
|
||||
fontWeight: '700',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: '0',
|
||||
}"
|
||||
>{{ i + 1 }}</span>
|
||||
<span style="flex: 1; font-size: 13px; color: #333">
|
||||
{{ displayName(item.username, item.classname) }}
|
||||
</span>
|
||||
<span style="font-size: 12px; color: #2080f0; font-weight: 600">
|
||||
{{ item.view_count }} 次
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 `<script>` 中确认 `TopViewedItem` 类型已被 `TaskStatsOut` 携带(无需额外 import)**
|
||||
|
||||
打开 `client/src/components/TaskStatsModal.vue`,确认顶部 import 行:
|
||||
|
||||
```ts
|
||||
import type { TaskStatsOut } from "../utils/type"
|
||||
```
|
||||
|
||||
`TopViewedItem` 作为 `TaskStatsOut.top_viewed` 的元素类型,无需单独导入。
|
||||
|
||||
- [ ] **Step 3: 启动前端开发服务器,打开任意 task 的提交统计弹窗**
|
||||
|
||||
```bash
|
||||
cd /home/xuyue/Projects/webpreview/client
|
||||
npm run start
|
||||
```
|
||||
|
||||
在浏览器中打开 Dashboard,进入任意 tutorial/challenge,点击"提交统计",向下滚动确认:
|
||||
- 出现"人气前五"标题
|
||||
- 列表按 view_count 降序排列,最多 5 项
|
||||
- 点击某项会在新标签打开对应提交详情
|
||||
- 切换班级筛选后列表随之更新
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/components/TaskStatsModal.vue
|
||||
git commit -m "feat: add top-viewed-5 section to TaskStatsModal"
|
||||
```
|
||||
@@ -0,0 +1,192 @@
|
||||
# AI Chat Stop Button 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:** Add a stop button to the AI chat panel that cancels in-flight streaming, removes the pending messages from the UI, and ensures nothing is saved to the database or submitted.
|
||||
|
||||
**Architecture:** When the user clicks stop, the frontend pops the unsaved user message from the UI, resets streaming state, and closes the WebSocket. The backend's existing `disconnect()` handler deletes the in-progress user message from the DB; the assistant message and Submission are never created because `complete` is never sent.
|
||||
|
||||
**Tech Stack:** Vue 3, TypeScript, Naive UI, WebSocket (Django Channels)
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `client/src/store/prompt.ts` | Store `_currentTaskId`; set `streaming=true` on send; add `stopPrompt()` |
|
||||
| `client/src/components/ai/PromptPanel.vue` | Import `stopPrompt`; swap Send/Stop buttons based on `streaming` |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Update `store/prompt.ts`
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/store/prompt.ts`
|
||||
|
||||
- [ ] **Step 1: Store `_currentTaskId` and set it in `connectPrompt`**
|
||||
|
||||
In `client/src/store/prompt.ts`, after the line `let ws: WebSocket | null = null`, add:
|
||||
|
||||
```typescript
|
||||
let _currentTaskId = 0
|
||||
```
|
||||
|
||||
Then inside `connectPrompt(taskId: number)`, add as the very first line of the function body:
|
||||
|
||||
```typescript
|
||||
_currentTaskId = taskId
|
||||
```
|
||||
|
||||
Result — the start of `connectPrompt` becomes:
|
||||
```typescript
|
||||
export function connectPrompt(taskId: number) {
|
||||
_currentTaskId = taskId
|
||||
if (ws) ws.close()
|
||||
// ... rest unchanged
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Set `streaming = true` immediately on send**
|
||||
|
||||
In `sendPrompt()`, add `streaming.value = true` as the first statement before the `push`:
|
||||
|
||||
```typescript
|
||||
export function sendPrompt(content: string, model: string = "") {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
streaming.value = true
|
||||
messages.value.push({ role: "user", content })
|
||||
ws.send(JSON.stringify({ type: "message", content, model }))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `stopPrompt()` function**
|
||||
|
||||
After `sendPrompt`, add:
|
||||
|
||||
```typescript
|
||||
export function stopPrompt() {
|
||||
// Remove the user message added to UI (not yet saved in DB)
|
||||
if (
|
||||
messages.value.length > 0 &&
|
||||
messages.value[messages.value.length - 1].role === "user"
|
||||
) {
|
||||
messages.value.pop()
|
||||
}
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
// Closing triggers backend disconnect() which deletes the DB user message
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
// Reconnect immediately so the next message can be sent
|
||||
if (_currentTaskId) {
|
||||
connectPrompt(_currentTaskId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify the file looks correct**
|
||||
|
||||
Open `client/src/store/prompt.ts` and confirm:
|
||||
- `_currentTaskId` is declared after `let ws`
|
||||
- `connectPrompt` sets `_currentTaskId = taskId` as its first line
|
||||
- `sendPrompt` sets `streaming.value = true` before the push
|
||||
- `stopPrompt` is exported and appears between `sendPrompt` and the private `applyCode`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/xuyue/Projects/webpreview
|
||||
git add client/src/store/prompt.ts
|
||||
git commit -m "feat: add stopPrompt() and track currentTaskId in prompt store"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add Stop button to `PromptPanel.vue`
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/components/ai/PromptPanel.vue`
|
||||
|
||||
- [ ] **Step 1: Import `stopPrompt`**
|
||||
|
||||
Find the import line in `<script setup>`:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
messages,
|
||||
streaming,
|
||||
streamingContent,
|
||||
sendPrompt,
|
||||
} from "../../store/prompt"
|
||||
```
|
||||
|
||||
Add `stopPrompt` to the import:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
messages,
|
||||
streaming,
|
||||
streamingContent,
|
||||
sendPrompt,
|
||||
stopPrompt,
|
||||
} from "../../store/prompt"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the send button with conditional Stop/Send buttons**
|
||||
|
||||
Find the current button block in `<template>`:
|
||||
|
||||
```html
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="streaming"
|
||||
:disabled="!input.trim() || streaming"
|
||||
@click="send"
|
||||
>
|
||||
发送
|
||||
</n-button>
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```html
|
||||
<n-button
|
||||
v-if="streaming"
|
||||
type="error"
|
||||
@click="stopPrompt"
|
||||
>
|
||||
停止
|
||||
</n-button>
|
||||
<n-button
|
||||
v-else
|
||||
type="primary"
|
||||
:disabled="!input.trim()"
|
||||
@click="send"
|
||||
>
|
||||
发送
|
||||
</n-button>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify in browser**
|
||||
|
||||
Start the dev server:
|
||||
```bash
|
||||
cd /home/xuyue/Projects/webpreview/client
|
||||
npm run start
|
||||
```
|
||||
|
||||
Open a challenge page, send a message, confirm:
|
||||
1. The **停止** button appears immediately when send is clicked (no gap)
|
||||
2. Clicking **停止** removes the user message and streaming content from the UI
|
||||
3. The **发送** button reappears and is usable for the next message
|
||||
4. A subsequent message works normally (WS reconnected)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd /home/xuyue/Projects/webpreview
|
||||
git add client/src/components/ai/PromptPanel.vue
|
||||
git commit -m "feat: show stop button during AI streaming in PromptPanel"
|
||||
```
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,267 @@
|
||||
# Chain Source Indicator 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:** In the ChainModal prompt chain, show a small tag on each round indicating whether it originated from AI dialogue or a manual submission.
|
||||
|
||||
**Architecture:** The `Message.source` field already exists on the backend ("conversation" or "manual") but is not serialized. We expose it through the existing `list_messages` endpoint, propagate it through the frontend type, and render a tag badge in `ChainModal.vue` alongside the existing L1–L6 level badge.
|
||||
|
||||
**Tech Stack:** Django-ninja (Python), Vue 3, TypeScript, Naive UI
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `api/prompt/schemas.py` | Add `source: str` to `MessageOut` |
|
||||
| `api/prompt/api.py` | Add `"source": m.source` to `list_messages` response |
|
||||
| `client/src/utils/type.ts` | Add `source?: string` to `PromptMessage` |
|
||||
| `client/src/components/submissions/ChainModal.vue` | Carry `source` through rounds, render tag |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Expose `source` from the backend messages endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/prompt/schemas.py`
|
||||
- Modify: `api/prompt/api.py`
|
||||
|
||||
- [ ] **Step 1: Add `source` to `MessageOut` schema**
|
||||
|
||||
Open `api/prompt/schemas.py`. Change:
|
||||
|
||||
```python
|
||||
class MessageOut(Schema):
|
||||
id: int
|
||||
role: str
|
||||
content: str
|
||||
code_html: Optional[str] = None
|
||||
code_css: Optional[str] = None
|
||||
code_js: Optional[str] = None
|
||||
prompt_level: Optional[int] = None
|
||||
created: str
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```python
|
||||
class MessageOut(Schema):
|
||||
id: int
|
||||
role: str
|
||||
source: str
|
||||
content: str
|
||||
code_html: Optional[str] = None
|
||||
code_css: Optional[str] = None
|
||||
code_js: Optional[str] = None
|
||||
prompt_level: Optional[int] = None
|
||||
created: str
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Include `source` in the `list_messages` response dict**
|
||||
|
||||
Open `api/prompt/api.py`. In `list_messages`, the return list comprehension currently builds dicts manually. Add `"source": m.source`:
|
||||
|
||||
```python
|
||||
return [
|
||||
{
|
||||
"id": m.id,
|
||||
"role": m.role,
|
||||
"source": m.source,
|
||||
"content": m.content,
|
||||
"code_html": m.code_html,
|
||||
"code_css": m.code_css,
|
||||
"code_js": m.code_js,
|
||||
"prompt_level": m.prompt_level,
|
||||
"created": m.created.isoformat(),
|
||||
}
|
||||
for m in messages
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the API returns `source` manually**
|
||||
|
||||
With the dev server running (`uv run manage.py runserver` from `api/`), open the Django-ninja interactive docs at `http://localhost:8000/api/docs` and call the messages endpoint. Confirm the response includes `"source": "conversation"` or `"source": "manual"` on each message object.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add api/prompt/schemas.py api/prompt/api.py
|
||||
git commit -m "feat: expose message source field in messages API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add `source` to the frontend type
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/utils/type.ts`
|
||||
|
||||
- [ ] **Step 1: Add `source` to `PromptMessage`**
|
||||
|
||||
Open `client/src/utils/type.ts`. Change:
|
||||
|
||||
```typescript
|
||||
export interface PromptMessage {
|
||||
id: number
|
||||
role: string
|
||||
content: string
|
||||
code_html: string | null
|
||||
code_css: string | null
|
||||
code_js: string | null
|
||||
prompt_level?: number | null
|
||||
created: string
|
||||
}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```typescript
|
||||
export interface PromptMessage {
|
||||
id: number
|
||||
role: string
|
||||
source?: string
|
||||
content: string
|
||||
code_html: string | null
|
||||
code_css: string | null
|
||||
code_js: string | null
|
||||
prompt_level?: number | null
|
||||
created: string
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/utils/type.ts
|
||||
git commit -m "feat: add source field to PromptMessage type"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Render source indicator in ChainModal
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/components/submissions/ChainModal.vue`
|
||||
|
||||
- [ ] **Step 1: Carry `source` through the rounds computed property**
|
||||
|
||||
In `ChainModal.vue`, the `rounds` computed property builds objects with `{ question, prompt_level, html, css, js }`. Add `source` from the user message:
|
||||
|
||||
```typescript
|
||||
const rounds = computed(() => {
|
||||
const result: {
|
||||
question: string
|
||||
source: string | null
|
||||
prompt_level: number | null
|
||||
html: string | null
|
||||
css: string | null
|
||||
js: string | null
|
||||
}[] = []
|
||||
for (const [i, msg] of messages.value.entries()) {
|
||||
if (msg.role !== "user") continue
|
||||
let html: string | null = null,
|
||||
css: string | null = null,
|
||||
js: string | null = null
|
||||
for (const reply of messages.value.slice(i + 1)) {
|
||||
if (reply.role === "user") break
|
||||
if (reply.role === "assistant" && reply.code_html) {
|
||||
html = reply.code_html
|
||||
css = reply.code_css
|
||||
js = reply.code_js
|
||||
break
|
||||
}
|
||||
}
|
||||
result.push({
|
||||
question: msg.content,
|
||||
source: msg.source ?? null,
|
||||
prompt_level: msg.prompt_level ?? null,
|
||||
html,
|
||||
css,
|
||||
js,
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the source tag to the round card template**
|
||||
|
||||
Locate the `<div>` that renders the question and the L-level badge in the round card. It currently looks like:
|
||||
|
||||
```html
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
"
|
||||
>
|
||||
<span>{{ round.question }}</span>
|
||||
<span
|
||||
v-if="round.prompt_level"
|
||||
:style="{
|
||||
flexShrink: 0,
|
||||
fontSize: '11px',
|
||||
fontWeight: 'bold',
|
||||
color: levelColors[round.prompt_level],
|
||||
marginTop: '2px',
|
||||
}"
|
||||
>L{{ round.prompt_level }}</span
|
||||
>
|
||||
</div>
|
||||
```
|
||||
|
||||
Replace it with (source tag appears to the right of the L-level badge, in a small flex column):
|
||||
|
||||
```html
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
"
|
||||
>
|
||||
<span>{{ round.question }}</span>
|
||||
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: 3px; flex-shrink: 0">
|
||||
<span
|
||||
v-if="round.prompt_level"
|
||||
:style="{
|
||||
fontSize: '11px',
|
||||
fontWeight: 'bold',
|
||||
color: levelColors[round.prompt_level],
|
||||
marginTop: '2px',
|
||||
}"
|
||||
>L{{ round.prompt_level }}</span
|
||||
>
|
||||
<span
|
||||
v-if="round.source"
|
||||
:style="{
|
||||
fontSize: '10px',
|
||||
padding: '1px 5px',
|
||||
borderRadius: '4px',
|
||||
background: round.source === 'conversation' ? '#e8f0fe' : '#f0f0f0',
|
||||
color: round.source === 'conversation' ? '#2060c0' : '#888',
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
}"
|
||||
>{{ round.source === "conversation" ? "AI" : "手动" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify visually in the browser**
|
||||
|
||||
Start the frontend dev server (`npm run start` from `client/`). Open the Submissions page, expand a row, click 查看 to open ChainModal. Confirm:
|
||||
- Rounds from AI chat show a blue `AI` badge
|
||||
- Rounds from manual submissions show a gray `手动` badge
|
||||
- Rounds with no `source` (old data) show no badge and don't break
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add client/src/components/submissions/ChainModal.vue
|
||||
git commit -m "feat: show AI/manual source indicator on each chain round"
|
||||
```
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,208 @@
|
||||
# Showcase Management 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:** Add a Dashboard-based management page for awards and awarded submissions that controls the existing student-facing showcase.
|
||||
|
||||
**Architecture:** Keep the existing public showcase API unchanged. Add admin-only django-ninja management endpoints under `submission/api.py`, extend the TypeScript API client and shared types, then add a focused `ShowcaseManage.vue` Dashboard page wired into the existing router and sidebar.
|
||||
|
||||
**Tech Stack:** Django, django-ninja, Django TestCase, Vue 3 Composition API, TypeScript, Naive UI, Axios.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend Management API
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/submission/schemas.py`
|
||||
- Modify: `api/submission/api.py`
|
||||
- Test: `api/submission/tests.py`
|
||||
|
||||
- [ ] **Step 1: Write failing API tests**
|
||||
|
||||
Append tests to `api/submission/tests.py` that create an admin user, a normal user, an award, and a submission. Cover forbidden normal-user access, award creation/update, adding the same submission twice, and student showcase visibility after removal/deactivation.
|
||||
|
||||
Run: `uv run manage.py test submission`
|
||||
|
||||
Expected: FAIL because `/api/submission/showcase/manage/awards` endpoints do not exist.
|
||||
|
||||
- [ ] **Step 2: Add schema classes**
|
||||
|
||||
Add management schemas in `api/submission/schemas.py`:
|
||||
|
||||
```python
|
||||
class AwardManageIn(Schema):
|
||||
name: str
|
||||
description: str = ""
|
||||
sort_order: int = 0
|
||||
is_active: bool = True
|
||||
item_ordering: str = "manual"
|
||||
|
||||
|
||||
class AwardManageOut(Schema):
|
||||
id: int
|
||||
name: str
|
||||
description: str
|
||||
sort_order: int
|
||||
is_active: bool
|
||||
item_ordering: str
|
||||
item_count: int
|
||||
|
||||
|
||||
class AwardItemIn(Schema):
|
||||
submission_id: UUID
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class AwardItemUpdateIn(Schema):
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class AwardItemManageOut(Schema):
|
||||
id: int
|
||||
submission_id: UUID
|
||||
username: str
|
||||
task_title: str
|
||||
task_display: int
|
||||
score: float
|
||||
view_count: int
|
||||
sort_order: int
|
||||
awarded_at: datetime
|
||||
has_prompt_chain: bool
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add admin-only endpoints**
|
||||
|
||||
In `api/submission/api.py`, import `admin_required` and the new schemas. Add endpoints before `/showcase/{submission_id}/` so static `manage/...` paths resolve correctly:
|
||||
|
||||
- `GET /showcase/manage/awards`
|
||||
- `POST /showcase/manage/awards`
|
||||
- `PUT /showcase/manage/awards/{award_id}`
|
||||
- `DELETE /showcase/manage/awards/{award_id}`
|
||||
- `GET /showcase/manage/awards/{award_id}/items`
|
||||
- `POST /showcase/manage/awards/{award_id}/items`
|
||||
- `PUT /showcase/manage/items/{item_id}`
|
||||
- `DELETE /showcase/manage/items/{item_id}`
|
||||
|
||||
Use `admin_required`, `get_object_or_404`, `Count`, `Exists`, and `OuterRef`. Validate `item_ordering` against `ItemOrdering.values`.
|
||||
|
||||
- [ ] **Step 4: Run backend tests**
|
||||
|
||||
Run: `uv run manage.py test submission`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 2: Frontend API Types And Client
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/utils/type.ts`
|
||||
- Modify: `client/src/api.ts`
|
||||
|
||||
- [ ] **Step 1: Add TypeScript types**
|
||||
|
||||
Add:
|
||||
|
||||
```ts
|
||||
export type ItemOrdering = "manual" | "awarded_at" | "score" | "view_count"
|
||||
|
||||
export interface AwardManageIn {
|
||||
name: string
|
||||
description: string
|
||||
sort_order: number
|
||||
is_active: boolean
|
||||
item_ordering: ItemOrdering
|
||||
}
|
||||
|
||||
export interface AwardManageOut extends AwardManageIn {
|
||||
id: number
|
||||
item_count: number
|
||||
}
|
||||
|
||||
export interface AwardItemIn {
|
||||
submission_id: string
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface AwardItemUpdateIn {
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface AwardItemManageOut {
|
||||
id: number
|
||||
submission_id: string
|
||||
username: string
|
||||
task_title: string
|
||||
task_display: number
|
||||
score: number
|
||||
view_count: number
|
||||
sort_order: number
|
||||
awarded_at: string
|
||||
has_prompt_chain: boolean
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend API client**
|
||||
|
||||
Import the new types in `client/src/api.ts` and add `Showcase` methods:
|
||||
|
||||
- `listManageAwards()`
|
||||
- `createAward(payload)`
|
||||
- `updateAward(id, payload)`
|
||||
- `deleteAward(id)`
|
||||
- `listAwardItems(id)`
|
||||
- `addAwardItem(id, payload)`
|
||||
- `updateAwardItem(itemId, payload)`
|
||||
- `deleteAwardItem(itemId)`
|
||||
|
||||
- [ ] **Step 3: Run type/build check**
|
||||
|
||||
Run: `npm run build` from `client/`.
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: Dashboard Route And Page
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/router.ts`
|
||||
- Modify: `client/src/pages/Dashboard.vue`
|
||||
- Create: `client/src/pages/ShowcaseManage.vue`
|
||||
|
||||
- [ ] **Step 1: Add route and menu**
|
||||
|
||||
Add `/dashboard/showcase` child route named `showcase-manage`. Add a Dashboard sidebar item labelled `作品广场`, visible to `admin` and `super`.
|
||||
|
||||
- [ ] **Step 2: Create management page**
|
||||
|
||||
Build `ShowcaseManage.vue` with:
|
||||
|
||||
- Left award list and new award button.
|
||||
- Right award edit form.
|
||||
- Award items table with sortable numeric input and remove button.
|
||||
- Submission search table using existing `Submission.list()`.
|
||||
- Add-to-award button for each searched submission.
|
||||
|
||||
- [ ] **Step 3: Run frontend build**
|
||||
|
||||
Run: `npm run build` from `client/`.
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 4: End-To-End Verification
|
||||
|
||||
**Files:**
|
||||
- No production file edits expected.
|
||||
|
||||
- [ ] **Step 1: Run backend tests**
|
||||
|
||||
Run: `uv run manage.py test submission` from `api/`.
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run frontend build**
|
||||
|
||||
Run: `npm run build` from `client/`.
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Manual smoke path**
|
||||
|
||||
Start the app with existing dev command if needed. Log in as an admin user, visit `/dashboard/showcase`, create an award, add a submission, then visit `/showcase` and confirm the awarded work appears.
|
||||
@@ -0,0 +1,815 @@
|
||||
# Prompt Guidance Feature 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:** Add a "调教提示词" lightbulb button to PromptPanel that opens a modal where the AI coaches students to improve their prompt before generating code.
|
||||
|
||||
**Architecture:** New `GuidanceConsumer` WebSocket at `ws/guidance/<task_id>/` handles coaching conversations, saving messages with `source="guidance"` to the existing DB. Frontend adds `store/guidance.ts` (mirrors `store/prompt.ts` pattern) and `GuidancePanel.vue` (Naive UI modal). `PromptPanel.vue` gets a lightbulb button and wires the generate event.
|
||||
|
||||
**Tech Stack:** Django Channels (AsyncWebsocketConsumer), OpenAI-compatible streaming API, Vue 3, Naive UI, TypeScript
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
**Create:**
|
||||
- `client/src/store/guidance.ts` — WebSocket state for guidance sessions
|
||||
- `client/src/components/ai/GuidancePanel.vue` — Guidance coaching modal
|
||||
|
||||
**Modify:**
|
||||
- `api/prompt/llm.py` — Add `GUIDANCE_SYSTEM_PROMPT`, `parse_guidance_response()`, `stream_guidance()`
|
||||
- `api/prompt/tests.py` — Add unit tests for `parse_guidance_response`
|
||||
- `api/prompt/consumers.py` — Add `GuidanceConsumer`, update import
|
||||
- `api/prompt/url.py` — Register `ws/guidance/<task_id>/`
|
||||
- `client/src/store/prompt.ts` — Export `currentTaskId` ref
|
||||
- `client/src/components/ai/PromptPanel.vue` — Add lightbulb button + GuidancePanel
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — Guidance LLM helpers
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/prompt/llm.py`
|
||||
- Test: `api/prompt/tests.py`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for `parse_guidance_response`**
|
||||
|
||||
Add at the bottom of `api/prompt/tests.py`:
|
||||
|
||||
```python
|
||||
from prompt.llm import parse_guidance_response
|
||||
|
||||
|
||||
class ParseGuidanceResponseTest(TestCase):
|
||||
def test_ready_prefix_with_newline_stripped(self):
|
||||
content, is_ready = parse_guidance_response("[READY]\n很好,可以生成了!")
|
||||
self.assertEqual(content, "很好,可以生成了!")
|
||||
self.assertTrue(is_ready)
|
||||
|
||||
def test_ready_prefix_without_newline_stripped(self):
|
||||
content, is_ready = parse_guidance_response("[READY]很好,可以生成了!")
|
||||
self.assertEqual(content, "很好,可以生成了!")
|
||||
self.assertTrue(is_ready)
|
||||
|
||||
def test_no_ready_prefix_unchanged(self):
|
||||
content, is_ready = parse_guidance_response("你的提示词不够具体,请问...")
|
||||
self.assertEqual(content, "你的提示词不够具体,请问...")
|
||||
self.assertFalse(is_ready)
|
||||
|
||||
def test_empty_string(self):
|
||||
content, is_ready = parse_guidance_response("")
|
||||
self.assertEqual(content, "")
|
||||
self.assertFalse(is_ready)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
```bash
|
||||
cd api && uv run python manage.py test prompt.tests.ParseGuidanceResponseTest -v 2
|
||||
```
|
||||
|
||||
Expected: `ImportError: cannot import name 'parse_guidance_response' from 'prompt.llm'`
|
||||
|
||||
- [ ] **Step 3: Add guidance helpers to `api/prompt/llm.py`**
|
||||
|
||||
Add `GUIDANCE_SYSTEM_PROMPT` after the existing `SYSTEM_PROMPT` block (before `DEFAULT_MODEL = "deepseek-v4-flash"`):
|
||||
|
||||
```python
|
||||
GUIDANCE_SYSTEM_PROMPT = """你是一个提示词写作教练,帮助学生写出清晰、具体的网页需求描述。
|
||||
|
||||
判断标准——满足以下条件视为"够好":
|
||||
- 有明确主题(例如:登录页、计时器、商品卡片列表)
|
||||
- 至少包含以下一项具体描述:颜色/布局/风格、交互行为(按钮点击、动画等)、页面内容(文字、数量、图标)
|
||||
|
||||
判断为"不够好"的典型情况:
|
||||
- 目标太泛,如"做一个好看的页面"
|
||||
- 只有主题,完全没有任何视觉、交互或内容描述
|
||||
|
||||
规则:
|
||||
1. 如果提示词不够好,用 1-2 个启发性问题引导学生补充细节,不要直接给出答案
|
||||
2. 如果提示词已经够好,以 [READY] 开头回复,简短夸奖学生并说明可以生成了
|
||||
3. 用中文回复,语气鼓励,简洁明了
|
||||
4. 不要生成任何代码"""
|
||||
```
|
||||
|
||||
Add `parse_guidance_response` and `stream_guidance` after the existing `extract_code` function:
|
||||
|
||||
```python
|
||||
def parse_guidance_response(full_response: str) -> tuple[str, bool]:
|
||||
if full_response.startswith("[READY]"):
|
||||
return full_response[len("[READY]"):].lstrip("\n"), True
|
||||
return full_response, False
|
||||
|
||||
|
||||
async def stream_guidance(history: list[dict]):
|
||||
"""Stream guidance coaching response. Yields content chunks."""
|
||||
messages = [{"role": "system", "content": GUIDANCE_SYSTEM_PROMPT}]
|
||||
messages.extend(history)
|
||||
client, model = _get_client("")
|
||||
async with client as c:
|
||||
stream = await c.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
stream=True,
|
||||
)
|
||||
async for chunk in stream:
|
||||
delta = chunk.choices[0].delta
|
||||
if delta.content:
|
||||
yield delta.content
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
```bash
|
||||
cd api && uv run python manage.py test prompt.tests.ParseGuidanceResponseTest -v 2
|
||||
```
|
||||
|
||||
Expected: `Ran 4 tests in ...s OK`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git -C /home/xuyue/Projects/webpreview add api/prompt/llm.py api/prompt/tests.py
|
||||
git -C /home/xuyue/Projects/webpreview commit -m "feat: add guidance LLM helpers and system prompt"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Backend — GuidanceConsumer
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/prompt/consumers.py`
|
||||
|
||||
- [ ] **Step 1: Update import at top of `consumers.py`**
|
||||
|
||||
Change:
|
||||
```python
|
||||
from .llm import stream_chat, extract_code
|
||||
```
|
||||
To:
|
||||
```python
|
||||
from .llm import stream_chat, extract_code, stream_guidance, parse_guidance_response
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `GuidanceConsumer` at the bottom of `consumers.py`**
|
||||
|
||||
```python
|
||||
class GuidanceConsumer(AsyncWebsocketConsumer):
|
||||
async def connect(self):
|
||||
self.user = self.scope["user"]
|
||||
if self.user.is_anonymous:
|
||||
await self.close()
|
||||
return
|
||||
|
||||
self.task_id = int(self.scope["url_route"]["kwargs"]["task_id"])
|
||||
self.current_user_message = None
|
||||
self.session_messages = [] # in-memory LLM context, resets each WS connection
|
||||
await self.accept()
|
||||
|
||||
self.conversation = await self.get_or_create_conversation()
|
||||
await self.send(text_data=json.dumps({"type": "init", "messages": []}))
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
if self.current_user_message:
|
||||
await self.delete_message(self.current_user_message)
|
||||
self.current_user_message = None
|
||||
|
||||
async def receive(self, text_data):
|
||||
data = json.loads(text_data)
|
||||
prompt = data.get("content", "").strip()
|
||||
if not prompt:
|
||||
return
|
||||
|
||||
self.current_user_message = await self.save_message("user", prompt)
|
||||
self.session_messages.append({"role": "user", "content": prompt})
|
||||
|
||||
try:
|
||||
full_response = ""
|
||||
try:
|
||||
async for chunk in stream_guidance(self.session_messages):
|
||||
full_response += chunk
|
||||
await self.send(text_data=json.dumps({
|
||||
"type": "stream",
|
||||
"content": chunk,
|
||||
}))
|
||||
except Exception as e:
|
||||
await self.send(text_data=json.dumps({
|
||||
"type": "error",
|
||||
"content": f"AI 服务出错:{str(e)}",
|
||||
}))
|
||||
return
|
||||
|
||||
clean_response, is_ready = parse_guidance_response(full_response)
|
||||
self.session_messages.append({"role": "assistant", "content": clean_response})
|
||||
assistant_msg = await self.save_message("assistant", clean_response)
|
||||
self.current_user_message = None
|
||||
|
||||
await self.send(text_data=json.dumps({
|
||||
"type": "complete",
|
||||
"message_id": assistant_msg.id,
|
||||
"is_ready": is_ready,
|
||||
}))
|
||||
|
||||
finally:
|
||||
if self.current_user_message:
|
||||
await self.delete_message(self.current_user_message)
|
||||
self.current_user_message = None
|
||||
|
||||
@database_sync_to_async
|
||||
def get_or_create_conversation(self):
|
||||
conv = (
|
||||
Conversation.objects.filter(user=self.user, task_id=self.task_id)
|
||||
.annotate(msg_count=Count("messages"))
|
||||
.order_by("-msg_count", "-created")
|
||||
.first()
|
||||
)
|
||||
if not conv:
|
||||
conv = Conversation.objects.create(user=self.user, task_id=self.task_id)
|
||||
return conv
|
||||
|
||||
@database_sync_to_async
|
||||
def delete_message(self, message):
|
||||
message.delete()
|
||||
|
||||
@database_sync_to_async
|
||||
def save_message(self, role, content):
|
||||
return Message.objects.create(
|
||||
conversation=self.conversation,
|
||||
role=role,
|
||||
source="guidance",
|
||||
content=content,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify Django imports are clean**
|
||||
|
||||
```bash
|
||||
cd api && uv run python manage.py check
|
||||
```
|
||||
|
||||
Expected: `System check identified no issues (0 silenced).`
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C /home/xuyue/Projects/webpreview add api/prompt/consumers.py
|
||||
git -C /home/xuyue/Projects/webpreview commit -m "feat: add GuidanceConsumer WebSocket handler"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Backend — Register WebSocket route
|
||||
|
||||
**Files:**
|
||||
- Modify: `api/prompt/url.py`
|
||||
|
||||
- [ ] **Step 1: Replace content of `api/prompt/url.py`**
|
||||
|
||||
```python
|
||||
from django.urls import path
|
||||
from .consumers import PromptConsumer, GuidanceConsumer
|
||||
|
||||
websocket_urlpatterns = [
|
||||
path("ws/prompt/<int:task_id>/", PromptConsumer.as_asgi()),
|
||||
path("ws/guidance/<int:task_id>/", GuidanceConsumer.as_asgi()),
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify routing is clean**
|
||||
|
||||
```bash
|
||||
cd api && uv run python manage.py check
|
||||
```
|
||||
|
||||
Expected: `System check identified no issues (0 silenced).`
|
||||
|
||||
- [ ] **Step 3: Run the full test suite**
|
||||
|
||||
```bash
|
||||
cd api && uv run python manage.py test prompt -v 2
|
||||
```
|
||||
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git -C /home/xuyue/Projects/webpreview add api/prompt/url.py
|
||||
git -C /home/xuyue/Projects/webpreview commit -m "feat: register ws/guidance/<task_id>/ WebSocket route"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Frontend — Export `currentTaskId` from prompt store
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/store/prompt.ts`
|
||||
|
||||
- [ ] **Step 1: Add exported `currentTaskId` ref**
|
||||
|
||||
After this line in `client/src/store/prompt.ts`:
|
||||
```typescript
|
||||
export const streamingContent = ref("")
|
||||
```
|
||||
|
||||
Add:
|
||||
```typescript
|
||||
export const currentTaskId = ref(0)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Keep it in sync in `connectPrompt`**
|
||||
|
||||
In `connectPrompt`, after `_currentTaskId = taskId`, add:
|
||||
```typescript
|
||||
currentTaskId.value = taskId
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git -C /home/xuyue/Projects/webpreview add client/src/store/prompt.ts
|
||||
git -C /home/xuyue/Projects/webpreview commit -m "feat: export currentTaskId from prompt store"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Frontend — Guidance store
|
||||
|
||||
**Files:**
|
||||
- Create: `client/src/store/guidance.ts`
|
||||
|
||||
- [ ] **Step 1: Create `client/src/store/guidance.ts`**
|
||||
|
||||
```typescript
|
||||
import { ref } from "vue"
|
||||
import { WS_BASE_URL } from "../utils/const"
|
||||
|
||||
export interface GuidanceMessage {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
id?: number
|
||||
}
|
||||
|
||||
export const messages = ref<GuidanceMessage[]>([])
|
||||
export const connected = ref(false)
|
||||
export const streaming = ref(false)
|
||||
export const streamingContent = ref("")
|
||||
export const isReady = ref(false)
|
||||
export const isOpen = ref(false)
|
||||
export const initialPrompt = ref("")
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let _taskId = 0
|
||||
|
||||
function _setupHandlers(socket: WebSocket) {
|
||||
socket.onopen = () => {
|
||||
connected.value = true
|
||||
if (initialPrompt.value) {
|
||||
_send(initialPrompt.value)
|
||||
}
|
||||
}
|
||||
socket.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data.type === "init") {
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
} else if (data.type === "stream") {
|
||||
streaming.value = true
|
||||
streamingContent.value += data.content
|
||||
} else if (data.type === "complete") {
|
||||
streaming.value = false
|
||||
const content = streamingContent.value.replace(/^\[READY\]\n?/, "")
|
||||
messages.value.push({ role: "assistant", content, id: data.message_id })
|
||||
streamingContent.value = ""
|
||||
if (data.is_ready) isReady.value = true
|
||||
} else if (data.type === "error") {
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
messages.value.push({ role: "assistant", content: data.content })
|
||||
}
|
||||
}
|
||||
socket.onclose = () => {
|
||||
connected.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function _send(content: string) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
streaming.value = true
|
||||
messages.value.push({ role: "user", content })
|
||||
ws.send(JSON.stringify({ type: "message", content }))
|
||||
}
|
||||
|
||||
export function openGuidance(taskId: number, prompt: string) {
|
||||
_taskId = taskId
|
||||
initialPrompt.value = prompt
|
||||
messages.value = []
|
||||
isReady.value = false
|
||||
streamingContent.value = ""
|
||||
isOpen.value = true
|
||||
|
||||
if (ws) ws.close()
|
||||
ws = new WebSocket(`${WS_BASE_URL}/ws/guidance/${taskId}/`)
|
||||
_setupHandlers(ws)
|
||||
}
|
||||
|
||||
export function sendGuidance(content: string) {
|
||||
_send(content)
|
||||
}
|
||||
|
||||
export function stopGuidance() {
|
||||
if (
|
||||
messages.value.length > 0 &&
|
||||
messages.value[messages.value.length - 1].role === "user"
|
||||
) {
|
||||
messages.value.pop()
|
||||
}
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
if (_taskId) {
|
||||
if (ws) ws.close()
|
||||
ws = new WebSocket(`${WS_BASE_URL}/ws/guidance/${_taskId}/`)
|
||||
initialPrompt.value = "" // don't re-send on reconnect
|
||||
_setupHandlers(ws)
|
||||
}
|
||||
}
|
||||
|
||||
export function closeGuidance() {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
isOpen.value = false
|
||||
messages.value = []
|
||||
isReady.value = false
|
||||
connected.value = false
|
||||
streaming.value = false
|
||||
streamingContent.value = ""
|
||||
initialPrompt.value = ""
|
||||
_taskId = 0
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git -C /home/xuyue/Projects/webpreview add client/src/store/guidance.ts
|
||||
git -C /home/xuyue/Projects/webpreview commit -m "feat: add guidance WebSocket store"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Frontend — GuidancePanel component
|
||||
|
||||
**Files:**
|
||||
- Create: `client/src/components/ai/GuidancePanel.vue`
|
||||
|
||||
- [ ] **Step 1: Create `client/src/components/ai/GuidancePanel.vue`**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<n-modal
|
||||
:show="isOpen"
|
||||
preset="card"
|
||||
title="调教提示词"
|
||||
style="width: 560px"
|
||||
:mask-closable="!streaming"
|
||||
@update:show="onModalClose"
|
||||
>
|
||||
<div class="guidance-body">
|
||||
<div class="guidance-messages" ref="messagesRef">
|
||||
<div
|
||||
v-for="(msg, i) in messages"
|
||||
:key="i"
|
||||
class="guidance-msg"
|
||||
:class="msg.role"
|
||||
>
|
||||
<div class="msg-role">{{ msg.role === "user" ? "你" : "AI" }}</div>
|
||||
<div class="msg-content" v-html="renderMarkdown(msg.content)"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="streaming" class="guidance-msg assistant">
|
||||
<div class="msg-role">AI 教练</div>
|
||||
<div v-if="!displayStreamingContent" class="typing-indicator">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="msg-content"
|
||||
v-html="renderMarkdown(displayStreamingContent)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isReady" class="ready-hint">
|
||||
<Icon icon="lucide:check-circle" :width="16" />
|
||||
提示词已优化,可以生成代码了!
|
||||
</div>
|
||||
|
||||
<div class="guidance-input">
|
||||
<n-input
|
||||
v-model:value="draftPrompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 5 }"
|
||||
placeholder="修改你的提示词后发送..."
|
||||
:disabled="streaming"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
/>
|
||||
<n-flex justify="space-between" align="center" style="margin-top: 8px">
|
||||
<div>
|
||||
<n-button
|
||||
v-if="streaming"
|
||||
type="error"
|
||||
size="small"
|
||||
@click="stopGuidance"
|
||||
>
|
||||
停止
|
||||
</n-button>
|
||||
<n-button
|
||||
v-else
|
||||
size="small"
|
||||
:disabled="!draftPrompt.trim() || !connected"
|
||||
@click="send"
|
||||
>
|
||||
发送
|
||||
</n-button>
|
||||
</div>
|
||||
<n-button
|
||||
:type="isReady ? 'primary' : 'default'"
|
||||
size="small"
|
||||
:disabled="!draftPrompt.trim()"
|
||||
@click="generate"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="lucide:play" :width="14" />
|
||||
</template>
|
||||
生成代码
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</div>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from "vue"
|
||||
import { marked } from "marked"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import {
|
||||
messages,
|
||||
streaming,
|
||||
streamingContent,
|
||||
isReady,
|
||||
isOpen,
|
||||
connected,
|
||||
initialPrompt,
|
||||
sendGuidance,
|
||||
stopGuidance,
|
||||
closeGuidance,
|
||||
} from "../../store/guidance"
|
||||
|
||||
const emit = defineEmits<{
|
||||
generate: [prompt: string]
|
||||
}>()
|
||||
|
||||
const draftPrompt = ref("")
|
||||
const messagesRef = ref<HTMLElement>()
|
||||
|
||||
const displayStreamingContent = computed(() =>
|
||||
streamingContent.value.replace(/^\[READY\]\n?/, "")
|
||||
)
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
return marked.parse(text) as string
|
||||
}
|
||||
|
||||
function send() {
|
||||
const text = draftPrompt.value.trim()
|
||||
if (!text || streaming.value) return
|
||||
sendGuidance(text)
|
||||
}
|
||||
|
||||
function generate() {
|
||||
const text = draftPrompt.value.trim()
|
||||
if (!text) return
|
||||
emit("generate", text)
|
||||
closeGuidance()
|
||||
}
|
||||
|
||||
function onModalClose(show: boolean) {
|
||||
if (!show && !streaming.value) closeGuidance()
|
||||
}
|
||||
|
||||
// Pre-fill draft when panel opens
|
||||
watch(isOpen, (val) => {
|
||||
if (val) draftPrompt.value = initialPrompt.value
|
||||
})
|
||||
|
||||
watch([() => messages.value.length, streamingContent], () => {
|
||||
nextTick(() => {
|
||||
if (messagesRef.value)
|
||||
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.guidance-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.guidance-messages {
|
||||
min-height: 160px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.guidance-msg .msg-role {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.guidance-msg.user .msg-role { color: #2080f0; }
|
||||
.guidance-msg.assistant .msg-role { color: #18a058; }
|
||||
|
||||
.guidance-msg.assistant .msg-content {
|
||||
background: #f0f7ff;
|
||||
border-left: 3px solid #2080f0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.guidance-msg.user .msg-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.ready-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #18a058;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 6px 10px;
|
||||
background: #f0fff4;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #b8e8cc;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #2080f0;
|
||||
animation: bounce 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||
30% { transform: translateY(-5px); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git -C /home/xuyue/Projects/webpreview add client/src/components/ai/GuidancePanel.vue
|
||||
git -C /home/xuyue/Projects/webpreview commit -m "feat: add GuidancePanel coaching modal component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Frontend — Wire GuidancePanel into PromptPanel
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/src/components/ai/PromptPanel.vue`
|
||||
|
||||
- [ ] **Step 1: Add imports to `<script setup>` in `PromptPanel.vue`**
|
||||
|
||||
Add this new import line after the other import statements:
|
||||
|
||||
```typescript
|
||||
import GuidancePanel from "./GuidancePanel.vue"
|
||||
import { openGuidance } from "../../store/guidance"
|
||||
```
|
||||
|
||||
In the existing `import { messages, streaming, ... } from "../../store/prompt"` line, add `currentTaskId` to the list:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
messages,
|
||||
streaming,
|
||||
streamingContent,
|
||||
connected,
|
||||
sendPrompt,
|
||||
stopPrompt,
|
||||
currentTaskId,
|
||||
} from "../../store/prompt"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `startGuidance` and `onGuidanceGenerate` functions**
|
||||
|
||||
Add after the `send` function:
|
||||
|
||||
```typescript
|
||||
function startGuidance() {
|
||||
if (!input.value.trim()) return
|
||||
openGuidance(currentTaskId.value, input.value)
|
||||
}
|
||||
|
||||
function onGuidanceGenerate(finalPrompt: string) {
|
||||
sendPrompt(finalPrompt, selectedModel.value)
|
||||
input.value = ""
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add GuidancePanel to template and lightbulb button to input area**
|
||||
|
||||
In the template, add `<GuidancePanel @generate="onGuidanceGenerate" />` just before `<div class="input-area">`:
|
||||
|
||||
```html
|
||||
<GuidancePanel @generate="onGuidanceGenerate" />
|
||||
<div class="input-area">
|
||||
```
|
||||
|
||||
In the `<n-flex>` inside `.input-area`, add the lightbulb button before the model selector. Replace:
|
||||
|
||||
```html
|
||||
<n-flex justify="flex-end" align="center" style="margin-top: 8px">
|
||||
<n-select
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```html
|
||||
<n-flex justify="flex-end" align="center" style="margin-top: 8px">
|
||||
<n-button
|
||||
secondary
|
||||
:disabled="!input.trim()"
|
||||
title="调教提示词"
|
||||
@click="startGuidance"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="lucide:lightbulb" :width="16" />
|
||||
</template>
|
||||
</n-button>
|
||||
<n-select
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify build**
|
||||
|
||||
```bash
|
||||
cd client && npm run build 2>&1 | tail -30
|
||||
```
|
||||
|
||||
Expected: Build succeeds, no TypeScript errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git -C /home/xuyue/Projects/webpreview add client/src/components/ai/PromptPanel.vue
|
||||
git -C /home/xuyue/Projects/webpreview commit -m "feat: integrate GuidancePanel into PromptPanel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Manual Smoke Test
|
||||
|
||||
After all tasks complete, start both servers and test the full flow:
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
cd api && uv run manage.py runserver
|
||||
|
||||
# Terminal 2
|
||||
cd client && npm run start
|
||||
```
|
||||
|
||||
1. Open a Challenge page
|
||||
2. Type a vague prompt like "做一个好看的页面" in the input
|
||||
3. Click the lightbulb button — guidance modal should open and AI should respond with guiding questions
|
||||
4. Revise the prompt to include specific details
|
||||
5. AI should respond with `[READY]` prefix (stripped) and the "生成代码" button should highlight green
|
||||
6. Click "生成代码" — modal closes, code is generated in the main panel
|
||||
7. Verify guidance messages are saved in Django admin under `prompt > Messages` with `source=guidance`
|
||||
@@ -0,0 +1,474 @@
|
||||
# 等待 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"
|
||||
```
|
||||
@@ -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` 过滤随机作品来源
|
||||
- 排除"当前挑战"的其他提交(已决定不排除)
|
||||
- 跳过按钮(已决定移除,强制打分为唯一主操作)
|
||||
Reference in new issue
Block a user