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

+163
View File
@@ -0,0 +1,163 @@
import { Hono } from "hono"
import { z } from "zod"
import { all, get, iso, uuidOut } from "../db"
import { isStaff, requireUser, type User } from "../auth"
import { HttpError, intParam, orNotFound, query, type Env } from "../http"
// 老师按“班级 × 任务”批改,学生看本班同学的作品。挂在 /submission/review 下
const router = new Hono<Env>()
function requireStaff(user: User) {
if (!isStaff(user)) throw new HttpError(403, "没有权限")
}
// 和成绩册一致:班里的学生包括当助教的 admin
const CLASS_MEMBERS = `SELECT id FROM account_user
WHERE classname = $classname AND role IN ('normal', 'admin') AND is_active = 1`
// 每个人在这个任务上最新的一次提交,以及提交了几次。
// 先只在 slim 覆盖索引上找,再按 rowid 读这几行的完整内容(代码是宽列,见 CLAUDE.md)
function latestByUser(taskId: number, members: string, params: Record<string, unknown>) {
const rows = all<{ rid: number; user_id: number; created: string }>(
`SELECT s.rowid AS rid, s.user_id, s.created FROM submission_submission s
WHERE s.task_id = $task_id AND s.user_id IN (${members})`,
{ task_id: taskId, ...params },
)
const latest = new Map<number, { rid: number; created: string; count: number }>()
for (const r of rows) {
const cur = latest.get(r.user_id)
if (!cur) latest.set(r.user_id, { rid: r.rid, created: r.created, count: 1 })
else {
cur.count++
if (r.created > cur.created) Object.assign(cur, { rid: r.rid, created: r.created })
}
}
return latest
}
function loadRows(rids: number[], me: number) {
if (!rids.length) return new Map<number, any>()
const rows = all(
`SELECT s.rowid AS rid, s.id, s.user_id, s.score, s.html, s.css, s.js, s.flag,
s.view_count, s.created,
(SELECT r.score FROM submission_rating r WHERE r.user_id = $me AND r.submission_id = s.id) AS my_score,
(SELECT COUNT(*) FROM submission_rating r WHERE r.submission_id = s.id) AS rating_count
FROM submission_submission s WHERE s.rowid IN (SELECT value FROM json_each($rids))`,
{ rids: JSON.stringify(rids), me },
)
return new Map(rows.map((r) => [r.user_id as number, r]))
}
// 班级列表和可批改的任务(教程、挑战)
router.get("/options", (c) => {
requireStaff(requireUser(c))
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 tasks = all(
`SELECT id, task_type, display, title, is_public FROM task_task
WHERE task_type IN ('tutorial', 'challenge')
ORDER BY task_type = 'challenge', display`,
).map((t) => ({ ...t, is_public: !!t.is_public }))
return c.json({ classes, tasks })
})
// 一个班每个任务交了几个人
router.get("/progress", (c) => {
requireStaff(requireUser(c))
const { classname } = query(c, z.object({ classname: z.string().min(1) }))
const studentCount = get<{ n: number }>(
`SELECT COUNT(*) AS n FROM (${CLASS_MEMBERS})`,
{ classname },
)!.n
const rows = all<{ task_id: number; n: number }>(
`SELECT s.task_id, COUNT(DISTINCT s.user_id) AS n FROM submission_submission s
WHERE s.user_id IN (${CLASS_MEMBERS}) GROUP BY s.task_id`,
{ classname },
)
return c.json({
student_count: studentCount,
submitted: Object.fromEntries(rows.map((r) => [r.task_id, r.n])),
})
})
// 作品墙:班里每个学生在这个任务上最新的一次提交(没交的 submission 为 null)
router.get("/class", (c) => {
const user = requireUser(c)
requireStaff(user)
const q = query(c, z.object({ classname: z.string().min(1), task_id: z.coerce.number().int() }))
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: q.task_id }), "任务不存在")
const students = all<{ id: number; username: string }>(
`SELECT id, username FROM account_user
WHERE classname = $classname AND role IN ('normal', 'admin') AND is_active = 1
ORDER BY username, id`,
{ classname: q.classname },
)
const latest = latestByUser(q.task_id, CLASS_MEMBERS, { classname: q.classname })
const rows = loadRows([...latest.values()].map((l) => l.rid), user.id)
return c.json(
students.map((s) => {
const r = rows.get(s.id)
return {
user_id: s.id,
username: s.username,
submission: r
? {
id: uuidOut(r.id),
score: r.score,
my_score: r.my_score || 0,
rating_count: r.rating_count,
flag: r.flag,
view_count: r.view_count,
submit_count: latest.get(s.id)!.count,
created: iso(r.created),
html: r.html,
css: r.css,
js: r.js,
}
: null,
}
}),
)
})
// 学生看本班同学在这道题上的作品:自己交过才开放;自己打分之前不显示作者和得分
router.get("/peers/:task_id", (c) => {
const user = requireUser(c)
const taskId = intParam(c, "task_id")
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: taskId }), "任务不存在")
const submitted = get(
`SELECT 1 FROM submission_submission WHERE user_id = $me AND task_id = $task_id LIMIT 1`,
{ me: user.id, task_id: taskId },
)
if (!submitted || !user.classname) return c.json({ locked: true, items: [] })
const members = `SELECT id FROM account_user
WHERE classname = $classname AND role = 'normal' AND is_active = 1 AND id != $me`
const params = { classname: user.classname, me: user.id }
const latest = latestByUser(taskId, members, params)
const rows = loadRows([...latest.values()].map((l) => l.rid), user.id)
const names = new Map(
all<{ id: number; username: string }>(`SELECT id, username FROM account_user WHERE id IN (${members})`, params).map(
(u) => [u.id, u.username],
),
)
const items = [...rows.values()]
.sort((a, b) => (a.created < b.created ? 1 : -1))
.map((r) => {
const rated = (r.my_score || 0) > 0
return {
id: uuidOut(r.id),
username: rated ? names.get(r.user_id) : null,
score: rated ? r.score : null,
my_score: r.my_score || 0,
created: iso(r.created),
html: r.html,
css: r.css,
js: r.js,
}
})
return c.json({ locked: false, items })
})
export default router
+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 })
+71
View File
@@ -493,3 +493,74 @@ describe("任务素材", () => {
}
})
})
describe("同学作品与批改", () => {
test("学生只能看自己的、作品展里的、本班同学在自己也交过的题上的提交", async () => {
const me = makeUser("me", "normal", "c1")
const mate = makeUser("mate", "normal", "c1")
const other = makeUser("other", "normal", "c2")
const task = makeTask("T")
const mateSub = makeSubmission(mate, task)
const otherSub = makeSubmission(other, task)
const stu = await client("me")
expect((await stu("GET", `/api/submission/${uuid(mateSub)}`)).status).toBe(403)
expect((await stu("GET", `/api/submission/${uuid(mateSub)}/prompt-chain`)).status).toBe(403)
const locked = await stu("GET", `/api/submission/review/peers/${task}`)
expect(locked.json).toEqual({ locked: true, items: [] })
makeSubmission(me, task)
expect((await stu("GET", `/api/submission/${uuid(mateSub)}`)).status).toBe(200)
// 外班的仍然看不到
expect((await stu("GET", `/api/submission/${uuid(otherSub)}`)).status).toBe(403)
// 列表里只有自己的
const list = await stu("GET", "/api/submission/")
expect(list.json.items.map((s: any) => s.username)).toEqual(["me"])
expect((await stu("GET", `/api/submission/by-user-task?user_id=${mate}&task_id=${task}`)).status).toBe(403)
})
test("同学作品打分前匿名,打完分才显示作者", async () => {
const me = makeUser("me", "normal", "c1")
const mate = makeUser("mate", "normal", "c1")
makeUser("other", "normal", "c2")
const task = makeTask("T")
makeSubmission(me, task)
const mateSub = makeSubmission(mate, task)
const stu = await client("me")
let peers = await stu("GET", `/api/submission/review/peers/${task}`)
expect(peers.json.items).toHaveLength(1)
expect(peers.json.items[0]).toMatchObject({ id: uuid(mateSub), username: null, score: null, my_score: 0 })
const rated = await stu("PUT", `/api/submission/${uuid(mateSub)}/score`, { score: 4 })
expect(rated.json.username).toBe("mate")
peers = await stu("GET", `/api/submission/review/peers/${task}`)
expect(peers.json.items[0]).toMatchObject({ username: "mate", my_score: 4 })
const random = await (await client("other"))("GET", "/api/submission/random-for-rating")
expect(random.json.username).toBeUndefined()
})
test("老师按班级 × 任务看作品墙;课上展示用 flag", async () => {
makeUser("t", "admin")
const a = makeUser("a", "normal", "c1")
makeUser("b", "normal", "c1")
const task = makeTask("T")
makeSubmission(a, task, 0, "2026-01-01 00:00:00.000000")
const latest = makeSubmission(a, task, 0, "2026-01-02 00:00:00.000000")
const teacher = await client("t")
expect((await (await client("a"))("GET", `/api/submission/review/class?classname=c1&task_id=${task}`)).status).toBe(403)
const wall = await teacher("GET", `/api/submission/review/class?classname=c1&task_id=${task}`)
expect(wall.json.map((s: any) => [s.username, s.submission?.id ?? null, s.submission?.submit_count])).toEqual([
["a", uuid(latest), 2],
["b", null, undefined],
])
const progress = await teacher("GET", "/api/submission/review/progress?classname=c1")
expect(progress.json).toEqual({ student_count: 2, submitted: { [task]: 1 } })
const options = await teacher("GET", "/api/submission/review/options")
expect(options.json.classes).toEqual(["c1"])
expect((await teacher("PUT", `/api/submission/${uuid(latest)}/flag`, { flag: "present" })).json.flag).toBe("present")
})
})