- 根目录加入 docker-compose、Caddyfile、文档等原本不在仓库里的文件 - 前后端两份 GitHub Actions 合并成根目录的一个 deploy workflow: 构建前端上传到 build/,再在服务器上 git pull 并重建容器 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
42 KiB
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(), andgradebook_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()andGradebook.downloadCsv().
- Adds
- Modify:
client/src/router.ts- Adds
/dashboard/gradebook.
- Adds
- 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:
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:
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
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:
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:
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
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:
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:
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:
GradebookOut,
Add the gradebook service import near the model imports:
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:
@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:
cd /home/xuyue/Projects/webpreview/api
uv run manage.py test submission.GradebookApiTest
Expected: PASS.
- Step 5: Run full submission tests
Run:
cd /home/xuyue/Projects/webpreview/api
uv run manage.py test submission
Expected: PASS.
- Step 6: Commit backend API
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:
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:
GradebookOut,
GradebookQuery,
- Step 3: Add
GradebookAPI object
Add this export in client/src/api.ts after Submission:
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:
cd /home/xuyue/Projects/webpreview/client
npm run build
Expected: PASS.
- Step 5: Commit frontend API
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:
{
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:
{
label: "平时成绩",
route: { name: "gradebook" },
show: roleAdmin.value || roleSuper.value,
},
- Step 3: Create
Gradebook.vuetemplate
Create client/src/pages/Gradebook.vue with this template:
<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.vuescript
Add this script block below the template:
<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.vuestyles
Add this style block at the bottom of client/src/pages/Gradebook.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:
cd /home/xuyue/Projects/webpreview/client
npm run fmt
Expected: PASS and only frontend source formatting changes.
- Step 7: Run frontend build
Run:
cd /home/xuyue/Projects/webpreview/client
npm run build
Expected: PASS.
- Step 8: Commit page
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:
cd /home/xuyue/Projects/webpreview/api
uv run manage.py test submission
Expected: PASS.
- Step 2: Run frontend build
Run:
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:
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:
- Visit
/dashboard/gradebook. - Confirm the first class is selected automatically.
- Switch task type between
全部,教程, and挑战; the task columns change. - Search for a username fragment; the returned rows shrink but ranks remain class-wide.
- Toggle
全部有提交任务; tasks below coverage appear with未计入headers and muted scores. - Click a submitted score; a new tab opens the matching
/submission/:idpage. - Click
导出 CSV; the downloaded CSV columns match the current filters.