Files
web/docs/superpowers/plans/2026-05-01-gradebook.md
xuyueandClaude Opus 5.5 47d4679984 chore: 合并为单仓库,统一部署流程
- 根目录加入 docker-compose、Caddyfile、文档等原本不在仓库里的文件
- 前后端两份 GitHub Actions 合并成根目录的一个 deploy workflow:
  构建前端上传到 build/,再在服务器上 git pull 并重建容器

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:02:12 +08:00

1528 lines
42 KiB
Markdown

# Gradebook 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 an admin gradebook page that computes per-class daily-performance grades across tutorials and challenges, supports coverage-based task inclusion, and exports the visible gradebook as CSV.
**Architecture:** Put the gradebook computation in a focused backend service so the JSON and CSV endpoints share one implementation. Expose admin-only django-ninja routes under `submission/`, then add typed frontend API methods and a Dashboard child page that renders fixed summary columns plus dynamic task columns.
**Tech Stack:** Django, django-ninja, Django TestCase, Python `csv`, Vue 3 Composition API, TypeScript, Naive UI, Axios.
---
## File Structure
- Create: `api/submission/gradebook.py`
- Owns all gradebook query and aggregation logic.
- Exposes `GradebookFilters`, `build_gradebook()`, and `gradebook_csv_rows()`.
- Modify: `api/submission/schemas.py`
- Adds response schemas for gradebook tasks, cells, rows, and whole payload.
- Modify: `api/submission/api.py`
- Adds admin-only JSON and CSV endpoints before dynamic submission-id routes.
- Modify: `api/submission/tests.py`
- Adds API tests for permissions, coverage, highest-score cells, grade boundaries, filtering, and CSV export.
- Modify: `client/src/utils/type.ts`
- Adds TypeScript gradebook query and response types.
- Modify: `client/src/api.ts`
- Adds `Gradebook.get()` and `Gradebook.downloadCsv()`.
- Modify: `client/src/router.ts`
- Adds `/dashboard/gradebook`.
- Modify: `client/src/pages/Dashboard.vue`
- Adds the sidebar entry visible to admin and super users.
- Create: `client/src/pages/Gradebook.vue`
- Implements filters, dynamic table columns, score links, missing/excluded states, and CSV export action.
Implementation note: coverage, included-task totals, ranks, and grades are calculated against the full selected class. The optional `username` filter only filters returned rows after full-class ranks and grades are assigned.
---
### Task 1: Backend Tests
**Files:**
- Modify: `api/submission/tests.py`
- [ ] **Step 1: Extend test helpers**
Update the imports and existing helpers near the top of `api/submission/tests.py`:
```python
import csv
import io
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone
from account.models import RoleChoices
from prompt.models import Conversation, Message
from task.models import Task
from .models import Award, Submission, SubmissionAward
User = get_user_model()
def _make_user(username, role=RoleChoices.NORMAL, classname=""):
user = User.objects.create_user(username=username, password="pw")
user.role = role
user.classname = classname
user.save(update_fields=["role", "classname"])
return user
def _make_task(
title="Test Challenge",
task_type="challenge",
display=1,
is_public=True,
):
return Task.objects.create(
title=title,
task_type=task_type,
display=display,
content="",
is_public=is_public,
)
```
Run: `cd /home/xuyue/Projects/webpreview/api && uv run manage.py test submission`
Expected: PASS. Existing tests should still work because the helper defaults preserve the old call shape.
- [ ] **Step 2: Add failing gradebook API tests**
Append this test class to `api/submission/tests.py`:
```python
class GradebookApiTest(TestCase):
def setUp(self):
self.admin = _make_user("grade-admin", role=RoleChoices.ADMIN)
self.normal = _make_user("grade-normal", classname="blocked")
def _student(self, username, classname="10A"):
return _make_user(username, classname=classname)
def _submit(self, user, task, score, created=None):
submission = Submission.objects.create(
user=user,
task=task,
score=score,
html="",
css="",
js="",
)
if created is not None:
Submission.objects.filter(pk=submission.pk).update(created=created)
submission.refresh_from_db()
return submission
def test_gradebook_requires_classname(self):
self.client.force_login(self.admin)
resp = self.client.get("/api/submission/gradebook/")
self.assertEqual(resp.status_code, 400)
self.assertEqual(resp.json()["detail"], "请选择班级")
def test_normal_user_cannot_access_gradebook(self):
self.client.force_login(self.normal)
resp = self.client.get("/api/submission/gradebook/?classname=10A")
export_resp = self.client.get(
"/api/submission/gradebook/export/?classname=10A"
)
self.assertIn(resp.status_code, (302, 403))
self.assertIn(export_resp.status_code, (302, 403))
def test_coverage_includes_tutorial_and_challenge_without_public_requirement(self):
students = [
self._student("alice"),
self._student("bob"),
self._student("carol"),
self._student("dave"),
]
tutorial = _make_task(
title="Intro",
task_type="tutorial",
display=1,
is_public=False,
)
challenge = _make_task(
title="Challenge One",
task_type="challenge",
display=1,
is_public=True,
)
low_coverage = _make_task(
title="Optional",
task_type="challenge",
display=2,
is_public=True,
)
self._submit(students[0], tutorial, 4.0)
self._submit(students[1], tutorial, 5.0)
self._submit(students[0], challenge, 3.0)
self._submit(students[1], challenge, 4.0)
self._submit(students[0], low_coverage, 5.0)
self.client.force_login(self.admin)
resp = self.client.get("/api/submission/gradebook/?classname=10A")
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data["student_count"], 4)
self.assertEqual(data["coverage_threshold_count"], 2)
self.assertEqual(data["included_task_count"], 2)
self.assertEqual([task["id"] for task in data["tasks"]], [tutorial.id, challenge.id])
self.assertTrue(all(task["included"] for task in data["tasks"]))
alice = next(row for row in data["rows"] if row["username"] == "alice")
carol = next(row for row in data["rows"] if row["username"] == "carol")
self.assertEqual(alice["tutorial_total"], 4.0)
self.assertEqual(alice["challenge_total"], 3.0)
self.assertEqual(alice["total_score"], 7.0)
self.assertEqual(alice["average_score"], 3.5)
self.assertEqual(alice["submitted_task_count"], 2)
self.assertEqual(alice["missing_task_count"], 0)
self.assertEqual(carol["total_score"], 0.0)
self.assertEqual(carol["submitted_task_count"], 0)
self.assertEqual(carol["missing_task_count"], 2)
include_all_resp = self.client.get(
"/api/submission/gradebook/?classname=10A&include_all_tasks=true"
)
include_all = include_all_resp.json()
optional = next(task for task in include_all["tasks"] if task["id"] == low_coverage.id)
alice_all = next(row for row in include_all["rows"] if row["username"] == "alice")
self.assertFalse(optional["included"])
self.assertTrue(alice_all["scores"][str(low_coverage.id)]["submitted"])
self.assertEqual(alice_all["scores"][str(low_coverage.id)]["score"], 5.0)
self.assertEqual(alice_all["total_score"], 7.0)
self.assertEqual(alice_all["submitted_task_count"], 2)
def test_best_submission_uses_highest_score_and_latest_equal_score_link(self):
alice = self._student("alice")
bob = self._student("bob")
task = _make_task(title="Best Score", task_type="tutorial", display=3)
older = timezone.now() - timedelta(days=2)
newer = timezone.now() - timedelta(days=1)
self._submit(alice, task, 2.0)
old_best = self._submit(alice, task, 4.5, created=older)
new_best = self._submit(alice, task, 4.5, created=newer)
self._submit(bob, task, 3.0)
self.client.force_login(self.admin)
resp = self.client.get("/api/submission/gradebook/?classname=10A")
self.assertEqual(resp.status_code, 200)
alice_row = next(row for row in resp.json()["rows"] if row["username"] == "alice")
cell = alice_row["scores"][str(task.id)]
self.assertEqual(cell["score"], 4.5)
self.assertEqual(cell["submission_id"], str(new_best.id))
self.assertNotEqual(cell["submission_id"], str(old_best.id))
def test_task_type_and_username_filters_keep_full_class_rank(self):
alice = self._student("alice")
bob = self._student("bob")
tutorial = _make_task(title="Tutorial", task_type="tutorial", display=1)
challenge = _make_task(title="Challenge", task_type="challenge", display=1)
self._submit(alice, tutorial, 1.0)
self._submit(bob, tutorial, 5.0)
self._submit(alice, challenge, 5.0)
self._submit(bob, challenge, 1.0)
self.client.force_login(self.admin)
resp = self.client.get(
"/api/submission/gradebook/?classname=10A&task_type=tutorial&username=alice"
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual([task["task_type"] for task in data["tasks"]], ["tutorial"])
self.assertEqual(len(data["rows"]), 1)
self.assertEqual(data["rows"][0]["username"], "alice")
self.assertEqual(data["rows"][0]["rank"], 2)
def test_missing_class_returns_empty_table_with_class_options(self):
self._student("alice")
self.client.force_login(self.admin)
resp = self.client.get("/api/submission/gradebook/?classname=missing")
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertIn("10A", data["classes"])
self.assertEqual(data["student_count"], 0)
self.assertEqual(data["coverage_threshold_count"], 0)
self.assertEqual(data["tasks"], [])
self.assertEqual(data["rows"], [])
def test_no_included_tasks_still_returns_student_rows(self):
students = [
self._student("alice"),
self._student("bob"),
self._student("carol"),
]
optional = _make_task(title="Low Coverage", task_type="challenge", display=8)
self._submit(students[0], optional, 5.0)
self.client.force_login(self.admin)
resp = self.client.get("/api/submission/gradebook/?classname=10A")
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data["student_count"], 3)
self.assertEqual(data["coverage_threshold_count"], 2)
self.assertEqual(data["tasks"], [])
self.assertEqual(data["included_task_count"], 0)
self.assertEqual(len(data["rows"]), 3)
alice = next(row for row in data["rows"] if row["username"] == "alice")
self.assertEqual(alice["total_score"], 0.0)
self.assertIsNone(alice["average_score"])
self.assertEqual(alice["submitted_task_count"], 0)
self.assertEqual(alice["missing_task_count"], 0)
include_all_resp = self.client.get(
"/api/submission/gradebook/?classname=10A&include_all_tasks=true"
)
include_all = include_all_resp.json()
self.assertEqual(include_all["task_count"], 1)
self.assertFalse(include_all["tasks"][0]["included"])
alice_all = next(row for row in include_all["rows"] if row["username"] == "alice")
self.assertTrue(alice_all["scores"][str(optional.id)]["submitted"])
self.assertEqual(alice_all["total_score"], 0.0)
def test_grade_boundaries_use_ceil_thresholds(self):
task = _make_task(title="Boundary", task_type="challenge", display=7)
for i in range(1, 21):
student = self._student(f"s{i:02d}", classname="10B")
self._submit(student, task, float(21 - i))
self.client.force_login(self.admin)
resp = self.client.get("/api/submission/gradebook/?classname=10B")
self.assertEqual(resp.status_code, 200)
rows_by_name = {row["username"]: row for row in resp.json()["rows"]}
self.assertEqual(rows_by_name["s01"]["grade"], "A")
self.assertEqual(rows_by_name["s06"]["grade"], "A")
self.assertEqual(rows_by_name["s07"]["grade"], "B")
self.assertEqual(rows_by_name["s14"]["grade"], "B")
self.assertEqual(rows_by_name["s15"]["grade"], "C")
self.assertEqual(rows_by_name["s18"]["grade"], "C")
self.assertEqual(rows_by_name["s19"]["grade"], "D")
self.assertEqual(rows_by_name["s20"]["grade"], "E")
def test_export_csv_matches_current_filters(self):
alice = self._student("alice")
bob = self._student("bob")
tutorial = _make_task(title="Intro", task_type="tutorial", display=1)
challenge = _make_task(title="Challenge", task_type="challenge", display=1)
self._submit(alice, tutorial, 4.0)
self._submit(bob, tutorial, 2.0)
self._submit(alice, challenge, 5.0)
self.client.force_login(self.admin)
resp = self.client.get(
"/api/submission/gradebook/export/?classname=10A&task_type=tutorial"
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp["Content-Type"], "text/csv; charset=utf-8")
self.assertIn("attachment;", resp["Content-Disposition"])
rows = list(csv.reader(io.StringIO(resp.content.decode("utf-8-sig"))))
self.assertEqual(
rows[0],
[
"排名",
"等级",
"用户名",
"班级",
"教程1-Intro",
"教程合计",
"挑战合计",
"总分",
"平均分",
"已提交任务数",
"未提交任务数",
],
)
self.assertEqual(rows[1][2], "alice")
self.assertEqual(rows[1][4], "4")
self.assertEqual(rows[1][7], "4")
```
Run: `cd /home/xuyue/Projects/webpreview/api && uv run manage.py test submission.GradebookApiTest`
Expected: FAIL because `/api/submission/gradebook/` and `/api/submission/gradebook/export/` are not implemented.
- [ ] **Step 3: Commit tests**
```bash
git add api/submission/tests.py
git commit -m "test: add gradebook API coverage"
```
---
### Task 2: Backend Gradebook Builder
**Files:**
- Create: `api/submission/gradebook.py`
- [ ] **Step 1: Create the gradebook service**
Create `api/submission/gradebook.py` with this content:
```python
from dataclasses import dataclass
from math import ceil
from typing import Literal
from django.db.models import Count
from ninja.errors import HttpError
from account.models import RoleChoices, User
from task.models import Task, TaskTypeChoices
from .models import Submission
GradebookTaskType = Literal["tutorial", "challenge"]
@dataclass(frozen=True)
class GradebookFilters:
classname: str
task_type: GradebookTaskType | None = None
username: str | None = None
include_all_tasks: bool = False
def _score(value):
rounded = round(float(value or 0), 2)
if rounded == 0:
return 0.0
return rounded
def _grade_for_rank(rank: int, student_count: int) -> str:
if rank <= ceil(0.30 * student_count):
return "A"
if rank <= ceil(0.70 * student_count):
return "B"
if rank <= ceil(0.90 * student_count):
return "C"
if rank <= ceil(0.95 * student_count):
return "D"
return "E"
def _task_sort_key(task):
type_order = 0 if task.task_type == TaskTypeChoices.TUTORIAL else 1
return (type_order, task.display, task.id)
def _task_type_label(task_type: str) -> str:
return "教程" if task_type == TaskTypeChoices.TUTORIAL else "挑战"
def _csv_number(value):
if value == "":
return ""
number = float(value)
if number.is_integer():
return str(int(number))
return f"{number:.2f}".rstrip("0").rstrip(".")
def _classes():
return list(
User.objects.filter(role=RoleChoices.NORMAL)
.exclude(classname="")
.values_list("classname", flat=True)
.distinct()
.order_by("classname")
)
def _task_csv_header(task):
return f"{_task_type_label(task['task_type'])}{task['display']}-{task['title']}"
def build_gradebook(filters: GradebookFilters):
classname = filters.classname.strip() if filters.classname else ""
if not classname:
raise HttpError(400, "请选择班级")
if filters.task_type not in (None, "tutorial", "challenge"):
raise HttpError(400, "无效的任务类型")
classes = _classes()
class_students = list(
User.objects.filter(role=RoleChoices.NORMAL, classname=classname)
.order_by("username", "id")
.only("id", "username", "classname")
)
class_student_ids = [student.id for student in class_students]
student_count = len(class_student_ids)
coverage_threshold_count = ceil(student_count * 0.5) if student_count else 0
task_submission_qs = Submission.objects.filter(user_id__in=class_student_ids)
if filters.task_type:
task_submission_qs = task_submission_qs.filter(task__task_type=filters.task_type)
submitted_counts = {
row["task_id"]: row["submitted_count"]
for row in task_submission_qs.values("task_id").annotate(
submitted_count=Count("user_id", distinct=True)
)
}
task_map = {
task.id: task
for task in Task.objects.filter(id__in=submitted_counts.keys()).only(
"id",
"display",
"title",
"task_type",
)
}
all_tasks = []
for task in sorted(task_map.values(), key=_task_sort_key):
submitted_count = submitted_counts[task.id]
included = student_count > 0 and submitted_count >= coverage_threshold_count
all_tasks.append(
{
"id": task.id,
"display": task.display,
"title": task.title,
"task_type": task.task_type,
"submitted_count": submitted_count,
"coverage": _score(submitted_count / student_count)
if student_count
else 0.0,
"included": included,
}
)
tasks = [
task
for task in all_tasks
if filters.include_all_tasks or task["included"]
]
visible_task_ids = [task["id"] for task in tasks]
included_task_ids = {task["id"] for task in tasks if task["included"]}
best_by_pair = {}
if class_student_ids and visible_task_ids:
submissions = (
Submission.objects.filter(
user_id__in=class_student_ids,
task_id__in=visible_task_ids,
)
.select_related("task")
.only("id", "user_id", "task_id", "score", "created", "task__task_type")
.order_by("user_id", "task_id", "-score", "-created", "-id")
)
for submission in submissions:
best_by_pair.setdefault(
(submission.user_id, submission.task_id),
submission,
)
rows = []
for student in class_students:
scores = {}
tutorial_total = 0.0
challenge_total = 0.0
submitted_task_count = 0
missing_task_count = 0
for task in tasks:
submission = best_by_pair.get((student.id, task["id"]))
if submission:
cell_score = _score(submission.score)
scores[task["id"]] = {
"score": cell_score,
"submitted": True,
"submission_id": submission.id,
}
else:
cell_score = 0.0
scores[task["id"]] = {
"score": 0.0,
"submitted": False,
"submission_id": None,
}
if not task["included"]:
continue
if submission:
submitted_task_count += 1
else:
missing_task_count += 1
if task["task_type"] == TaskTypeChoices.TUTORIAL:
tutorial_total += cell_score
else:
challenge_total += cell_score
tutorial_total = _score(tutorial_total)
challenge_total = _score(challenge_total)
total_score = _score(tutorial_total + challenge_total)
average_score = (
_score(total_score / len(included_task_ids))
if included_task_ids
else None
)
rows.append(
{
"user_id": student.id,
"username": student.username,
"classname": student.classname,
"rank": 0,
"grade": "E",
"scores": scores,
"tutorial_total": tutorial_total,
"challenge_total": challenge_total,
"total_score": total_score,
"average_score": average_score,
"submitted_task_count": submitted_task_count,
"missing_task_count": missing_task_count,
}
)
rows.sort(key=lambda row: (-row["total_score"], row["username"]))
for index, row in enumerate(rows, start=1):
row["rank"] = index
row["grade"] = _grade_for_rank(index, student_count)
username = filters.username.strip().lower() if filters.username else ""
if username:
rows = [row for row in rows if username in row["username"].lower()]
return {
"classname": classname,
"classes": classes,
"task_count": len(tasks),
"included_task_count": len(included_task_ids),
"student_count": student_count,
"coverage_threshold_count": coverage_threshold_count,
"tasks": tasks,
"rows": rows,
}
def gradebook_csv_rows(gradebook):
tasks = gradebook["tasks"]
yield [
"排名",
"等级",
"用户名",
"班级",
*[_task_csv_header(task) for task in tasks],
"教程合计",
"挑战合计",
"总分",
"平均分",
"已提交任务数",
"未提交任务数",
]
for row in gradebook["rows"]:
yield [
row["rank"],
row["grade"],
row["username"],
row["classname"],
*[_csv_number(row["scores"][task["id"]]["score"]) for task in tasks],
_csv_number(row["tutorial_total"]),
_csv_number(row["challenge_total"]),
_csv_number(row["total_score"]),
"" if row["average_score"] is None else _csv_number(row["average_score"]),
row["submitted_task_count"],
row["missing_task_count"],
]
```
- [ ] **Step 2: Verify service imports**
Run:
```bash
cd /home/xuyue/Projects/webpreview/api
uv run python -c "from submission.gradebook import GradebookFilters, build_gradebook, gradebook_csv_rows; print('OK')"
```
Expected output: `OK`
- [ ] **Step 3: Commit service**
```bash
git add api/submission/gradebook.py
git commit -m "feat: add gradebook aggregation service"
```
---
### Task 3: Backend Schemas And Routes
**Files:**
- Modify: `api/submission/schemas.py`
- Modify: `api/submission/api.py`
- Test: `api/submission/tests.py`
- [ ] **Step 1: Add gradebook schemas**
In `api/submission/schemas.py`, keep the existing `from typing import Optional, Literal` import and append these classes after `TaskStatsOut`:
```python
class GradebookTask(Schema):
id: int
display: int
title: str
task_type: Literal["tutorial", "challenge"]
submitted_count: int
coverage: float
included: bool
class GradebookCell(Schema):
score: float
submitted: bool
submission_id: Optional[UUID] = None
class GradebookRow(Schema):
user_id: int
username: str
classname: str
rank: int
grade: Literal["A", "B", "C", "D", "E"]
scores: dict[int, GradebookCell]
tutorial_total: float
challenge_total: float
total_score: float
average_score: Optional[float]
submitted_task_count: int
missing_task_count: int
class GradebookOut(Schema):
classname: str
classes: list[str]
task_count: int
included_task_count: int
student_count: int
coverage_threshold_count: int
tasks: list[GradebookTask]
rows: list[GradebookRow]
```
- [ ] **Step 2: Add imports in `api/submission/api.py`**
Update the top of `api/submission/api.py`:
```python
import csv
import threading
from typing import List, Literal, Optional
from urllib.parse import quote
from uuid import UUID
from django.http import HttpResponse
```
Add `GradebookOut` to the `.schemas` import list:
```python
GradebookOut,
```
Add the gradebook service import near the model imports:
```python
from .gradebook import GradebookFilters, build_gradebook, gradebook_csv_rows
```
- [ ] **Step 3: Add JSON and CSV endpoints**
Insert these routes in `api/submission/api.py` after `get_task_stats()` and before `/showcase/` routes:
```python
@router.get("/gradebook/", response=GradebookOut)
@admin_required
def get_gradebook(
request,
classname: str = "",
task_type: Optional[Literal["tutorial", "challenge"]] = None,
username: Optional[str] = None,
include_all_tasks: bool = False,
):
return build_gradebook(
GradebookFilters(
classname=classname,
task_type=task_type,
username=username,
include_all_tasks=include_all_tasks,
)
)
@router.get("/gradebook/export/")
@admin_required
def export_gradebook(
request,
classname: str = "",
task_type: Optional[Literal["tutorial", "challenge"]] = None,
username: Optional[str] = None,
include_all_tasks: bool = False,
):
gradebook = build_gradebook(
GradebookFilters(
classname=classname,
task_type=task_type,
username=username,
include_all_tasks=include_all_tasks,
)
)
response = HttpResponse(content_type="text/csv; charset=utf-8")
filename = f"gradebook-{gradebook['classname']}.csv"
response["Content-Disposition"] = (
f"attachment; filename*=UTF-8''{quote(filename)}"
)
response.write("\ufeff")
writer = csv.writer(response)
for row in gradebook_csv_rows(gradebook):
writer.writerow(row)
return response
```
- [ ] **Step 4: Run backend tests**
Run:
```bash
cd /home/xuyue/Projects/webpreview/api
uv run manage.py test submission.GradebookApiTest
```
Expected: PASS.
- [ ] **Step 5: Run full submission tests**
Run:
```bash
cd /home/xuyue/Projects/webpreview/api
uv run manage.py test submission
```
Expected: PASS.
- [ ] **Step 6: Commit backend API**
```bash
git add api/submission/schemas.py api/submission/api.py api/submission/tests.py
git commit -m "feat: expose gradebook API and CSV export"
```
---
### Task 4: Frontend Types And API Client
**Files:**
- Modify: `client/src/utils/type.ts`
- Modify: `client/src/api.ts`
- [ ] **Step 1: Add gradebook TypeScript types**
Append these types to `client/src/utils/type.ts`:
```ts
export type GradebookTaskType = "tutorial" | "challenge"
export type GradebookGrade = "A" | "B" | "C" | "D" | "E"
export interface GradebookQuery {
classname: string
task_type?: GradebookTaskType | ""
username?: string
include_all_tasks?: boolean
}
export interface GradebookTask {
id: number
display: number
title: string
task_type: GradebookTaskType
submitted_count: number
coverage: number
included: boolean
}
export interface GradebookCell {
score: number
submitted: boolean
submission_id: string | null
}
export interface GradebookRow {
user_id: number
username: string
classname: string
rank: number
grade: GradebookGrade
scores: Record<number, GradebookCell>
tutorial_total: number
challenge_total: number
total_score: number
average_score: number | null
submitted_task_count: number
missing_task_count: number
}
export interface GradebookOut {
classname: string
classes: string[]
task_count: number
included_task_count: number
student_count: number
coverage_threshold_count: number
tasks: GradebookTask[]
rows: GradebookRow[]
}
```
- [ ] **Step 2: Import gradebook types in API client**
In `client/src/api.ts`, add these names to the existing type import:
```ts
GradebookOut,
GradebookQuery,
```
- [ ] **Step 3: Add `Gradebook` API object**
Add this export in `client/src/api.ts` after `Submission`:
```ts
function gradebookParams(query: GradebookQuery) {
const params: Record<string, string | boolean> = {
classname: query.classname,
}
if (query.task_type) params.task_type = query.task_type
if (query.username) params.username = query.username
if (query.include_all_tasks) params.include_all_tasks = true
return params
}
function filenameFromDisposition(disposition: string | undefined, fallback: string) {
const match = disposition?.match(/filename\*=UTF-8''([^;]+)/)
return match ? decodeURIComponent(match[1]) : fallback
}
export const Gradebook = {
async get(query: GradebookQuery): Promise<GradebookOut> {
const res = await http.get("/submission/gradebook/", {
params: gradebookParams(query),
})
return res.data
},
async downloadCsv(query: GradebookQuery) {
const res = await http.get("/submission/gradebook/export/", {
params: gradebookParams(query),
responseType: "blob",
})
const blob = new Blob([res.data], { type: "text/csv;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filenameFromDisposition(
res.headers["content-disposition"],
`gradebook-${query.classname}.csv`,
)
a.click()
URL.revokeObjectURL(url)
},
}
```
- [ ] **Step 4: Run frontend build**
Run:
```bash
cd /home/xuyue/Projects/webpreview/client
npm run build
```
Expected: PASS.
- [ ] **Step 5: Commit frontend API**
```bash
git add client/src/utils/type.ts client/src/api.ts
git commit -m "feat: add gradebook frontend API types"
```
---
### Task 5: Dashboard Route And Gradebook Page
**Files:**
- Modify: `client/src/router.ts`
- Modify: `client/src/pages/Dashboard.vue`
- Create: `client/src/pages/Gradebook.vue`
- [ ] **Step 1: Add dashboard child route**
In `client/src/router.ts`, add this child route under `/dashboard`:
```ts
{
path: "gradebook",
name: "gradebook",
component: () => import("./pages/Gradebook.vue"),
},
```
- [ ] **Step 2: Add dashboard sidebar entry**
In `client/src/pages/Dashboard.vue`, add this menu item before the `"提交"` item:
```ts
{
label: "平时成绩",
route: { name: "gradebook" },
show: roleAdmin.value || roleSuper.value,
},
```
- [ ] **Step 3: Create `Gradebook.vue` template**
Create `client/src/pages/Gradebook.vue` with this template:
```vue
<template>
<n-flex vertical class="gradebook-page" :size="12">
<n-flex class="toolbar" align="center" justify="space-between">
<n-flex align="center" :size="8" class="filters">
<n-select
v-model:value="query.classname"
class="class-select"
:options="classOptions"
placeholder="班级"
:loading="classesLoading"
/>
<n-select
v-model:value="query.task_type"
class="type-select"
:options="taskTypeOptions"
/>
<n-input
v-model:value="query.username"
class="search-input"
clearable
placeholder="学生搜索"
/>
<n-switch v-model:value="query.include_all_tasks">
<template #checked>全部有提交任务</template>
<template #unchecked>只看计入任务</template>
</n-switch>
</n-flex>
<n-flex align="center" :size="8">
<n-button
secondary
title="刷新"
:disabled="!query.classname"
:loading="loading"
@click="loadGradebook"
>
<template #icon>
<Icon icon="lucide:refresh-cw" :width="15" />
</template>
</n-button>
<n-button
type="primary"
secondary
:disabled="!query.classname || !gradebook"
:loading="exporting"
@click="exportCsv"
>
<template #icon>
<Icon icon="lucide:download" :width="15" />
</template>
导出 CSV
</n-button>
</n-flex>
</n-flex>
<n-alert v-if="loadError" type="error" closable @close="loadError = ''">
{{ loadError }}
</n-alert>
<n-flex v-if="gradebook" class="summary" align="center" :size="8">
<n-tag size="small">学生 {{ gradebook.student_count }}</n-tag>
<n-tag size="small">任务 {{ gradebook.task_count }}</n-tag>
<n-tag size="small" type="success">
计入 {{ gradebook.included_task_count }}
</n-tag>
<n-tag size="small">
覆盖门槛 {{ gradebook.coverage_threshold_count }} 人
</n-tag>
</n-flex>
<n-data-table
class="gradebook-table"
size="small"
striped
flex-height
:loading="loading"
:columns="columns"
:data="rows"
:row-key="(row: GradebookRow) => row.user_id"
:scroll-x="scrollX"
/>
</n-flex>
</template>
```
- [ ] **Step 4: Add `Gradebook.vue` script**
Add this script block below the template:
```vue
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref, watch } from "vue"
import { Icon } from "@iconify/vue"
import { watchDebounced } from "@vueuse/core"
import {
NButton,
NTag,
NText,
useMessage,
type DataTableColumn,
} from "naive-ui"
import { useRouter } from "vue-router"
import { Account, Gradebook } from "../api"
import type {
GradebookCell,
GradebookOut,
GradebookQuery,
GradebookRow,
GradebookTask,
GradebookTaskType,
} from "../utils/type"
const router = useRouter()
const message = useMessage()
const classesLoading = ref(false)
const loading = ref(false)
const exporting = ref(false)
const loadError = ref("")
const gradebook = ref<GradebookOut | null>(null)
const classes = ref<string[]>([])
const query = reactive<GradebookQuery>({
classname: "",
task_type: "",
username: "",
include_all_tasks: false,
})
const taskTypeOptions: { label: string; value: GradebookTaskType | "" }[] = [
{ label: "全部", value: "" },
{ label: "教程", value: "tutorial" },
{ label: "挑战", value: "challenge" },
]
const classOptions = computed(() =>
classes.value.map((classname) => ({ label: classname, value: classname })),
)
const rows = computed(() => gradebook.value?.rows ?? [])
const scrollX = computed(() => 860 + (gradebook.value?.tasks.length ?? 0) * 96)
function formatScore(value: number | null) {
if (value === null) return "-"
return Number.isInteger(value) ? String(value) : value.toFixed(2)
}
function taskTitle(task: GradebookTask) {
const typeLabel = task.task_type === "tutorial" ? "教程" : "挑战"
return `${typeLabel}${task.display}`
}
function openSubmission(cell: GradebookCell) {
if (!cell.submitted || !cell.submission_id) return
const { href } = router.resolve({
name: "submission",
params: { id: cell.submission_id },
})
window.open(href, "_blank")
}
function gradeTagType(grade: string) {
if (grade === "A") return "success"
if (grade === "B") return "info"
if (grade === "C") return "default"
return "warning"
}
function renderTaskHeader(task: GradebookTask) {
return h("div", { class: ["task-header", { muted: !task.included }] }, [
h("div", { class: "task-title", title: task.title }, taskTitle(task)),
h("div", { class: "task-meta" }, [
h("span", `${Math.round(task.coverage * 100)}%`),
task.included
? null
: h(
NTag,
{ size: "tiny", round: false },
{ default: () => "未计入" },
),
]),
])
}
function renderScore(row: GradebookRow, task: GradebookTask) {
const cell = row.scores[task.id]
if (!cell || !cell.submitted) {
return h("span", { class: "missing-cell" }, "缺交")
}
return h(
NButton,
{
text: true,
type: task.included ? "primary" : "default",
class: ["score-link", { muted: !task.included }],
onClick: (event: MouseEvent) => {
event.stopPropagation()
openSubmission(cell)
},
},
{ default: () => formatScore(cell.score) },
)
}
const columns = computed<DataTableColumn<GradebookRow>[]>(() => {
const tasks = gradebook.value?.tasks ?? []
return [
{
title: "排名",
key: "rank",
width: 66,
fixed: "left",
},
{
title: "等级",
key: "grade",
width: 66,
fixed: "left",
render: (row) =>
h(
NTag,
{ size: "small", type: gradeTagType(row.grade) },
{ default: () => row.grade },
),
},
{
title: "学生",
key: "username",
width: 140,
fixed: "left",
render: (row) =>
h(NText, { title: row.username }, { default: () => row.username }),
},
{
title: "班级",
key: "classname",
width: 90,
fixed: "left",
},
...tasks.map((task) => ({
title: () => renderTaskHeader(task),
key: `task-${task.id}`,
width: 96,
align: "center" as const,
className: task.included ? "" : "excluded-task-column",
render: (row: GradebookRow) => renderScore(row, task),
})),
{
title: "教程合计",
key: "tutorial_total",
width: 92,
fixed: "right",
render: (row) => formatScore(row.tutorial_total),
},
{
title: "挑战合计",
key: "challenge_total",
width: 92,
fixed: "right",
render: (row) => formatScore(row.challenge_total),
},
{
title: "总分",
key: "total_score",
width: 82,
fixed: "right",
render: (row) => formatScore(row.total_score),
},
{
title: "平均",
key: "average_score",
width: 82,
fixed: "right",
render: (row) => formatScore(row.average_score),
},
{
title: "已交",
key: "submitted_task_count",
width: 70,
fixed: "right",
},
{
title: "缺交",
key: "missing_task_count",
width: 70,
fixed: "right",
},
]
})
async function loadClasses() {
classesLoading.value = true
try {
classes.value = await Account.listClasses()
if (!query.classname && classes.value.length > 0) {
query.classname = classes.value[0]
}
} finally {
classesLoading.value = false
}
}
async function loadGradebook() {
if (!query.classname) {
gradebook.value = null
return
}
loading.value = true
loadError.value = ""
try {
gradebook.value = await Gradebook.get(query)
classes.value = gradebook.value.classes
} catch (err: any) {
loadError.value = err.response?.data?.detail ?? "成绩册加载失败"
} finally {
loading.value = false
}
}
async function exportCsv() {
if (!query.classname) return
exporting.value = true
try {
await Gradebook.downloadCsv(query)
} catch (err: any) {
message.error(err.response?.data?.detail ?? "导出失败")
} finally {
exporting.value = false
}
}
watch(
() => [query.classname, query.task_type, query.include_all_tasks],
() => loadGradebook(),
)
watchDebounced(
() => query.username,
() => loadGradebook(),
{ debounce: 400, maxWait: 1000 },
)
onMounted(async () => {
await loadClasses()
await loadGradebook()
})
</script>
```
- [ ] **Step 5: Add `Gradebook.vue` styles**
Add this style block at the bottom of `client/src/pages/Gradebook.vue`:
```vue
<style scoped>
.gradebook-page {
height: 100%;
min-width: 0;
box-sizing: border-box;
padding: 10px 10px 10px 0;
overflow: hidden;
}
.toolbar,
.summary {
flex-shrink: 0;
}
.filters {
min-width: 0;
flex-wrap: wrap;
}
.class-select {
width: 150px;
}
.type-select {
width: 112px;
}
.search-input {
width: 160px;
}
.gradebook-table {
flex: 1;
min-height: 0;
}
.task-header {
min-width: 0;
line-height: 1.2;
}
.task-header.muted {
color: #888;
}
.task-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}
.task-meta {
display: flex;
min-height: 18px;
align-items: center;
justify-content: center;
gap: 4px;
margin-top: 2px;
color: #999;
font-size: 11px;
}
.missing-cell {
color: #d03050;
font-size: 12px;
}
.score-link.muted {
color: #777;
}
:deep(.excluded-task-column) {
background: #fafafa;
}
@media (max-width: 720px) {
.toolbar {
align-items: flex-start;
}
.class-select,
.type-select,
.search-input {
width: 100%;
}
}
</style>
```
- [ ] **Step 6: Run frontend format**
Run:
```bash
cd /home/xuyue/Projects/webpreview/client
npm run fmt
```
Expected: PASS and only frontend source formatting changes.
- [ ] **Step 7: Run frontend build**
Run:
```bash
cd /home/xuyue/Projects/webpreview/client
npm run build
```
Expected: PASS.
- [ ] **Step 8: Commit page**
```bash
git add client/src/router.ts client/src/pages/Dashboard.vue client/src/pages/Gradebook.vue
git commit -m "feat: add admin gradebook page"
```
---
### Task 6: End-To-End Verification
**Files:**
- No production file edits expected.
- [ ] **Step 1: Run backend tests**
Run:
```bash
cd /home/xuyue/Projects/webpreview/api
uv run manage.py test submission
```
Expected: PASS.
- [ ] **Step 2: Run frontend build**
Run:
```bash
cd /home/xuyue/Projects/webpreview/client
npm run build
```
Expected: PASS.
- [ ] **Step 3: Start local app**
Run the full stack if it is not already running:
```bash
cd /home/xuyue/Projects/webpreview
./dev.sh
```
Expected: Django and Rsbuild dev servers start without errors.
- [ ] **Step 4: Manual smoke test**
Use an admin or super account:
1. Visit `/dashboard/gradebook`.
2. Confirm the first class is selected automatically.
3. Switch task type between `全部`, `教程`, and `挑战`; the task columns change.
4. Search for a username fragment; the returned rows shrink but ranks remain class-wide.
5. Toggle `全部有提交任务`; tasks below coverage appear with `未计入` headers and muted scores.
6. Click a submitted score; a new tab opens the matching `/submission/:id` page.
7. Click `导出 CSV`; the downloaded CSV columns match the current filters.