Deploy / deploy (build, debian, 22) (push) Canceled after 0s
- 学生“同学作品”加“本班 / 全部班级”切换(peers?scope=all),作品标班级,仍是交过才开放、打分前匿名; 服务端放开:交过某题后能看任何班同学在这道题上的提交 - 批改页班级下拉加“全部班级”:一道题全校作品按班级分组,没人交的班收起成一行; /review/class、/review/progress 不传 classname 就是全部班级 - 点开作品默认大图:作品墙收起,预览占满中间,打分 / 课上展示 / 提示词放右栏; Esc 返回作品墙,可切回小面板(记住选择);成绩册弹出的详情也用大图布局 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
446 lines
11 KiB
Vue
446 lines
11 KiB
Vue
<template>
|
|
<n-flex
|
|
vertical
|
|
:size="12"
|
|
style="height: 100%; min-width: 0; box-sizing: border-box; padding: 10px 10px 10px 0; overflow: hidden;"
|
|
>
|
|
<n-flex class="toolbar" align="center" justify="space-between" style="flex-shrink: 0;">
|
|
<n-flex align="center" :size="8" wrap style="min-width: 0;">
|
|
<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" align="center" :size="8" style="flex-shrink: 0;">
|
|
<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
|
|
size="small"
|
|
striped
|
|
flex-height
|
|
:loading="loading"
|
|
:columns="columns"
|
|
:data="rows"
|
|
:row-key="(row: GradebookRow) => row.user_id"
|
|
:scroll-x="scrollX"
|
|
style="flex: 1; min-height: 0;"
|
|
/>
|
|
</n-flex>
|
|
<n-drawer
|
|
:show="!!reviewId"
|
|
width="min(1180px, 92vw)"
|
|
placement="right"
|
|
@update:show="(v: boolean) => !v && (reviewId = null)"
|
|
>
|
|
<ReviewPanel v-if="reviewId" :submission-id="reviewId" wide @close="reviewId = null" />
|
|
</n-drawer>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
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 { Account, Gradebook } from "../api"
|
|
import { displayGradebookStudentName } from "../utils/gradebook"
|
|
import type {
|
|
GradebookCell,
|
|
GradebookOut,
|
|
GradebookQuery,
|
|
GradebookRow,
|
|
GradebookTask,
|
|
GradebookTaskType,
|
|
} from "../utils/type"
|
|
|
|
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(() => 1040 + (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}`
|
|
}
|
|
|
|
// 点分数:在右侧打开和批改页一样的详情,能直接打分、看提示词
|
|
const reviewId = ref<string | null>(null)
|
|
function openSubmission(cell: GradebookCell) {
|
|
if (!cell.submitted || !cell.submission_id) return
|
|
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 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(
|
|
NText,
|
|
{ class: "missing-cell", type: "error" },
|
|
{ default: () => "缺交" },
|
|
)
|
|
}
|
|
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) },
|
|
)
|
|
}
|
|
|
|
function renderMissingCount(value: number) {
|
|
if (value <= 0) return "0"
|
|
return h(NText, { type: "error" }, { default: () => String(value) })
|
|
}
|
|
|
|
const columns = computed<DataTableColumn<GradebookRow>[]>(() => {
|
|
const tasks = gradebook.value?.tasks ?? []
|
|
return [
|
|
{
|
|
title: "排名",
|
|
key: "rank",
|
|
width: 60,
|
|
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,
|
|
},
|
|
{
|
|
title: "学生",
|
|
key: "username",
|
|
width: 80,
|
|
fixed: "left",
|
|
render: (row) => {
|
|
const studentName = displayGradebookStudentName(row)
|
|
return h(NText, { title: studentName }, { default: () => studentName })
|
|
},
|
|
},
|
|
...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",
|
|
render: (row) => renderMissingCount(row.missing_task_count),
|
|
},
|
|
]
|
|
})
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// 快速切换班级时只用最后一次请求的结果
|
|
let loadSeq = 0
|
|
|
|
async function loadGradebook() {
|
|
const seq = ++loadSeq
|
|
if (!query.classname) {
|
|
gradebook.value = null
|
|
loading.value = false
|
|
return
|
|
}
|
|
loading.value = true
|
|
loadError.value = ""
|
|
try {
|
|
const data = await Gradebook.get(query)
|
|
if (seq !== loadSeq) return
|
|
gradebook.value = data
|
|
classes.value = data.classes
|
|
} catch (err: any) {
|
|
if (seq !== loadSeq) return
|
|
loadError.value = err.response?.data?.detail ?? "成绩册加载失败"
|
|
} finally {
|
|
if (seq === loadSeq) 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 () => {
|
|
const before = query.classname
|
|
await loadClasses()
|
|
// loadClasses 选了默认班级时,上面的 watch 已经会加载
|
|
if (query.classname === before) loadGradebook()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.class-select {
|
|
width: 150px;
|
|
}
|
|
|
|
.type-select {
|
|
width: 112px;
|
|
}
|
|
|
|
.search-input {
|
|
width: 160px;
|
|
}
|
|
|
|
.task-header {
|
|
min-width: 0;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.task-header.muted {
|
|
color: var(--wp-muted);
|
|
}
|
|
|
|
.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: var(--wp-faint);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.missing-cell {
|
|
color: var(--wp-bad);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.score-link.muted {
|
|
color: var(--wp-muted);
|
|
}
|
|
|
|
:deep(.excluded-task-column) {
|
|
background: var(--wp-paper);
|
|
}
|
|
|
|
@media (max-width: 720px) {
|
|
.toolbar {
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.class-select,
|
|
.type-select,
|
|
.search-input {
|
|
width: 100%;
|
|
}
|
|
}
|
|
</style>
|