diff --git a/client/src/pages/Gradebook.vue b/client/src/pages/Gradebook.vue index 9a758f5..dd70a30 100644 --- a/client/src/pages/Gradebook.vue +++ b/client/src/pages/Gradebook.vue @@ -1,95 +1,116 @@ @@ -98,13 +119,7 @@ import ReviewPanel from "../components/review/ReviewPanel.vue" import { computed, h, onMounted, reactive, ref, watch } from "vue" import { Icon } from "../icons" import { watchDebounced } from "@vueuse/core" -import { - NButton, - NTag, - NText, - useMessage, - type DataTableColumn, -} from "naive-ui" +import { useMessage, type DataTableColumn } from "naive-ui" import { Account, Gradebook } from "../api" import { displayGradebookStudentName } from "../utils/gradebook" import type { @@ -113,9 +128,11 @@ import type { GradebookQuery, GradebookRow, GradebookTask, - GradebookTaskType, } from "../utils/type" +// 和批改页共用“当前班级”(那边的“全部班级”在这里不适用) +const CLASS_KEY = "review-class" + const message = useMessage() const classesLoading = ref(false) const loading = ref(false) @@ -131,28 +148,19 @@ const query = reactive({ 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 })), + classes.value.map((classname) => ({ label: `${classname} 班`, value: classname })), ) const rows = computed(() => gradebook.value?.rows ?? []) -const scrollX = computed(() => 1040 + (gradebook.value?.tasks.length ?? 0) * 96) + +const TASK_WIDTH = 66 +const scrollX = computed(() => 380 + (gradebook.value?.tasks.length ?? 0) * TASK_WIDTH) function formatScore(value: number | null) { - if (value === null) return "-" + 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}` -} - // 点分数:在右侧打开和批改页一样的详情,能直接打分、看提示词 const reviewId = ref(null) function openSubmission(cell: GradebookCell) { @@ -160,152 +168,135 @@ function openSubmission(cell: GradebookCell) { reviewId.value = cell.submission_id } -function gradeTagType(grade: string) { - if (grade === "A") return "success" - if (grade === "B") return "info" - if (grade === "C") return "default" - return "warning" +function renderGrade(grade: string, extra?: string | number) { + return h("span", { class: ["grade", `g-${grade}`] }, [ + grade, + extra === undefined ? null : h("small", String(extra)), + ]) } 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: () => "未计入" }), - ]), - ]) + const pct = Math.round(task.coverage * 100) + const studentCount = gradebook.value?.student_count ?? 0 + return h( + "div", + { + class: ["task-head", { excluded: !task.included }], + title: `${task.display}. ${task.title}\n交了 ${task.submitted_count} / ${studentCount} 人(${pct}%)${task.included ? "" : ",不计入"}`, + }, + [ + h("div", { class: "task-name" }, task.title), + h("div", { class: "task-cov" }, [ + h("span", { class: "cov-bar" }, [h("span", { style: { width: pct + "%" } })]), + ]), + ], + ) } function renderScore(row: GradebookRow, task: GradebookTask) { const cell = row.scores[task.id] - if (!cell || !cell.submitted) { - return h( - NText, - { class: "missing-cell", type: "error" }, - { default: () => "缺交" }, - ) - } + if (!cell?.submitted) + return h("span", { class: ["cell", "missing", { excluded: !task.included }] }, "缺") return h( - NButton, + "button", { - text: true, - type: task.included ? "primary" : "default", - class: ["score-link", { muted: !task.included }], - onClick: (event: MouseEvent) => { - event.stopPropagation() - openSubmission(cell) - }, + class: ["cell", "score", { unrated: cell.score === 0, excluded: !task.included }], + title: cell.score === 0 ? "交了,还没人评分" : `${formatScore(cell.score)},点开批改`, + onClick: () => openSubmission(cell), }, - { default: () => formatScore(cell.score) }, + cell.score === 0 ? "未评" : cell.score.toFixed(1), ) } -function renderMissingCount(value: number) { - if (value <= 0) return "0" - return h(NText, { type: "error" }, { default: () => String(value) }) +function taskColumn(task: GradebookTask): DataTableColumn { + return { + title: () => renderTaskHeader(task), + key: `task-${task.id}`, + width: TASK_WIDTH, + align: "center", + className: task.included ? "" : "excluded-col", + render: (row) => renderScore(row, task), + } } const columns = computed[]>(() => { const tasks = gradebook.value?.tasks ?? [] + const groups = (["tutorial", "challenge"] as const) + .map((type) => ({ type, items: tasks.filter((t) => t.task_type === type) })) + .filter((g) => g.items.length) return [ { - title: "排名", + title: "#", key: "rank", - width: 60, + width: 40, fixed: "left", - }, - { - title: "平时等级", - key: "grade", - width: 80, - fixed: "left", - render: (row) => - h( - NTag, - { size: "small", type: gradeTagType(row.grade) }, - { default: () => row.grade }, - ), - }, - { - title: "考核等级", - key: "assessment_grade", - width: 80, - fixed: "left", - render: (row) => - h( - NTag, - { size: "small", type: gradeTagType(row.assessment_grade) }, - { default: () => row.assessment_grade }, - ), - }, - { - title: "考核分数", - key: "assessment_score", - width: 80, - fixed: "left", - render: (row) => row.assessment_score, + align: "right", + sorter: (a, b) => a.rank - b.rank, + render: (row) => h("span", { class: "rank" }, row.rank), }, { title: "学生", key: "username", - width: 80, + width: 92, fixed: "left", - render: (row) => { - const studentName = displayGradebookStudentName(row) - return h(NText, { title: studentName }, { default: () => studentName }) - }, + ellipsis: { tooltip: true }, + render: (row) => displayGradebookStudentName(row), }, - ...tasks.map((task) => ({ - title: () => renderTaskHeader(task), - key: `task-${task.id}`, - width: 96, + { + title: "平时", + key: "grade", + width: 52, + fixed: "left", + align: "center", + render: (row) => renderGrade(row.grade), + }, + { + title: "考核", + key: "assessment_score", + width: 72, + fixed: "left", + sorter: (a, b) => a.assessment_score - b.assessment_score, + render: (row) => renderGrade(row.assessment_grade, row.assessment_score), + }, + ...groups.map((g) => ({ + title: g.type === "tutorial" ? "教程" : "挑战", + key: `group-${g.type}`, align: "center" as const, - className: task.included ? "" : "excluded-task-column", - render: (row: GradebookRow) => renderScore(row, task), + children: g.items.map(taskColumn), })), - { - 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, + width: 72, fixed: "right", - render: (row) => formatScore(row.total_score), - }, - { - title: "平均", - key: "average_score", - width: 82, - fixed: "right", - render: (row) => formatScore(row.average_score), + align: "right", + sorter: (a, b) => a.total_score - b.total_score, + render: (row) => + h( + "span", + { + class: "total", + title: `教程合计 ${formatScore(row.tutorial_total)}\n挑战合计 ${formatScore(row.challenge_total)}\n平均 ${formatScore(row.average_score)}`, + }, + formatScore(row.total_score), + ), }, { title: "已交", key: "submitted_task_count", - width: 70, + width: 64, fixed: "right", - }, - { - title: "缺交", - key: "missing_task_count", - width: 70, - fixed: "right", - render: (row) => renderMissingCount(row.missing_task_count), + align: "right", + sorter: (a, b) => a.missing_task_count - b.missing_task_count, + render: (row) => + h( + "span", + { + class: ["done", { short: row.missing_task_count > 0 }], + title: row.missing_task_count ? `缺交 ${row.missing_task_count} 个` : "都交了", + }, + `${row.submitted_task_count}/${row.submitted_task_count + row.missing_task_count}`, + ), }, ] }) @@ -315,7 +306,11 @@ async function loadClasses() { try { classes.value = await Account.listClasses() if (!query.classname && classes.value.length > 0) { - query.classname = classes.value[0] + let saved: string | null = null + try { + saved = localStorage.getItem(CLASS_KEY) + } catch {} + query.classname = saved && classes.value.includes(saved) ? saved : classes.value[0]! } } finally { classesLoading.value = false @@ -359,6 +354,16 @@ async function exportCsv() { } } +watch( + () => query.classname, + (c) => { + if (!c) return + try { + localStorage.setItem(CLASS_KEY, c) + } catch {} + }, +) + watch( () => [query.classname, query.task_type, query.include_all_tasks], () => loadGradebook(), @@ -379,67 +384,194 @@ onMounted(async () => { + +