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

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

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

No files matched your search

@@ -0,0 +1,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"
```