- 学生“同学作品”加“本班 / 全部班级”切换(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
@@ -15,8 +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/<prefix>`, CORS, auth middleware, error → `{ detail }` JSON (ninja-compatible; validation errors are 422)
|
- `src/app.ts` — mounts routers under `/api/<prefix>`, 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/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/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`), `review` (mounted under `/submission/review`: teacher class × task wall, students' same-class peer works), `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 — no `classname` means all classes —, students' peer works with `scope=class|all`), `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
|
- Who can read a submission (`canView` in `routes/submission.ts`): staff, the owner, anyone if it's in the showcase, or any student who has submitted the same task (any class). 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/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/<task_id>/?token=<access_token>` (code generation chat; the AI context and the page's chat include `conversation`, `edit` and `manual` messages) and `/ws/guidance/<task_id>/?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
|
- `src/ws.ts` — `/ws/prompt/<task_id>/?token=<access_token>` (code generation chat; the AI context and the page's chat include `conversation`, `edit` and `manual` messages) and `/ws/guidance/<task_id>/?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`
|
- `scripts/` — one-off CLIs: `create-superuser.ts`, `classify-prompts.ts`, `recalculate-scores.ts`
|
||||||
@@ -32,7 +32,7 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1
|
|||||||
- `src/icons.ts` — `<Icon icon="lucide:name" />` renders bundled `lucide-vue-next` icons (works offline); register new names in its map. Fonts are bundled too (`@fontsource/jetbrains-mono`)
|
- `src/icons.ts` — `<Icon icon="lucide:name" />` 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:<user>:<task>`); the tutorial page edits the shared `store/editors.ts` refs but `Workspace.vue` saves them under the same per-task key and `TutorialContent` restores them (example code only the first time)
|
- `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:<user>:<task>`); the tutorial page edits the shared `store/editors.ts` refs but `Workspace.vue` saves them under the same per-task key and `TutorialContent` restores them (example code only the first time)
|
||||||
- `pages/ChallengeHome.vue` — challenge list page; `pages/TaskEditor.vue` (prop `kind`) is the single tutorial / challenge authoring page
|
- `pages/ChallengeHome.vue` — challenge list page; `pages/TaskEditor.vue` (prop `kind`) is the single tutorial / challenge authoring page
|
||||||
- `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' “同学作品”
|
- `pages/Review.vue` (“批改”, default teaching page) + `components/review/ReviewPanel.vue` (also opened from the gradebook) — opens as a large preview (wall hidden, Esc returns; “小面板” switches to a side panel, remembered); keys: J/K next/prev student, 1–5 rate, P present; “全部班级” groups the wall by class; `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`
|
- `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/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)
|
- `src/pages/`, `src/components/` — user management uses `components/dashboard/UserFormModal.vue` (there is no Django Admin anymore)
|
||||||
|
|||||||
+6
-4
@@ -465,18 +465,20 @@ export const Review = {
|
|||||||
async options(): Promise<ReviewOptions> {
|
async options(): Promise<ReviewOptions> {
|
||||||
return (await http.get("/submission/review/options")).data
|
return (await http.get("/submission/review/options")).data
|
||||||
},
|
},
|
||||||
async progress(classname: string): Promise<ReviewProgress> {
|
// classname 不传就是全部班级
|
||||||
|
async progress(classname?: string): Promise<ReviewProgress> {
|
||||||
return (await http.get("/submission/review/progress", { params: { classname } })).data
|
return (await http.get("/submission/review/progress", { params: { classname } })).data
|
||||||
},
|
},
|
||||||
async classWall(classname: string, taskId: number): Promise<ReviewStudent[]> {
|
async classWall(classname: string | undefined, taskId: number): Promise<ReviewStudent[]> {
|
||||||
return (
|
return (
|
||||||
await http.get("/submission/review/class", {
|
await http.get("/submission/review/class", {
|
||||||
params: { classname, task_id: taskId },
|
params: { classname, task_id: taskId },
|
||||||
})
|
})
|
||||||
).data
|
).data
|
||||||
},
|
},
|
||||||
async peers(taskId: number): Promise<PeerWorks> {
|
// scope:class 只看本班,all 看所有班
|
||||||
return (await http.get(`/submission/review/peers/${taskId}`)).data
|
async peers(taskId: number, scope: "class" | "all" = "class"): Promise<PeerWorks> {
|
||||||
|
return (await http.get(`/submission/review/peers/${taskId}`, { params: { scope } })).data
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,15 @@
|
|||||||
style="margin-top: 40px"
|
style="margin-top: 40px"
|
||||||
/>
|
/>
|
||||||
<template v-else-if="data">
|
<template v-else-if="data">
|
||||||
<p class="hint">
|
<n-flex justify="space-between" align="center" class="bar">
|
||||||
本班同学在这道题上最新的作品。打分之前不显示是谁做的,打完分才显示。
|
<span class="hint">
|
||||||
</p>
|
同学们在这道题上最新的作品。打分之前不显示是谁做的,打完分才显示。
|
||||||
|
</span>
|
||||||
|
<n-radio-group v-model:value="scope" size="small">
|
||||||
|
<n-radio-button value="class">本班</n-radio-button>
|
||||||
|
<n-radio-button value="all">全部班级</n-radio-button>
|
||||||
|
</n-radio-group>
|
||||||
|
</n-flex>
|
||||||
<n-empty v-if="!data.items.length" description="还没有同学交这道题" />
|
<n-empty v-if="!data.items.length" description="还没有同学交这道题" />
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div v-for="w in data.items" :key="w.id" class="work">
|
<div v-for="w in data.items" :key="w.id" class="work">
|
||||||
@@ -25,7 +31,10 @@
|
|||||||
></iframe>
|
></iframe>
|
||||||
</button>
|
</button>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span :class="{ anon: !w.username }">{{ w.username ?? "匿名同学" }}</span>
|
<span :class="{ anon: !w.username }">
|
||||||
|
<span v-if="scope === 'all' && w.classname" class="cls">{{ w.classname }} 班</span>
|
||||||
|
{{ w.username ?? "匿名同学" }}
|
||||||
|
</span>
|
||||||
<span v-if="w.my_score" class="mine">我给了 {{ w.my_score }} 分</span>
|
<span v-if="w.my_score" class="mine">我给了 {{ w.my_score }} 分</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!w.my_score" class="rates">
|
<div v-if="!w.my_score" class="rates">
|
||||||
@@ -79,19 +88,26 @@ const data = ref<PeerWorks | null>(null)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rating = ref<string | null>(null)
|
const rating = ref<string | null>(null)
|
||||||
const large = ref<PeerWork | null>(null)
|
const large = ref<PeerWork | null>(null)
|
||||||
|
const scope = ref<"class" | "all">("class")
|
||||||
|
|
||||||
const docOf = (w: PeerWork) =>
|
const docOf = (w: PeerWork) =>
|
||||||
buildPreviewDocument({ html: w.html, css: w.css, js: w.js, assetBaseUrl: props.assetBaseUrl })
|
buildPreviewDocument({ html: w.html, css: w.css, js: w.js, assetBaseUrl: props.assetBaseUrl })
|
||||||
|
|
||||||
watch(show, async (open) => {
|
let seq = 0
|
||||||
if (!open || !props.taskId) return
|
async function load() {
|
||||||
|
if (!props.taskId) return
|
||||||
|
const s = ++seq
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
data.value = await Review.peers(props.taskId)
|
const res = await Review.peers(props.taskId, scope.value)
|
||||||
|
if (s === seq) data.value = res
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (s === seq) loading.value = false
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
watch(show, (open) => open && load())
|
||||||
|
watch(scope, load)
|
||||||
|
|
||||||
async function rate(w: PeerWork, score: number) {
|
async function rate(w: PeerWork, score: number) {
|
||||||
rating.value = w.id
|
rating.value = w.id
|
||||||
@@ -107,12 +123,22 @@ async function rate(w: PeerWork, score: number) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.bar {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
margin: 0 0 12px;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--wp-muted);
|
color: var(--wp-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cls {
|
||||||
|
margin-right: 4px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="panel">
|
<div :class="['panel', { wide }]">
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<div class="who">
|
<div class="who">
|
||||||
<b>{{ sub?.username ?? "…" }}</b>
|
<b>{{ sub?.username ?? "…" }}</b>
|
||||||
@@ -24,14 +24,31 @@
|
|||||||
下一个学生(J)
|
下一个学生(J)
|
||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<n-button size="small" quaternary @click="emit('close')">
|
<n-button
|
||||||
<Icon icon="lucide:x" />
|
v-if="canWiden"
|
||||||
|
size="small"
|
||||||
|
quaternary
|
||||||
|
@click="emit('toggle-wide')"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon :icon="wide ? 'lucide:minimize-2' : 'lucide:maximize-2'" />
|
||||||
|
</template>
|
||||||
|
{{ wide ? "小面板" : "大图" }}
|
||||||
</n-button>
|
</n-button>
|
||||||
|
<n-tooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button size="small" quaternary @click="emit('close')">
|
||||||
|
<Icon icon="lucide:x" />
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
{{ wide ? "返回作品墙(Esc)" : "关闭" }}
|
||||||
|
</n-tooltip>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<n-spin :show="loading" class="body">
|
<n-spin :show="loading" class="body">
|
||||||
<template v-if="sub">
|
<div v-if="sub" class="layout">
|
||||||
|
<div class="stage">
|
||||||
<div class="versions" v-if="versions.length > 1">
|
<div class="versions" v-if="versions.length > 1">
|
||||||
<span class="muted">共交了 {{ versions.length }} 次:</span>
|
<span class="muted">共交了 {{ versions.length }} 次:</span>
|
||||||
<button
|
<button
|
||||||
@@ -53,6 +70,8 @@
|
|||||||
</span>
|
</span>
|
||||||
<n-button text size="small" @click="openFull">全屏打开</n-button>
|
<n-button text size="small" @click="openFull">全屏打开</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
<div class="side">
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h4>打分</h4>
|
<h4>打分</h4>
|
||||||
@@ -106,7 +125,8 @@
|
|||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</div>
|
||||||
|
</div>
|
||||||
</n-spin>
|
</n-spin>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -128,6 +148,10 @@ import type {
|
|||||||
// 老师看一份提交:预览、交过的各个版本、打分、课上展示、加入作品展、提示词链
|
// 老师看一份提交:预览、交过的各个版本、打分、课上展示、加入作品展、提示词链
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
submissionId: string
|
submissionId: string
|
||||||
|
// 大图:预览占满左边,打分和提示词放右边一栏
|
||||||
|
wide?: boolean
|
||||||
|
// 是否显示“大图 / 小面板”切换
|
||||||
|
canWiden?: boolean
|
||||||
// 作品墙里用:能不能切到上一个 / 下一个学生
|
// 作品墙里用:能不能切到上一个 / 下一个学生
|
||||||
nav?: { prev: boolean; next: boolean }
|
nav?: { prev: boolean; next: boolean }
|
||||||
}>()
|
}>()
|
||||||
@@ -135,6 +159,7 @@ const emit = defineEmits<{
|
|||||||
prev: []
|
prev: []
|
||||||
next: []
|
next: []
|
||||||
close: []
|
close: []
|
||||||
|
"toggle-wide": []
|
||||||
// 让作品墙同步更新这张卡片
|
// 让作品墙同步更新这张卡片
|
||||||
changed: [patch: { id: string; my_score?: number; score?: number; flag?: FlagType }]
|
changed: [patch: { id: string; my_score?: number; score?: number; flag?: FlagType }]
|
||||||
}>()
|
}>()
|
||||||
@@ -282,6 +307,41 @@ onUnmounted(() => window.removeEventListener("keydown", onKey))
|
|||||||
padding: 12px 14px 20px;
|
padding: 12px 14px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 大图:左边预览占满高度,右边一栏单独滚动 */
|
||||||
|
.wide .body {
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide .body :deep(.n-spin-content) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide .layout {
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide .stage {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide .stage .frame {
|
||||||
|
flex: 1;
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide .side {
|
||||||
|
border-left: 1px solid var(--wp-line);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 14px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--wp-muted);
|
color: var(--wp-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -361,6 +421,11 @@ h4 {
|
|||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide .rates {
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chain {
|
.chain {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
Lightbulb,
|
Lightbulb,
|
||||||
List,
|
List,
|
||||||
Lock,
|
Lock,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
LogOut,
|
LogOut,
|
||||||
Monitor,
|
Monitor,
|
||||||
Moon,
|
Moon,
|
||||||
@@ -54,6 +56,8 @@ const registry: Record<string, Component> = {
|
|||||||
"chevron-up": ChevronUp,
|
"chevron-up": ChevronUp,
|
||||||
globe: Globe,
|
globe: Globe,
|
||||||
lock: Lock,
|
lock: Lock,
|
||||||
|
"maximize-2": Maximize2,
|
||||||
|
"minimize-2": Minimize2,
|
||||||
"check-circle": CircleCheck,
|
"check-circle": CircleCheck,
|
||||||
download: Download,
|
download: Download,
|
||||||
eye: Eye,
|
eye: Eye,
|
||||||
|
|||||||
@@ -85,11 +85,11 @@
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
<n-drawer
|
<n-drawer
|
||||||
:show="!!reviewId"
|
:show="!!reviewId"
|
||||||
:width="400"
|
width="min(1180px, 92vw)"
|
||||||
placement="right"
|
placement="right"
|
||||||
@update:show="(v: boolean) => !v && (reviewId = null)"
|
@update:show="(v: boolean) => !v && (reviewId = null)"
|
||||||
>
|
>
|
||||||
<ReviewPanel v-if="reviewId" :submission-id="reviewId" @close="reviewId = null" />
|
<ReviewPanel v-if="reviewId" :submission-id="reviewId" wide @close="reviewId = null" />
|
||||||
</n-drawer>
|
</n-drawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
+66
-10
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="review">
|
<div class="review">
|
||||||
<aside class="tasks">
|
<aside v-show="!fullDetail" class="tasks">
|
||||||
<n-select
|
<n-select
|
||||||
v-model:value="classname"
|
v-model:value="classname"
|
||||||
:options="classOptions"
|
:options="classOptions"
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<n-empty v-else description="先选一个班级" size="small" style="margin-top: 40px" />
|
<n-empty v-else description="先选一个班级" size="small" style="margin-top: 40px" />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="wall">
|
<main v-show="!fullDetail" class="wall">
|
||||||
<template v-if="task && classname">
|
<template v-if="task && classname">
|
||||||
<div class="wall-head">
|
<div class="wall-head">
|
||||||
<div class="wall-title">
|
<div class="wall-title">
|
||||||
@@ -59,9 +59,15 @@
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
</div>
|
</div>
|
||||||
<n-spin :show="loadingWall" class="grid-wrap">
|
<n-spin :show="loadingWall" class="grid-wrap">
|
||||||
<div class="grid">
|
<template v-for="g in groups" :key="g.name">
|
||||||
|
<div v-if="g.name" class="group-head">
|
||||||
|
<b>{{ g.name }} 班</b>
|
||||||
|
<span>已交 {{ g.submitted }} / {{ g.total }}</span>
|
||||||
|
<span v-if="g.collapsed">· 这个班还没有人交,已收起</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!g.collapsed" class="grid">
|
||||||
<button
|
<button
|
||||||
v-for="s in visible"
|
v-for="s in g.items"
|
||||||
:key="s.user_id"
|
:key="s.user_id"
|
||||||
:class="['card', { sel: s.submission?.id === selectedId, missing: !s.submission }]"
|
:class="['card', { sel: s.submission?.id === selectedId, missing: !s.submission }]"
|
||||||
:disabled="!s.submission"
|
:disabled="!s.submission"
|
||||||
@@ -86,6 +92,7 @@
|
|||||||
<span v-if="s.submission?.flag === 'present'" class="present">讲评</span>
|
<span v-if="s.submission?.flag === 'present'" class="present">讲评</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
<n-empty
|
<n-empty
|
||||||
v-if="!loadingWall && !visible.length"
|
v-if="!loadingWall && !visible.length"
|
||||||
description="没有符合条件的学生"
|
description="没有符合条件的学生"
|
||||||
@@ -96,9 +103,12 @@
|
|||||||
<n-empty v-else description="选一个班级和任务开始批改" style="margin-top: 120px" />
|
<n-empty v-else description="选一个班级和任务开始批改" style="margin-top: 120px" />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside v-if="selectedId" class="detail">
|
<aside v-if="selectedId" :class="['detail', { full: fullDetail }]">
|
||||||
<ReviewPanel
|
<ReviewPanel
|
||||||
:submission-id="selectedId"
|
:submission-id="selectedId"
|
||||||
|
:wide="fullDetail"
|
||||||
|
can-widen
|
||||||
|
@toggle-wide="bigPreview = !bigPreview"
|
||||||
:nav="{ prev: neighbor(-1) !== null, next: neighbor(1) !== null }"
|
:nav="{ prev: neighbor(-1) !== null, next: neighbor(1) !== null }"
|
||||||
@prev="move(-1)"
|
@prev="move(-1)"
|
||||||
@next="move(1)"
|
@next="move(1)"
|
||||||
@@ -124,6 +134,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue"
|
import { computed, onMounted, onUnmounted, ref, watch } from "vue"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { useStorage } from "@vueuse/core"
|
||||||
import { Icon } from "../icons"
|
import { Icon } from "../icons"
|
||||||
import ReviewPanel from "../components/review/ReviewPanel.vue"
|
import ReviewPanel from "../components/review/ReviewPanel.vue"
|
||||||
import { Review } from "../api"
|
import { Review } from "../api"
|
||||||
@@ -143,9 +154,18 @@ const taskId = ref<number | null>(route.query.task ? Number(route.query.task) :
|
|||||||
const filter = ref<"all" | "unrated" | "missing" | "present">("all")
|
const filter = ref<"all" | "unrated" | "missing" | "present">("all")
|
||||||
const selectedId = ref<string | null>(null)
|
const selectedId = ref<string | null>(null)
|
||||||
const presenting = ref(false)
|
const presenting = ref(false)
|
||||||
|
// 点开作品默认用大图(预览占满中间),老师也可以切回旁边的小面板;记住选择
|
||||||
|
const bigPreview = useStorage("review-big-preview", true)
|
||||||
|
const fullDetail = computed(() => !!selectedId.value && bigPreview.value)
|
||||||
const presentIndex = ref(0)
|
const presentIndex = ref(0)
|
||||||
|
|
||||||
const classOptions = computed(() => options.value.classes.map((c) => ({ label: `${c} 班`, value: c })))
|
// 全部班级:一道题全校的作品放在一面墙上,按班级分组
|
||||||
|
const ALL = "__all__"
|
||||||
|
const classOptions = computed(() => [
|
||||||
|
{ label: "全部班级", value: ALL },
|
||||||
|
...options.value.classes.map((c) => ({ label: `${c} 班`, value: c })),
|
||||||
|
])
|
||||||
|
const classParam = () => (classname.value === ALL ? undefined : classname.value ?? undefined)
|
||||||
const task = computed(() => options.value.tasks.find((t) => t.id === taskId.value))
|
const task = computed(() => options.value.tasks.find((t) => t.id === taskId.value))
|
||||||
const taskGroups = computed(() => [
|
const taskGroups = computed(() => [
|
||||||
{ label: "教程", items: options.value.tasks.filter((t) => t.task_type === "tutorial") },
|
{ label: "教程", items: options.value.tasks.filter((t) => t.task_type === "tutorial") },
|
||||||
@@ -171,6 +191,22 @@ const visible = computed(() =>
|
|||||||
return true
|
return true
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
const groups = computed(() => {
|
||||||
|
if (classname.value !== ALL) {
|
||||||
|
return [{ name: "", items: visible.value, submitted: 0, total: 0, collapsed: false }]
|
||||||
|
}
|
||||||
|
const map = new Map<string, ReviewStudent[]>()
|
||||||
|
for (const s of visible.value) {
|
||||||
|
if (!map.has(s.classname)) map.set(s.classname, [])
|
||||||
|
map.get(s.classname)!.push(s)
|
||||||
|
}
|
||||||
|
return [...map].map(([name, items]) => {
|
||||||
|
const all = students.value.filter((s) => s.classname === name)
|
||||||
|
const submitted = all.filter((s) => s.submission).length
|
||||||
|
// 全校一起看时,没人交的班(这道题没布置给它)只留一行标题,不然一屏都是“未交”
|
||||||
|
return { name, items, submitted, total: all.length, collapsed: !submitted && filter.value !== "missing" }
|
||||||
|
})
|
||||||
|
})
|
||||||
const presentList = computed(() => students.value.filter((s) => s.submission?.flag === "present"))
|
const presentList = computed(() => students.value.filter((s) => s.submission?.flag === "present"))
|
||||||
|
|
||||||
const docs = new Map<string, string>()
|
const docs = new Map<string, string>()
|
||||||
@@ -197,7 +233,7 @@ async function loadWall() {
|
|||||||
const s = ++wallSeq
|
const s = ++wallSeq
|
||||||
loadingWall.value = true
|
loadingWall.value = true
|
||||||
try {
|
try {
|
||||||
const data = await Review.classWall(classname.value, taskId.value)
|
const data = await Review.classWall(classParam(), taskId.value)
|
||||||
if (s === wallSeq) students.value = data
|
if (s === wallSeq) students.value = data
|
||||||
} finally {
|
} finally {
|
||||||
if (s === wallSeq) loadingWall.value = false
|
if (s === wallSeq) loadingWall.value = false
|
||||||
@@ -206,7 +242,7 @@ async function loadWall() {
|
|||||||
|
|
||||||
async function loadProgress() {
|
async function loadProgress() {
|
||||||
progress.value = null
|
progress.value = null
|
||||||
if (classname.value) progress.value = await Review.progress(classname.value)
|
if (classname.value) progress.value = await Review.progress(classParam())
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(classname, (c) => {
|
watch(classname, (c) => {
|
||||||
@@ -254,7 +290,8 @@ function onKey(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
const t = e.target as HTMLElement
|
const t = e.target as HTMLElement
|
||||||
if (t.closest("input, textarea, [contenteditable]") || e.ctrlKey || e.metaKey || e.altKey) return
|
if (t.closest("input, textarea, [contenteditable]") || e.ctrlKey || e.metaKey || e.altKey) return
|
||||||
if (e.key === "j" || e.key === "J") move(1)
|
if (e.key === "Escape" && selectedId.value) selectedId.value = null
|
||||||
|
else if (e.key === "j" || e.key === "J") move(1)
|
||||||
else if (e.key === "k" || e.key === "K") move(-1)
|
else if (e.key === "k" || e.key === "K") move(-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +300,8 @@ onMounted(async () => {
|
|||||||
options.value = await Review.options()
|
options.value = await Review.options()
|
||||||
if (!classname.value) {
|
if (!classname.value) {
|
||||||
const saved = localStorage.getItem("review-class")
|
const saved = localStorage.getItem("review-class")
|
||||||
classname.value = saved && options.value.classes.includes(saved) ? saved : null
|
classname.value =
|
||||||
|
saved && (saved === ALL || options.value.classes.includes(saved)) ? saved : null
|
||||||
} else {
|
} else {
|
||||||
loadProgress()
|
loadProgress()
|
||||||
loadWall()
|
loadWall()
|
||||||
@@ -401,6 +439,18 @@ onUnmounted(() => window.removeEventListener("keydown", onKey))
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.group-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-head span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--wp-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||||
@@ -522,6 +572,12 @@ onUnmounted(() => window.removeEventListener("keydown", onKey))
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail.full {
|
||||||
|
flex: 1;
|
||||||
|
width: auto;
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.present-mode {
|
.present-mode {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -410,6 +410,7 @@ export interface ReviewSubmission {
|
|||||||
export interface ReviewStudent {
|
export interface ReviewStudent {
|
||||||
user_id: number
|
user_id: number
|
||||||
username: string
|
username: string
|
||||||
|
classname: string
|
||||||
submission: ReviewSubmission | null
|
submission: ReviewSubmission | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,6 +418,7 @@ export interface PeerWork {
|
|||||||
id: string
|
id: string
|
||||||
// 自己打分之前是 null
|
// 自己打分之前是 null
|
||||||
username: string | null
|
username: string | null
|
||||||
|
classname: string
|
||||||
score: number | null
|
score: number | null
|
||||||
my_score: number
|
my_score: number
|
||||||
created: string
|
created: string
|
||||||
|
|||||||
+44
-24
@@ -14,6 +14,15 @@ function requireStaff(user: User) {
|
|||||||
// 和成绩册一致:班里的学生包括当助教的 admin
|
// 和成绩册一致:班里的学生包括当助教的 admin
|
||||||
const CLASS_MEMBERS = `SELECT id FROM account_user
|
const CLASS_MEMBERS = `SELECT id FROM account_user
|
||||||
WHERE classname = $classname AND role IN ('normal', 'admin') AND is_active = 1`
|
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)
|
// 先只在 slim 覆盖索引上找,再按 rowid 读这几行的完整内容(代码是宽列,见 CLAUDE.md)
|
||||||
@@ -64,17 +73,16 @@ router.get("/options", (c) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 一个班每个任务交了几个人
|
// 一个班每个任务交了几个人
|
||||||
|
// 不传 classname 就是全部班级
|
||||||
router.get("/progress", (c) => {
|
router.get("/progress", (c) => {
|
||||||
requireStaff(requireUser(c))
|
requireStaff(requireUser(c))
|
||||||
const { classname } = query(c, z.object({ classname: z.string().min(1) }))
|
const { classname } = query(c, z.object({ classname: z.string().min(1).optional() }))
|
||||||
const studentCount = get<{ n: number }>(
|
const m = members(classname)
|
||||||
`SELECT COUNT(*) AS n FROM (${CLASS_MEMBERS})`,
|
const studentCount = get<{ n: number }>(`SELECT COUNT(*) AS n FROM (${m.sql})`, m.params)!.n
|
||||||
{ classname },
|
|
||||||
)!.n
|
|
||||||
const rows = all<{ task_id: number; n: number }>(
|
const rows = all<{ task_id: number; n: number }>(
|
||||||
`SELECT s.task_id, COUNT(DISTINCT s.user_id) AS n FROM submission_submission s
|
`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`,
|
WHERE s.user_id IN (${m.sql}) GROUP BY s.task_id`,
|
||||||
{ classname },
|
m.params,
|
||||||
)
|
)
|
||||||
return c.json({
|
return c.json({
|
||||||
student_count: studentCount,
|
student_count: studentCount,
|
||||||
@@ -86,15 +94,18 @@ router.get("/progress", (c) => {
|
|||||||
router.get("/class", (c) => {
|
router.get("/class", (c) => {
|
||||||
const user = requireUser(c)
|
const user = requireUser(c)
|
||||||
requireStaff(user)
|
requireStaff(user)
|
||||||
const q = query(c, z.object({ classname: z.string().min(1), task_id: z.coerce.number().int() }))
|
const q = query(
|
||||||
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: q.task_id }), "任务不存在")
|
c,
|
||||||
const students = all<{ id: number; username: string }>(
|
z.object({ classname: z.string().min(1).optional(), task_id: z.coerce.number().int() }),
|
||||||
`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 })
|
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)
|
const rows = loadRows([...latest.values()].map((l) => l.rid), user.id)
|
||||||
return c.json(
|
return c.json(
|
||||||
students.map((s) => {
|
students.map((s) => {
|
||||||
@@ -102,6 +113,7 @@ router.get("/class", (c) => {
|
|||||||
return {
|
return {
|
||||||
user_id: s.id,
|
user_id: s.id,
|
||||||
username: s.username,
|
username: s.username,
|
||||||
|
classname: s.classname,
|
||||||
submission: r
|
submission: r
|
||||||
? {
|
? {
|
||||||
id: uuidOut(r.id),
|
id: uuidOut(r.id),
|
||||||
@@ -122,33 +134,41 @@ router.get("/class", (c) => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 学生看本班同学在这道题上的作品:自己交过才开放;自己打分之前不显示作者和得分
|
// 学生看同学在这道题上的作品:自己交过才开放;自己打分之前不显示作者和得分。
|
||||||
|
// scope=class 只看本班(默认,一个班几十份),scope=all 看全校所有班
|
||||||
router.get("/peers/:task_id", (c) => {
|
router.get("/peers/:task_id", (c) => {
|
||||||
const user = requireUser(c)
|
const user = requireUser(c)
|
||||||
const taskId = intParam(c, "task_id")
|
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 }), "任务不存在")
|
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: taskId }), "任务不存在")
|
||||||
const submitted = get(
|
const submitted = get(
|
||||||
`SELECT 1 FROM submission_submission WHERE user_id = $me AND task_id = $task_id LIMIT 1`,
|
`SELECT 1 FROM submission_submission WHERE user_id = $me AND task_id = $task_id LIMIT 1`,
|
||||||
{ me: user.id, task_id: taskId },
|
{ 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
|
const members = `SELECT id FROM account_user
|
||||||
WHERE classname = $classname AND role = 'normal' AND is_active = 1 AND id != $me`
|
WHERE role = 'normal' AND is_active = 1 AND id != $me
|
||||||
const params = { classname: user.classname, me: user.id }
|
${byClass ? "AND classname = $classname" : ""}`
|
||||||
|
const params = byClass ? { classname: user.classname, me: user.id } : { me: user.id }
|
||||||
const latest = latestByUser(taskId, members, params)
|
const latest = latestByUser(taskId, members, params)
|
||||||
const rows = loadRows([...latest.values()].map((l) => l.rid), user.id)
|
const rows = loadRows([...latest.values()].map((l) => l.rid), user.id)
|
||||||
const names = new Map(
|
const people = new Map(
|
||||||
all<{ id: number; username: string }>(`SELECT id, username FROM account_user WHERE id IN (${members})`, params).map(
|
all<{ id: number; username: string; classname: string }>(
|
||||||
(u) => [u.id, u.username],
|
`SELECT id, username, classname FROM account_user WHERE id IN (${members})`,
|
||||||
),
|
params,
|
||||||
|
).map((u) => [u.id, u]),
|
||||||
)
|
)
|
||||||
const items = [...rows.values()]
|
const items = [...rows.values()]
|
||||||
.sort((a, b) => (a.created < b.created ? 1 : -1))
|
.sort((a, b) => (a.created < b.created ? 1 : -1))
|
||||||
.map((r) => {
|
.map((r) => {
|
||||||
const rated = (r.my_score || 0) > 0
|
const rated = (r.my_score || 0) > 0
|
||||||
|
const p = people.get(r.user_id)
|
||||||
return {
|
return {
|
||||||
id: uuidOut(r.id),
|
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,
|
score: rated ? r.score : null,
|
||||||
my_score: r.my_score || 0,
|
my_score: r.my_score || 0,
|
||||||
created: iso(r.created),
|
created: iso(r.created),
|
||||||
|
|||||||
@@ -37,16 +37,13 @@ const router = new Hono<Env>()
|
|||||||
router.route("/showcase", showcase)
|
router.route("/showcase", showcase)
|
||||||
router.route("/review", review)
|
router.route("/review", review)
|
||||||
|
|
||||||
// 学生能看的提交:自己的、进了作品展的、本班同学在自己也交过的题上的。老师都能看
|
// 学生能看的提交:自己的、进了作品展的、自己也交过的那道题上同学(任何班)的。老师都能看
|
||||||
function canView(user: User, sub: { id: string; user_id: number; task_id: number }) {
|
function canView(user: User, sub: { id: string; user_id: number; task_id: number }) {
|
||||||
if (isStaff(user) || sub.user_id === user.id) return true
|
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 (get(`SELECT 1 FROM submission_submissionaward WHERE submission_id = $id`, { id: sub.id })) return true
|
||||||
if (!user.classname) return false
|
|
||||||
return !!get(
|
return !!get(
|
||||||
`SELECT 1 FROM account_user u
|
`SELECT 1 FROM submission_submission s WHERE s.user_id = $me AND s.task_id = $task_id LIMIT 1`,
|
||||||
WHERE u.id = $owner AND u.classname = $classname
|
{ me: user.id, task_id: sub.task_id },
|
||||||
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 },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -495,7 +495,7 @@ describe("任务素材", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("同学作品与批改", () => {
|
describe("同学作品与批改", () => {
|
||||||
test("学生只能看自己的、作品展里的、本班同学在自己也交过的题上的提交", async () => {
|
test("学生只能看自己的、作品展里的、自己也交过的题上同学(任何班)的提交", async () => {
|
||||||
const me = makeUser("me", "normal", "c1")
|
const me = makeUser("me", "normal", "c1")
|
||||||
const mate = makeUser("mate", "normal", "c1")
|
const mate = makeUser("mate", "normal", "c1")
|
||||||
const other = makeUser("other", "normal", "c2")
|
const other = makeUser("other", "normal", "c2")
|
||||||
@@ -509,10 +509,15 @@ describe("同学作品与批改", () => {
|
|||||||
const locked = await stu("GET", `/api/submission/review/peers/${task}`)
|
const locked = await stu("GET", `/api/submission/review/peers/${task}`)
|
||||||
expect(locked.json).toEqual({ locked: true, items: [] })
|
expect(locked.json).toEqual({ locked: true, items: [] })
|
||||||
|
|
||||||
|
expect((await stu("GET", `/api/submission/${uuid(otherSub)}`)).status).toBe(403)
|
||||||
makeSubmission(me, task)
|
makeSubmission(me, task)
|
||||||
expect((await stu("GET", `/api/submission/${uuid(mateSub)}`)).status).toBe(200)
|
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/")
|
const list = await stu("GET", "/api/submission/")
|
||||||
expect(list.json.items.map((s: any) => s.username)).toEqual(["me"])
|
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")
|
const random = await (await client("other"))("GET", "/api/submission/random-for-rating")
|
||||||
expect(random.json.username).toBeUndefined()
|
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 () => {
|
test("老师按班级 × 任务看作品墙;课上展示用 flag", async () => {
|
||||||
@@ -558,8 +571,20 @@ describe("同学作品与批改", () => {
|
|||||||
])
|
])
|
||||||
const progress = await teacher("GET", "/api/submission/review/progress?classname=c1")
|
const progress = await teacher("GET", "/api/submission/review/progress?classname=c1")
|
||||||
expect(progress.json).toEqual({ student_count: 2, submitted: { [task]: 1 } })
|
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")
|
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")
|
expect((await teacher("PUT", `/api/submission/${uuid(latest)}/flag`, { flag: "present" })).json.flag).toBe("present")
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in new issue
Block a user