diff --git a/CLAUDE.md b/CLAUDE.md index ba7d56e..8a36f38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,8 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1 - `src/app.ts` — mounts routers under `/api/`, CORS, auth middleware, error → `{ detail }` JSON (ninja-compatible; validation errors are 422) - `src/db.ts` — schema (`CREATE TABLE IF NOT EXISTS`, same DDL as Django), `all/get/run/tx` helpers using `$name` params, time (`now()`, `iso()`) and UUID (`uuidHex()`, `uuidOut()`) conversions - `src/auth.ts` — Django-compatible password hashing; JWT login, no cookies: `POST /account/login` returns a 15-min HS256 `access_token` (sent as `Authorization: Bearer`, signed with `JWT_SECRET`, random per process if unset) and a 14-day opaque `refresh_token` (only its sha256 is stored, in `auth_refresh_token`; `POST /account/refresh` trades it for a new access token, logout / password change / deactivation delete it). An invalid or expired Bearer token is a 401 even on public endpoints so the client knows to refresh. `requireUser/requireAdmin/requireSuper` (401 / 403) - - `src/routes/` — `account`, `tasks` (tutorial / challenge / creation), `files` (task assets + image upload), `submission`, `showcase` (mounted under `/submission/showcase`), `prompt` + - `src/routes/` — `account`, `tasks` (tutorial / challenge / creation), `files` (task assets + image upload), `submission`, `showcase` (mounted under `/submission/showcase`), `review` (mounted under `/submission/review`: teacher class × task wall, students' same-class peer works), `prompt` + - Who can read a submission (`canView` in `routes/submission.ts`): staff, the owner, anyone if it's in the showcase, or a same-class student who has submitted the same task. Students' `GET /submission/` only lists their own. Peer works and the random-rating item are anonymous until the viewer rates them (`PUT /:id/score` returns the author). `flag` now only carries `present` (teacher's in-class presentation queue); the old four colors are no longer shown - `src/services/` — `score.ts` (rating weights super×0.5, admin×0.3, normal×0.2, then Bayesian smoothing with C=3 and zones), `gradebook.ts`, `llm.ts` (OpenAI-compatible streaming via fetch; DeepSeek default, ARK for doubao models; chat context is the last 20 messages with older code blocks elided), `classifier.ts` (Bloom-level L1–L6 prompt tagging, runs in background), `cascade.ts` (Django's CASCADE deletes done by hand, plus the shared delete rules: `hasRatingsOrAwards`, `deleteRound`), `pyround.ts` (`pyRound`/`pySum` reproduce Python's `round()` and `sum()` so scores match the old system bit-for-bit) - `src/ws.ts` — `/ws/prompt//?token=` (code generation chat; the AI context and the page's chat include `conversation`, `edit` and `manual` messages) and `/ws/guidance//?token=...` (prompt coaching); one message per connection at a time (a message sent while the AI is still replying gets an error, payloads capped at 64 KB); on disconnect the pending user message is deleted and the LLM request aborted - `scripts/` — one-off CLIs: `create-superuser.ts`, `classify-prompts.ts`, `recalculate-scores.ts` @@ -30,6 +31,7 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1 - `src/theme.ts` — the only place colors are defined: one palette (light + dark values) becomes both `--wp-*` CSS variables and Naive UI `themeOverrides`; use `var(--wp-...)` in styles, never hard-coded colors. No `color-mix()` (Chrome 105) - `src/icons.ts` — `` renders bundled `lucide-vue-next` icons (works offline); register new names in its map. Fonts are bundled too (`@fontsource/jetbrains-mono`) - `components/workbench/Workbench.vue` — shared challenge / creation page: left = brief + AI chat (`PromptPanel`), right = preview / code (`CodeTabs`) + `VersionBar`. Every assistant message with code is a version; hand edits and pasted external-AI code are saved as versions via `POST /prompt/versions` (`source` `edit` / `manual`, stored as a user+assistant pair). Challenges are submitted only when the student clicks “交这一版” (`POST /submission/` with `message_id`), never automatically. Editor code is per task (`composables/useTaskCode.ts`, localStorage `web-code::`); the tutorial page still uses the shared `store/editors.ts` + - `pages/Review.vue` (“批改”, default teaching page) + `components/review/ReviewPanel.vue` (also opened from the gradebook) — keys: J/K next/prev student, 1–5 rate, P present; `components/review/PeersDrawer.vue` is the students' “同学作品” - `components/layout/AppShell.vue` — top bar for every page; `pages/Dashboard.vue` is the teaching sidebar (“教学”) under `/dashboard` - `src/store/` — Reactive state (plain Vue refs, not Vuex/Pinia); `prompt.ts` / `guidance.ts` own the WebSockets - `src/pages/`, `src/components/` — user management uses `components/dashboard/UserFormModal.vue` (there is no Django Admin anymore) diff --git a/client/src/api.ts b/client/src/api.ts index 780dafc..b86a40d 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -24,6 +24,10 @@ import type { CreationSlim, CreationDetail, CreationIn, + ReviewOptions, + ReviewProgress, + ReviewStudent, + PeerWorks, } from "./utils/type" import { BASE_URL } from "./utils/const" import { @@ -320,7 +324,11 @@ export const Submission = { await http.post(`/submission/${id}/view`) }, - async updateScore(id: string, score: number) { + // 返回作者名:互评打分前是匿名的 + async updateScore( + id: string, + score: number, + ): Promise<{ message: string; username?: string }> { const res = await http.put(`/submission/${id}/score`, { score }) return res.data }, @@ -337,11 +345,6 @@ export const Submission = { return res.data }, - async clearAllFlags() { - const res = await http.delete(`/submission/flags`) - return res.data as { cleared: number } - }, - async getStats(taskId: number, classname?: string): Promise { const params: Record = {} if (classname) params.classname = classname @@ -457,6 +460,26 @@ export const Helper = { }, } +// 老师按“班级 × 任务”批改;学生看本班同学的作品 +export const Review = { + async options(): Promise { + return (await http.get("/submission/review/options")).data + }, + async progress(classname: string): Promise { + return (await http.get("/submission/review/progress", { params: { classname } })).data + }, + async classWall(classname: string, taskId: number): Promise { + return ( + await http.get("/submission/review/class", { + params: { classname, task_id: taskId }, + }) + ).data + }, + async peers(taskId: number): Promise { + return (await http.get(`/submission/review/peers/${taskId}`)).data + }, +} + export const Showcase = { async list(): Promise { const res = await http.get("/submission/showcase/") diff --git a/client/src/components/review/PeersDrawer.vue b/client/src/components/review/PeersDrawer.vue new file mode 100644 index 0000000..8663c00 --- /dev/null +++ b/client/src/components/review/PeersDrawer.vue @@ -0,0 +1,205 @@ + + + diff --git a/client/src/components/review/ReviewPanel.vue b/client/src/components/review/ReviewPanel.vue new file mode 100644 index 0000000..55d4a58 --- /dev/null +++ b/client/src/components/review/ReviewPanel.vue @@ -0,0 +1,393 @@ + + + diff --git a/client/src/components/submissions/FlagCell.vue b/client/src/components/submissions/FlagCell.vue deleted file mode 100644 index 56bd370..0000000 --- a/client/src/components/submissions/FlagCell.vue +++ /dev/null @@ -1,74 +0,0 @@ - - - diff --git a/client/src/components/task/TaskPanel.vue b/client/src/components/task/TaskPanel.vue index be74f4a..053a925 100644 --- a/client/src/components/task/TaskPanel.vue +++ b/client/src/components/task/TaskPanel.vue @@ -37,6 +37,14 @@ 素材 + + 同学作品 + 编辑 @@ -54,6 +62,11 @@ + ([]) const showAssets = ref(false) +const showPeers = ref(false) async function loadTutorialAssets(display: number) { if (!display) { diff --git a/client/src/components/task/TaskStatsModal.vue b/client/src/components/task/TaskStatsModal.vue index 22b04c3..774c46a 100644 --- a/client/src/components/task/TaskStatsModal.vue +++ b/client/src/components/task/TaskStatsModal.vue @@ -279,53 +279,8 @@ - -
-
- 标记统计 -
- -
- - {{ - flag.label - }} - {{ - flag.value - }} -
-
-
- -
+
props.show, diff --git a/client/src/components/workbench/RatingCard.vue b/client/src/components/workbench/RatingCard.vue index 747a404..6dfcb2e 100644 --- a/client/src/components/workbench/RatingCard.vue +++ b/client/src/components/workbench/RatingCard.vue @@ -106,8 +106,8 @@ async function rate(score: number) { if (!current.value) return rating.value = true try { - await Submission.updateScore(current.value.submission_id, score) - revealed.value = current.value.username + const res = await Submission.updateScore(current.value.submission_id, score) + revealed.value = res.username ?? "同学" current.value = null showLarge.value = false setTimeout(() => { diff --git a/client/src/pages/ChallengeDetail.vue b/client/src/pages/ChallengeDetail.vue index 4efb6ef..0a63c2f 100644 --- a/client/src/pages/ChallengeDetail.vue +++ b/client/src/pages/ChallengeDetail.vue @@ -15,6 +15,7 @@ + ([]) const exampleCode = ref(null) const showStats = ref(false) const showAssets = ref(false) +const showPeers = ref(false) const assetBaseUrl = computed( () => `/media/tasks/challenge/${challengeDisplay.value}/`, diff --git a/client/src/pages/Dashboard.vue b/client/src/pages/Dashboard.vue index 7b58cac..bc1807b 100644 --- a/client/src/pages/Dashboard.vue +++ b/client/src/pages/Dashboard.vue @@ -50,6 +50,13 @@ const groups = computed(() => { label: "班级", items: [ + { + label: "批改", + icon: "lucide:check-circle", + route: { name: "review" }, + names: ["review"], + show: true, + }, { label: "成绩册", icon: "lucide:graduation-cap", @@ -58,11 +65,12 @@ const groups = computed(() => show: roleSuper.value, }, { + // 全站提交列表留给超管排查问题;平时批改用上面的“批改” label: "全部提交", icon: "lucide:list", route: { name: "teach-submissions", params: { page: 1 } }, names: ["teach-submissions"], - show: true, + show: roleSuper.value, }, ], }, diff --git a/client/src/pages/Gradebook.vue b/client/src/pages/Gradebook.vue index 36cd210..cc0dd78 100644 --- a/client/src/pages/Gradebook.vue +++ b/client/src/pages/Gradebook.vue @@ -83,9 +83,18 @@ style="flex: 1; min-height: 0;" /> + + + + diff --git a/client/src/pages/Submissions.vue b/client/src/pages/Submissions.vue index 859d13c..a13a0d7 100644 --- a/client/src/pages/Submissions.vue +++ b/client/src/pages/Submissions.vue @@ -12,28 +12,18 @@ style="height: 100%; padding-right: 10px; overflow: hidden" > - ()) // 管理员判断 const isAdmin = computed(() => roleAdmin.value || roleSuper.value) -// Flag 过滤选项 -const flagFilterOptions = computed(() => { - const opts = [ - { label: "红旗", value: "red" }, - { label: "蓝旗", value: "blue" }, - { label: "绿旗", value: "green" }, - { label: "黄旗", value: "yellow" }, - { label: "全部", value: "any" }, - ] - if (isAdmin.value) opts.push({ label: "清除", value: "_clear_all" }) - return opts -}) - -function handleFlagSelect(value: string | null) { - if (value === "_clear_all") { - clearAllFlags() - return - } - query.flag = value -} - -async function updateFlag(row: SubmissionOut, flag: FlagType) { - await Submission.updateFlag(row.id, flag) - row.flag = flag -} - -async function clearAllFlags() { - await Submission.clearAllFlags() - data.value = data.value.map((d) => ({ ...d, flag: null })) - query.flag = null -} +// 得分分区(按得分高低和评分人数分的三档,见 server/src/services/score.ts) +const zoneOptions = [ + { label: "优秀", value: "featured" }, + { label: "待定", value: "pending" }, + { label: "待提高", value: "low" }, +] function showChain(submissionId: string, username: string) { chainSubmissionId.value = submissionId @@ -209,28 +172,17 @@ const columns: DataTableColumn[] = [ }), }, { - title: "", - key: "flag", - width: 50, - render: (row) => - h(FlagCell, { - flag: row.flag ?? null, - isAdmin: isAdmin.value, - "onUpdate:flag": (flag: FlagType) => updateFlag(row, flag), - }), - }, - { - title: "", + title: "分区", key: "zone", - width: 42, + width: 64, render: (row) => { const map: Record< string, { label: string; type: "success" | "default" | "warning" } > = { - featured: { label: "夯", type: "success" }, - pending: { label: "N", type: "default" }, - low: { label: "拉", type: "warning" }, + featured: { label: "优秀", type: "success" }, + pending: { label: "待定", type: "default" }, + low: { label: "待提高", type: "warning" }, } if (!row.zone || !map[row.zone]) return null const { label, type } = map[row.zone] @@ -404,13 +356,6 @@ watchDebounced( }, { debounce: 500, maxWait: 1000 }, ) -watch( - () => query.flag, - () => { - if (query.page !== 1) query.page = 1 - else init() - }, -) watch( () => query.zone, () => { diff --git a/client/src/router.ts b/client/src/router.ts index ecda3a0..0175c6a 100644 --- a/client/src/router.ts +++ b/client/src/router.ts @@ -108,11 +108,17 @@ const routes: RouteRecordRaw[] = [ component: () => import("./pages/Gradebook.vue"), meta: { role: "super" }, }, + { + path: "review", + name: "review", + component: () => import("./pages/Review.vue"), + }, { // 和顶层的 /submissions 是同一个页面,放在教学侧栏里打开,侧栏不会消失 path: "submissions/:page", name: "teach-submissions", component: () => import("./pages/Submissions.vue"), + meta: { role: "super" }, }, ], }, @@ -134,11 +140,6 @@ router.beforeEach(async (to) => { const allowed = to.meta.role === "super" ? roleSuper.value : roleStaff.value if (!allowed) return { name: "home" } } - // 教学的默认页:超管从教程开始,管理员只能出挑战 - if (to.name === "dashboard") { - return { - name: roleSuper.value ? "tutorial-editor" : "challenge-editor", - params: { display: 0 }, - } - } + // 教学的默认页是批改 + if (to.name === "dashboard") return { name: "review" } }) diff --git a/client/src/utils/type.ts b/client/src/utils/type.ts index 616829e..3cb8aab 100644 --- a/client/src/utils/type.ts +++ b/client/src/utils/type.ts @@ -39,7 +39,8 @@ export function getRole(role: Role) { }[role] } -export type FlagType = "red" | "blue" | "green" | "yellow" | null +// 只用 present(课上展示);以前的四色标记数据里还可能有 +export type FlagType = "present" | "red" | "blue" | "green" | "yellow" | null export interface TaskAsset { name: string @@ -362,9 +363,9 @@ export interface PromptRound { js: string | null } +// 打分前不带作者(打完分 updateScore 返回) export interface RandomRatingItem { submission_id: string - username: string task_title: string task_display: number task_type: TASK_TYPE @@ -372,3 +373,58 @@ export interface RandomRatingItem { css: string | null js: string | null } + +export interface ReviewOptions { + classes: string[] + tasks: { + id: number + task_type: "tutorial" | "challenge" + display: number + title: string + is_public: boolean + }[] +} + +export interface ReviewProgress { + student_count: number + // task_id → 交了的人数 + submitted: Record +} + +export interface ReviewSubmission { + id: string + score: number + my_score: number + rating_count: number + flag: FlagType + view_count: number + submit_count: number + created: string + html: string + css: string + js: string +} + +export interface ReviewStudent { + user_id: number + username: string + submission: ReviewSubmission | null +} + +export interface PeerWork { + id: string + // 自己打分之前是 null + username: string | null + score: number | null + my_score: number + created: string + html: string + css: string + js: string +} + +export interface PeerWorks { + // 自己还没交这道题 + locked: boolean + items: PeerWork[] +} diff --git a/server/src/routes/review.ts b/server/src/routes/review.ts new file mode 100644 index 0000000..308cdcc --- /dev/null +++ b/server/src/routes/review.ts @@ -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() + +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) { + 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() + 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() + 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 diff --git a/server/src/routes/submission.ts b/server/src/routes/submission.ts index 2beb0d1..f453225 100644 --- a/server/src/routes/submission.ts +++ b/server/src/routes/submission.ts @@ -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() 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(`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 }) diff --git a/server/tests/api.test.ts b/server/tests/api.test.ts index cd1a3e6..92d387d 100644 --- a/server/tests/api.test.ts +++ b/server/tests/api.test.ts @@ -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") + }) +})