feat: 同学作品和批改都能看全部班级,批改默认大图预览
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>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-27 09:22:41 +08:00
1 parent 8653d411a6
commit 75e20a3ae1
11 files changed
+265 -68

No files matched your search

+44 -24
View File
@@ -14,6 +14,15 @@ function requireStaff(user: User) {
// 和成绩册一致:班里的学生包括当助教的 admin
const CLASS_MEMBERS = `SELECT id FROM account_user
WHERE classname = $classname AND role IN ('normal', 'admin') AND is_active = 1`
// 不传班级时:所有有班级的学生
const ALL_MEMBERS = `SELECT id FROM account_user
WHERE classname != '' AND role IN ('normal', 'admin') AND is_active = 1`
function members(classname: string | undefined) {
return classname
? { sql: CLASS_MEMBERS, params: { classname } as Record<string, unknown> }
: { sql: ALL_MEMBERS, params: {} as Record<string, unknown> }
}
// 每个人在这个任务上最新的一次提交,以及提交了几次。
// 先只在 slim 覆盖索引上找,再按 rowid 读这几行的完整内容(代码是宽列,见 CLAUDE.md)
@@ -64,17 +73,16 @@ router.get("/options", (c) => {
})
// 一个班每个任务交了几个人
// 不传 classname 就是全部班级
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 { classname } = query(c, z.object({ classname: z.string().min(1).optional() }))
const m = members(classname)
const studentCount = get<{ n: number }>(`SELECT COUNT(*) AS n FROM (${m.sql})`, m.params)!.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 },
WHERE s.user_id IN (${m.sql}) GROUP BY s.task_id`,
m.params,
)
return c.json({
student_count: studentCount,
@@ -86,15 +94,18 @@ router.get("/progress", (c) => {
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 q = query(
c,
z.object({ classname: z.string().min(1).optional(), task_id: z.coerce.number().int() }),
)
const latest = latestByUser(q.task_id, CLASS_MEMBERS, { classname: q.classname })
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: q.task_id }), "任务不存在")
const m = members(q.classname)
const students = all<{ id: number; username: string; classname: string }>(
`SELECT id, username, classname FROM account_user WHERE id IN (${m.sql})
ORDER BY classname, username, id`,
m.params,
)
const latest = latestByUser(q.task_id, m.sql, m.params)
const rows = loadRows([...latest.values()].map((l) => l.rid), user.id)
return c.json(
students.map((s) => {
@@ -102,6 +113,7 @@ router.get("/class", (c) => {
return {
user_id: s.id,
username: s.username,
classname: s.classname,
submission: r
? {
id: uuidOut(r.id),
@@ -122,33 +134,41 @@ router.get("/class", (c) => {
)
})
// 学生看本班同学在这道题上的作品:自己交过才开放;自己打分之前不显示作者和得分
// 学生看同学在这道题上的作品:自己交过才开放;自己打分之前不显示作者和得分。
// scope=class 只看本班(默认,一个班几十份),scope=all 看全校所有班
router.get("/peers/:task_id", (c) => {
const user = requireUser(c)
const taskId = intParam(c, "task_id")
const { scope } = query(c, z.object({ scope: z.enum(["class", "all"]).default("class") }))
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: [] })
if (!submitted) return c.json({ locked: true, items: [] })
const byClass = scope === "class" && !!user.classname
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 }
WHERE role = 'normal' AND is_active = 1 AND id != $me
${byClass ? "AND classname = $classname" : ""}`
const params = byClass ? { classname: user.classname, me: user.id } : { 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 people = new Map(
all<{ id: number; username: string; classname: string }>(
`SELECT id, username, classname FROM account_user WHERE id IN (${members})`,
params,
).map((u) => [u.id, u]),
)
const items = [...rows.values()]
.sort((a, b) => (a.created < b.created ? 1 : -1))
.map((r) => {
const rated = (r.my_score || 0) > 0
const p = people.get(r.user_id)
return {
id: uuidOut(r.id),
username: rated ? names.get(r.user_id) : null,
username: rated ? p?.username : null,
// 班级不算身份信息,打分前也显示
classname: p?.classname ?? "",
score: rated ? r.score : null,
my_score: r.my_score || 0,
created: iso(r.created),
+3 -6
View File
@@ -37,16 +37,13 @@ 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 },
`SELECT 1 FROM submission_submission s WHERE s.user_id = $me AND s.task_id = $task_id LIMIT 1`,
{ me: user.id, task_id: sub.task_id },
)
}
+29 -4
View File
@@ -495,7 +495,7 @@ describe("任务素材", () => {
})
describe("同学作品与批改", () => {
test("学生只能看自己的、作品展里的、本班同学在自己也交过的题上的提交", async () => {
test("学生只能看自己的、作品展里的、自己也交过的题上同学(任何班)的提交", async () => {
const me = makeUser("me", "normal", "c1")
const mate = makeUser("mate", "normal", "c1")
const other = makeUser("other", "normal", "c2")
@@ -509,10 +509,15 @@ describe("同学作品与批改", () => {
const locked = await stu("GET", `/api/submission/review/peers/${task}`)
expect(locked.json).toEqual({ locked: true, items: [] })
expect((await stu("GET", `/api/submission/${uuid(otherSub)}`)).status).toBe(403)
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)
// 交过以后外班的也能看
expect((await stu("GET", `/api/submission/${uuid(otherSub)}`)).status).toBe(200)
// 没交过的题仍然看不到
const otherTask = makeTask("T2", "challenge", 2)
const otherTaskSub = makeSubmission(mate, otherTask)
expect((await stu("GET", `/api/submission/${uuid(otherTaskSub)}`)).status).toBe(403)
// 列表里只有自己的
const list = await stu("GET", "/api/submission/")
expect(list.json.items.map((s: any) => s.username)).toEqual(["me"])
@@ -539,6 +544,14 @@ describe("同学作品与批改", () => {
const random = await (await client("other"))("GET", "/api/submission/random-for-rating")
expect(random.json.username).toBeUndefined()
// 默认只看本班;scope=all 能看到外班,标着班级,同样匿名
const otherSub = makeSubmission(get(`SELECT id FROM account_user WHERE username = 'other'`).id, task)
const all = await stu("GET", `/api/submission/review/peers/${task}?scope=all`)
expect(all.json.items.map((w: any) => [w.classname, w.username])).toContainEqual(["c2", null])
expect(all.json.items.map((w: any) => w.id)).toContain(uuid(otherSub))
const mine = await stu("GET", `/api/submission/review/peers/${task}`)
expect(mine.json.items.map((w: any) => w.classname)).toEqual(["c1"])
})
test("老师按班级 × 任务看作品墙;课上展示用 flag", async () => {
@@ -558,8 +571,20 @@ describe("同学作品与批改", () => {
])
const progress = await teacher("GET", "/api/submission/review/progress?classname=c1")
expect(progress.json).toEqual({ student_count: 2, submitted: { [task]: 1 } })
// 不传班级:所有班的学生都在,带着班级
const other = makeUser("z", "normal", "c2")
makeSubmission(other, task)
const allWall = await teacher("GET", `/api/submission/review/class?task_id=${task}`)
expect(allWall.json.map((s: any) => [s.classname, s.username])).toEqual([
["c1", "a"],
["c1", "b"],
["c2", "z"],
])
const allProgress = await teacher("GET", "/api/submission/review/progress")
expect(allProgress.json).toEqual({ student_count: 3, submitted: { [task]: 2 } })
const options = await teacher("GET", "/api/submission/review/options")
expect(options.json.classes).toEqual(["c1"])
expect(options.json.classes).toEqual(["c1", "c2"])
expect((await teacher("PUT", `/api/submission/${uuid(latest)}/flag`, { flag: "present" })).json.flag).toBe("present")
})