- 学生“同学作品”加“本班 / 全部班级”切换(peers?scope=all),作品标班级,仍是交过才开放、打分前匿名; 服务端放开:交过某题后能看任何班同学在这道题上的提交 - 批改页班级下拉加“全部班级”:一道题全校作品按班级分组,没人交的班收起成一行; /review/class、/review/progress 不传 classname 就是全部班级 - 点开作品默认大图:作品墙收起,预览占满中间,打分 / 课上展示 / 提示词放右栏; Esc 返回作品墙,可切回小面板(记住选择);成绩册弹出的详情也用大图布局 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8653d411a6
commit
75e20a3ae1
11 files changed
+265
-68
No files matched your search
+44
-24
@@ -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),
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user