feat: 改版阶段 3:老师按班级 × 任务批改,学生看本班同学作品

老师
- 新的“批改”页(教学默认页):左边班级 + 任务列表和完成进度,中间作品墙
  (每人最新一版、未交 / 我未评 / 分数),右侧详情面板:各版本、提示词链、打分、
  加入课上展示、加入作品展;J/K 切换学生,1–5 打分,P 课上展示
- “开始讲评”全屏依次播放课上展示的作品;成绩册点分数打开同一个详情面板
- 四色标记和“一键清除全站标记”去掉,flag 只用 present(课上展示);全部提交只给超管

学生
- “同学作品”:只看本班、自己交过这道题才开放,打分前匿名(服务端不返回作者)
- 学生的提交列表只有自己的;别人的提交和提示词链只有本班同学交过同一题、或进了作品展才能看
- 随机互评不再返回作者,打完分由 PUT /:id/score 返回

服务端新增 routes/review.ts(/submission/review:options、progress、class、peers),
查询走 slim 覆盖索引

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-27 00:10:18 +08:00
1 parent 9c6c01eba1
commit df3f842dac
18 files changed
+1616 -275

No files matched your search

+56 -21
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono"
import { z } from "zod"
import { all, get, run, tx, now, iso, likeEscape, newUuidHex, uuidHex, uuidOut } from "../db"
import { Role, isStaff, requireSuper, requireUser } from "../auth"
import { Role, isStaff, requireSuper, requireUser, type User } from "../auth"
import {
HttpError,
ValidationError,
@@ -30,10 +30,35 @@ import {
} from "../services/prompt"
import { pyRound } from "../services/pyround"
import showcase from "./showcase"
import review from "./review"
const router = new Hono<Env>()
router.route("/showcase", showcase)
router.route("/review", review)
// 学生能看的提交:自己的、进了作品展的、本班同学在自己也交过的题上的。老师都能看
function canView(user: User, sub: { id: string; user_id: number; task_id: number }) {
if (isStaff(user) || sub.user_id === user.id) return true
if (get(`SELECT 1 FROM submission_submissionaward WHERE submission_id = $id`, { id: sub.id })) return true
if (!user.classname) return false
return !!get(
`SELECT 1 FROM account_user u
WHERE u.id = $owner AND u.classname = $classname
AND EXISTS (SELECT 1 FROM submission_submission s WHERE s.user_id = $me AND s.task_id = $task_id)`,
{ owner: sub.user_id, classname: user.classname, me: user.id, task_id: sub.task_id },
)
}
function requireView(user: User, id: string) {
const sub = orNotFound(
get<{ id: string; user_id: number; task_id: number }>(
`SELECT id, user_id, task_id FROM submission_submission WHERE id = $id`,
{ id },
),
)
if (!canView(user, sub)) throw new HttpError(403, "交过这道题以后才能看同学的作品")
}
// 当天(UTC 零点起)的评分次数,与原实现保持一致
function todayRatingCount(userId: number) {
@@ -221,13 +246,19 @@ router.get("/", (c) => {
where.push("t.task_type = $task_type")
params.task_type = f.task_type
}
if (f.username) {
where.push(`u.username LIKE $username ESCAPE '\\'`)
params.username = likeEscape(f.username)
}
if (f.user_id) {
// 学生只能看自己的提交(同学的作品走 /review/peers)
if (user.role === Role.NORMAL) {
where.push("s.user_id = $user_id")
params.user_id = f.user_id
params.user_id = user.id
} else {
if (f.username) {
where.push(`u.username LIKE $username ESCAPE '\\'`)
params.username = likeEscape(f.username)
}
if (f.user_id) {
where.push("s.user_id = $user_id")
params.user_id = f.user_id
}
}
// flag / zone 前的一元 + 让 SQLite 不用这两列的单列索引(那样要回表读大行),改走 slim 覆盖索引
if (f.flag === "any") where.push("+s.flag IS NOT NULL")
@@ -288,6 +319,7 @@ router.get("/", (c) => {
router.get("/by-user-task", (c) => {
const me = requireUser(c)
const q = query(c, z.object({ user_id: qs.int, task_id: qs.int }))
if (me.role === Role.NORMAL && q.user_id !== me.id) throw new HttpError(403, "没有权限")
const rows = all(
`${SUBMISSION_SELECT} ${SUBMISSION_FROM}
WHERE s.user_id = $user_id AND s.task_id = $task_id ORDER BY s.created DESC`,
@@ -333,14 +365,14 @@ router.get("/random-for-rating", (c) => {
get<{ rid: number }>(`${base} ORDER BY RANDOM() LIMIT 1`, params)
if (!picked) return c.json(null)
const pick = get(
`SELECT s.id, u.username, t.title AS task_title, t.display AS task_display,
`SELECT s.id, t.title AS task_title, t.display AS task_display,
t.task_type, s.html, s.css, s.js
${SUBMISSION_FROM} WHERE s.rowid = $rid`,
{ rid: picked.rid },
)!
// 打分前不告诉作者是谁(打完分 PUT /:id/score 会返回)
return c.json({
submission_id: uuidOut(pick.id),
username: pick.username,
task_title: pick.task_title,
task_display: pick.task_display,
task_type: pick.task_type,
@@ -350,13 +382,6 @@ router.get("/random-for-rating", (c) => {
})
})
router.delete("/flags", (c) => {
const user = requireUser(c)
if (!isStaff(user)) throw new HttpError(403, "没有权限")
const res = run(`UPDATE submission_submission SET flag = NULL WHERE flag IS NOT NULL`)
return c.json({ cleared: res.changes })
})
// ── 统计 ──────────────────────────────────────────────────────────────────────
router.get("/stats/:task_id", (c) => {
@@ -510,9 +535,9 @@ router.delete("/:id", (c) => {
})
router.get("/:id/prompt-chain", (c) => {
requireUser(c)
const user = requireUser(c)
const id = uuidParam(c, "id")
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id }))
requireView(user, id)
const msg = get<MessageRow>(`SELECT * FROM prompt_message WHERE submission_id = $id`, { id })
if (!msg) throw new HttpError(404, "该提交没有关联提示词链")
return c.json(buildPromptRounds(msg))
@@ -521,6 +546,7 @@ router.get("/:id/prompt-chain", (c) => {
router.get("/:id", (c) => {
const user = requireUser(c)
const id = uuidParam(c, "id")
requireView(user, id)
const s = orNotFound(get(`${SUBMISSION_SELECT} ${SUBMISSION_FROM} WHERE s.id = $id`, { me: user.id, id }))
return c.json(submissionOut(s))
})
@@ -539,7 +565,13 @@ router.put("/:id/score", async (c) => {
const id = uuidParam(c, "id")
const { score } = await body(c, z.object({ score: z.number().int().max(5) }))
if (score <= 0) throw new HttpError(400, "分数不能为零")
const sub = orNotFound(get<{ user_id: number }>(`SELECT user_id FROM submission_submission WHERE id = $id`, { id }))
const sub = orNotFound(
get<{ user_id: number; username: string }>(
`SELECT s.user_id, u.username FROM submission_submission s
JOIN account_user u ON u.id = s.user_id WHERE s.id = $id`,
{ id },
),
)
if (sub.user_id === user.id) throw new HttpError(400, "不能给自己的提交打分")
const exists = get(`SELECT 1 FROM submission_rating WHERE user_id = $user_id AND submission_id = $id`, {
@@ -558,7 +590,8 @@ router.put("/:id/score", async (c) => {
)
updateSubmissionScore(id)
})
return c.json({ message: "打分成功" })
// 互评时打分前是匿名的,打完分再告诉作者是谁
return c.json({ message: "打分成功", username: sub.username })
})
router.put("/:id/flag", async (c) => {
@@ -567,7 +600,9 @@ router.put("/:id/flag", async (c) => {
const id = uuidParam(c, "id")
const { flag } = await body(
c,
z.object({ flag: z.enum(["red", "blue", "green", "yellow"]).nullable().default(null) }),
z.object({
flag: z.enum(["present", "red", "blue", "green", "yellow"]).nullable().default(null),
}),
)
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id }))
run(`UPDATE submission_submission SET flag = $flag WHERE id = $id`, { flag, id })