Compare commits

..

3 Commits

Author SHA1 Message Date
2abf95888b feat: add admin gradebook page
Some checks failed
Deploy / deploy (build, debian, 22) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822) (push) Has been cancelled
2026-05-02 08:01:15 -06:00
eff635fb49 feat: add gradebook frontend API types 2026-05-02 08:01:04 -06:00
3136be2df7 chore: ignore worktrees 2026-05-01 08:44:59 -06:00
6 changed files with 532 additions and 0 deletions

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules node_modules
dist dist
dist-ssr dist-ssr
.worktrees/
*.local *.local
components.d.ts components.d.ts

View File

@@ -14,6 +14,8 @@ import type {
AwardItemIn, AwardItemIn,
AwardItemUpdateIn, AwardItemUpdateIn,
AwardItemManageOut, AwardItemManageOut,
GradebookOut,
GradebookQuery,
ShowcaseSubmissionLookupOut, ShowcaseSubmissionLookupOut,
ShowcaseDetail, ShowcaseDetail,
PromptRound, PromptRound,
@@ -252,6 +254,50 @@ export const 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)
},
}
export const Prompt = { export const Prompt = {
async listConversations(taskId?: number, userId?: number) { async listConversations(taskId?: number, userId?: number) {
const params: Record<string, number> = {} const params: Record<string, number> = {}

View File

@@ -48,6 +48,11 @@ const menu = computed(() =>
route: { name: "showcase-manage" }, route: { name: "showcase-manage" },
show: roleSuper.value, show: roleSuper.value,
}, },
{
label: "平时成绩",
route: { name: "gradebook" },
show: roleAdmin.value || roleSuper.value,
},
{ {
label: "提交", label: "提交",
route: { name: "submissions", params: { page: 1 } }, route: { name: "submissions", params: { page: 1 } },

423
src/pages/Gradebook.vue Normal file
View File

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

View File

@@ -64,6 +64,11 @@ const routes = [
name: "showcase-manage", name: "showcase-manage",
component: () => import("./pages/ShowcaseManage.vue"), component: () => import("./pages/ShowcaseManage.vue"),
}, },
{
path: "gradebook",
name: "gradebook",
component: () => import("./pages/Gradebook.vue"),
},
], ],
}, },
] ]

View File

@@ -161,6 +161,58 @@ export interface TaskStatsOut {
top_viewed: TopViewedItem[] top_viewed: TopViewedItem[]
} }
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[]
}
export interface ShowcaseItem { export interface ShowcaseItem {
submission_id: string submission_id: string
username: string username: string