Django 多个 worker 内存占用 1-2 GB,换成 Bun 单进程后约 25 MB, 同时去掉 Postgres 和 Redis,只剩 Caddy + Bun 两个容器。 - server/:Hono + bun:sqlite + Bun 原生 WebSocket,接口与原 django-ninja 保持兼容;表结构、时间/UUID 存储格式、密码哈希与 Django 一致,旧数据可直接使用 - 评分加权、贝叶斯平滑、成绩册的计算结果与原实现逐位一致(复刻 Python round/sum) - scripts/:Postgres → SQLite 迁移、新建超管、提示词层级标注、重算分数 - 前端:Django Admin 去掉后,用户新建/修改改为页面内弹窗; 非超管的挑战公开按钮置灰 - 权限不足返回 JSON 403(原来是重定向到不存在的登录页) - docker-compose / Caddyfile / dev.sh / CLAUDE.md 更新,删除 AGENTS.md - 服务器切换步骤见 docs/migrate-to-bun.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
307 lines
9.8 KiB
TypeScript
307 lines
9.8 KiB
TypeScript
import { all } from "../db"
|
|
import { uuidOut } from "../db"
|
|
import { HttpError } from "../http"
|
|
import { pyRound, pySum } from "./pyround"
|
|
|
|
export type GradebookTaskType = "tutorial" | "challenge"
|
|
type Grade = "A" | "B" | "C" | "D" | "E"
|
|
|
|
export interface GradebookFilters {
|
|
classname: string
|
|
task_type?: GradebookTaskType
|
|
username?: string
|
|
include_all_tasks?: boolean
|
|
}
|
|
|
|
interface GradebookTask {
|
|
id: number
|
|
display: number
|
|
title: string
|
|
task_type: string
|
|
submitted_count: number
|
|
coverage: number
|
|
included: boolean
|
|
}
|
|
|
|
interface GradebookRow {
|
|
user_id: number
|
|
username: string
|
|
classname: string
|
|
rank: number
|
|
grade: Grade
|
|
assessment_grade: Grade
|
|
assessment_score: number
|
|
top3_score: number
|
|
scores: Record<number, { score: number; submitted: boolean; submission_id: string | null }>
|
|
tutorial_total: number
|
|
challenge_total: number
|
|
total_score: number
|
|
average_score: number | null
|
|
submitted_task_count: number
|
|
missing_task_count: number
|
|
}
|
|
|
|
export interface Gradebook {
|
|
classname: string
|
|
classes: string[]
|
|
task_count: number
|
|
included_task_count: number
|
|
student_count: number
|
|
coverage_threshold_count: number
|
|
tasks: GradebookTask[]
|
|
rows: GradebookRow[]
|
|
}
|
|
|
|
const score = (value: number | null | undefined) => pyRound(value || 0, 2)
|
|
|
|
// Python 字符串比较(按码点),不能用 localeCompare
|
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
|
|
|
function gradeForRank(rank: number, n: number): Grade {
|
|
if (rank <= Math.ceil(0.3 * n)) return "A"
|
|
if (rank <= Math.ceil(0.7 * n)) return "B"
|
|
if (rank <= Math.ceil(0.9 * n)) return "C"
|
|
if (rank <= Math.ceil(0.95 * n)) return "D"
|
|
return "E"
|
|
}
|
|
|
|
// 考核等级:A10% B20% C40% D20% E10%,按最高三次提交均分排名
|
|
function assessmentGradeForRank(rank: number, n: number): Grade {
|
|
if (rank <= Math.ceil(0.1 * n)) return "A"
|
|
if (rank <= Math.ceil(0.3 * n)) return "B"
|
|
if (rank <= Math.ceil(0.7 * n)) return "C"
|
|
if (rank <= Math.ceil(0.9 * n)) return "D"
|
|
return "E"
|
|
}
|
|
|
|
// 考核分数:各等级对应的分数段(高分→低分),段内按名次线性铺开;最高分封顶 99
|
|
const ASSESSMENT_SCORE_BANDS: Record<Grade, [number, number]> = {
|
|
A: [92, 99],
|
|
B: [84, 91],
|
|
C: [76, 83],
|
|
D: [68, 75],
|
|
E: [60, 67],
|
|
}
|
|
|
|
function assessmentScore(indexInGrade: number, gradeSize: number, grade: Grade) {
|
|
const [low, high] = ASSESSMENT_SCORE_BANDS[grade]
|
|
if (gradeSize <= 1) return high
|
|
// 段内第 1 名拿段顶,最后 1 名拿段底
|
|
return pyRound(high - (indexInGrade / (gradeSize - 1)) * (high - low))
|
|
}
|
|
|
|
export function buildGradebook(filters: GradebookFilters): Gradebook {
|
|
const classname = filters.classname?.trim() ?? ""
|
|
if (!classname) throw new HttpError(400, "请选择班级")
|
|
|
|
const classes = all<{ classname: string }>(
|
|
`SELECT DISTINCT classname FROM account_user
|
|
WHERE role IN ('normal', 'admin') AND classname != '' ORDER BY classname`,
|
|
).map((r) => r.classname)
|
|
|
|
const students = all<{ id: number; username: string; classname: string }>(
|
|
`SELECT id, username, classname FROM account_user
|
|
WHERE role IN ('normal', 'admin') AND classname = $classname
|
|
ORDER BY username, id`,
|
|
{ classname },
|
|
)
|
|
const studentCount = students.length
|
|
const coverageThreshold = studentCount ? Math.ceil(studentCount * 0.5) : 0
|
|
|
|
const typeFilter = filters.task_type ? `AND t.task_type = $task_type` : ""
|
|
const typeParams = filters.task_type ? { task_type: filters.task_type } : {}
|
|
|
|
const taskRows = all<{ id: number; display: number; title: string; task_type: string; submitted_count: number }>(
|
|
`SELECT t.id, t.display, t.title, t.task_type, COUNT(DISTINCT s.user_id) AS submitted_count
|
|
FROM submission_submission s
|
|
JOIN task_task t ON t.id = s.task_id
|
|
JOIN account_user u ON u.id = s.user_id
|
|
WHERE u.role IN ('normal', 'admin') AND u.classname = $classname ${typeFilter}
|
|
GROUP BY t.id`,
|
|
{ classname, ...typeParams },
|
|
)
|
|
taskRows.sort(
|
|
(a, b) =>
|
|
(a.task_type === "tutorial" ? 0 : 1) - (b.task_type === "tutorial" ? 0 : 1) ||
|
|
a.display - b.display ||
|
|
a.id - b.id,
|
|
)
|
|
|
|
const allTasks: GradebookTask[] = taskRows.map((t) => ({
|
|
id: t.id,
|
|
display: t.display,
|
|
title: t.title,
|
|
task_type: t.task_type,
|
|
submitted_count: t.submitted_count,
|
|
coverage: studentCount ? score(t.submitted_count / studentCount) : 0,
|
|
included: studentCount > 0 && t.submitted_count >= coverageThreshold,
|
|
}))
|
|
|
|
const tasks = allTasks.filter((t) => filters.include_all_tasks || t.included)
|
|
const visibleTaskIds = new Set(tasks.map((t) => t.id))
|
|
const includedTaskCount = tasks.filter((t) => t.included).length
|
|
|
|
// 每个 (学生, 任务) 取最高分的提交;同分取最新
|
|
const best = new Map<string, { id: string; score: number }>()
|
|
if (studentCount && visibleTaskIds.size) {
|
|
const subs = all<{ id: string; user_id: number; task_id: number; score: number }>(
|
|
`SELECT s.id, s.user_id, s.task_id, s.score
|
|
FROM submission_submission s JOIN account_user u ON u.id = s.user_id
|
|
WHERE u.role IN ('normal', 'admin') AND u.classname = $classname
|
|
ORDER BY s.user_id, s.task_id, s.score DESC, s.created DESC, s.id DESC`,
|
|
{ classname },
|
|
)
|
|
for (const s of subs) {
|
|
if (!visibleTaskIds.has(s.task_id)) continue
|
|
const key = `${s.user_id}:${s.task_id}`
|
|
if (!best.has(key)) best.set(key, s)
|
|
}
|
|
}
|
|
|
|
let rows: GradebookRow[] = students.map((student) => {
|
|
const scores: GradebookRow["scores"] = {}
|
|
let tutorialTotal = 0
|
|
let challengeTotal = 0
|
|
const submittedScores: number[] = []
|
|
let submittedTaskCount = 0
|
|
let missingTaskCount = 0
|
|
|
|
for (const task of tasks) {
|
|
const sub = best.get(`${student.id}:${task.id}`)
|
|
const cellScore = sub ? score(sub.score) : 0
|
|
scores[task.id] = {
|
|
score: cellScore,
|
|
submitted: !!sub,
|
|
submission_id: sub ? uuidOut(sub.id) : null,
|
|
}
|
|
if (!task.included) continue
|
|
if (sub) {
|
|
submittedTaskCount++
|
|
submittedScores.push(cellScore)
|
|
} else {
|
|
missingTaskCount++
|
|
}
|
|
if (task.task_type === "tutorial") tutorialTotal += cellScore
|
|
else challengeTotal += cellScore
|
|
}
|
|
|
|
tutorialTotal = score(tutorialTotal)
|
|
challengeTotal = score(challengeTotal)
|
|
const top3 = submittedScores.sort((a, b) => b - a).slice(0, 3)
|
|
const top3Score = top3.length ? score(pySum(top3) / top3.length) : 0
|
|
const totalScore = score(tutorialTotal + challengeTotal)
|
|
return {
|
|
user_id: student.id,
|
|
username: student.username,
|
|
classname: student.classname,
|
|
rank: 0,
|
|
grade: "E",
|
|
assessment_grade: "E",
|
|
assessment_score: 60,
|
|
top3_score: top3Score,
|
|
scores,
|
|
tutorial_total: tutorialTotal,
|
|
challenge_total: challengeTotal,
|
|
total_score: totalScore,
|
|
average_score: includedTaskCount ? score(totalScore / includedTaskCount) : null,
|
|
submitted_task_count: submittedTaskCount,
|
|
missing_task_count: missingTaskCount,
|
|
}
|
|
})
|
|
|
|
rows.sort((a, b) => b.total_score - a.total_score || cmp(a.username, b.username))
|
|
rows.forEach((row, i) => {
|
|
row.rank = i + 1
|
|
row.grade = gradeForRank(i + 1, studentCount)
|
|
})
|
|
|
|
// 考核等级:按最高三次提交均分单独排名后定级(不改变上面的总分排名)
|
|
const assessmentOrder = [...rows].sort(
|
|
(a, b) => b.top3_score - a.top3_score || cmp(a.username, b.username),
|
|
)
|
|
assessmentOrder.forEach((row, i) => {
|
|
row.assessment_grade = assessmentGradeForRank(i + 1, studentCount)
|
|
})
|
|
|
|
// 考核分数:在各等级段内按名次线性铺开到对应分数段
|
|
const groups = new Map<Grade, GradebookRow[]>()
|
|
for (const row of assessmentOrder) {
|
|
const g = groups.get(row.assessment_grade) ?? []
|
|
g.push(row)
|
|
groups.set(row.assessment_grade, g)
|
|
}
|
|
for (const [grade, group] of groups) {
|
|
group.forEach((row, i) => {
|
|
row.assessment_score = assessmentScore(i, group.length, grade)
|
|
})
|
|
}
|
|
|
|
const username = filters.username?.trim().toLowerCase() ?? ""
|
|
if (username) rows = rows.filter((r) => r.username.toLowerCase().includes(username))
|
|
|
|
return {
|
|
classname,
|
|
classes,
|
|
task_count: tasks.length,
|
|
included_task_count: includedTaskCount,
|
|
student_count: studentCount,
|
|
coverage_threshold_count: coverageThreshold,
|
|
tasks,
|
|
rows,
|
|
}
|
|
}
|
|
|
|
function csvNumber(value: number) {
|
|
if (Number.isInteger(value)) return String(value)
|
|
return pyRound(value, 2).toFixed(2).replace(/0+$/, "").replace(/\.$/, "")
|
|
}
|
|
|
|
const taskTypeLabel = (t: string) => (t === "tutorial" ? "教程" : "挑战")
|
|
|
|
export function gradebookCsvRows(gb: Gradebook): (string | number)[][] {
|
|
const rows: (string | number)[][] = [
|
|
[
|
|
"排名",
|
|
"平时等级",
|
|
"考核等级",
|
|
"考核分数",
|
|
"用户名",
|
|
"班级",
|
|
...gb.tasks.map((t) => `${taskTypeLabel(t.task_type)}${t.display}-${t.title}`),
|
|
"教程合计",
|
|
"挑战合计",
|
|
"总分",
|
|
"平均分",
|
|
"已提交任务数",
|
|
"未提交任务数",
|
|
],
|
|
]
|
|
for (const row of gb.rows) {
|
|
rows.push([
|
|
row.rank,
|
|
row.grade,
|
|
row.assessment_grade,
|
|
row.assessment_score,
|
|
row.username,
|
|
row.classname,
|
|
...gb.tasks.map((t) => csvNumber(row.scores[t.id]!.score)),
|
|
csvNumber(row.tutorial_total),
|
|
csvNumber(row.challenge_total),
|
|
csvNumber(row.total_score),
|
|
row.average_score === null ? "" : csvNumber(row.average_score),
|
|
row.submitted_task_count,
|
|
row.missing_task_count,
|
|
])
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// 与 Python csv 模块默认行为一致:含逗号/引号/换行时加引号,行尾 \r\n
|
|
export function toCsv(rows: (string | number)[][]) {
|
|
const cell = (v: string | number) => {
|
|
const s = String(v)
|
|
return /[",\r\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s
|
|
}
|
|
return rows.map((r) => r.map(cell).join(",")).join("\r\n") + "\r\n"
|
|
}
|