feat: 改版阶段 3:老师按班级 × 任务批改,学生看本班同学作品
老师 - 新的“批改”页(教学默认页):左边班级 + 任务列表和完成进度,中间作品墙 (每人最新一版、未交 / 我未评 / 分数),右侧详情面板:各版本、提示词链、打分、 加入课上展示、加入作品展;J/K 切换学生,1–5 打分,P 课上展示 - “开始讲评”全屏依次播放课上展示的作品;成绩册点分数打开同一个详情面板 - 四色标记和“一键清除全站标记”去掉,flag 只用 present(课上展示);全部提交只给超管 学生 - “同学作品”:只看本班、自己交过这道题才开放,打分前匿名(服务端不返回作者) - 学生的提交列表只有自己的;别人的提交和提示词链只有本班同学交过同一题、或进了作品展才能看 - 随机互评不再返回作者,打完分由 PUT /:id/score 返回 服务端新增 routes/review.ts(/submission/review:options、progress、class、peers), 查询走 slim 覆盖索引 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9c6c01eba1
commit
df3f842dac
18 files changed
+1616
-275
No files matched your search
@@ -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/<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/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/<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`
|
||||
@@ -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` — `<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 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)
|
||||
|
||||
+29
-6
@@ -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<TaskStatsOut> {
|
||||
const params: Record<string, string | number> = {}
|
||||
if (classname) params.classname = classname
|
||||
@@ -457,6 +460,26 @@ export const Helper = {
|
||||
},
|
||||
}
|
||||
|
||||
// 老师按“班级 × 任务”批改;学生看本班同学的作品
|
||||
export const Review = {
|
||||
async options(): Promise<ReviewOptions> {
|
||||
return (await http.get("/submission/review/options")).data
|
||||
},
|
||||
async progress(classname: string): Promise<ReviewProgress> {
|
||||
return (await http.get("/submission/review/progress", { params: { classname } })).data
|
||||
},
|
||||
async classWall(classname: string, taskId: number): Promise<ReviewStudent[]> {
|
||||
return (
|
||||
await http.get("/submission/review/class", {
|
||||
params: { classname, task_id: taskId },
|
||||
})
|
||||
).data
|
||||
},
|
||||
async peers(taskId: number): Promise<PeerWorks> {
|
||||
return (await http.get(`/submission/review/peers/${taskId}`)).data
|
||||
},
|
||||
}
|
||||
|
||||
export const Showcase = {
|
||||
async list(): Promise<AwardSection[]> {
|
||||
const res = await http.get("/submission/showcase/")
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<n-drawer v-model:show="show" :width="600" placement="right">
|
||||
<n-drawer-content title="同学作品" :native-scrollbar="false">
|
||||
<n-spin :show="loading">
|
||||
<n-result
|
||||
v-if="data?.locked"
|
||||
status="info"
|
||||
title="交过这道题以后就能看到"
|
||||
description="先完成自己的作品并提交,再来看看同学们是怎么做的。"
|
||||
style="margin-top: 40px"
|
||||
/>
|
||||
<template v-else-if="data">
|
||||
<p class="hint">
|
||||
本班同学在这道题上最新的作品。打分之前不显示是谁做的,打完分才显示。
|
||||
</p>
|
||||
<n-empty v-if="!data.items.length" description="还没有同学交这道题" />
|
||||
<div class="grid">
|
||||
<div v-for="w in data.items" :key="w.id" class="work">
|
||||
<button class="thumb" title="点击放大看" @click="large = w">
|
||||
<iframe
|
||||
:sandbox="PREVIEW_SANDBOX"
|
||||
:srcdoc="docOf(w)"
|
||||
loading="lazy"
|
||||
tabindex="-1"
|
||||
></iframe>
|
||||
</button>
|
||||
<div class="info">
|
||||
<span :class="{ anon: !w.username }">{{ w.username ?? "匿名同学" }}</span>
|
||||
<span v-if="w.my_score" class="mine">我给了 {{ w.my_score }} 分</span>
|
||||
</div>
|
||||
<div v-if="!w.my_score" class="rates">
|
||||
<n-tooltip v-for="s in scores" :key="s.value">
|
||||
<template #trigger>
|
||||
<button class="rate" :disabled="rating === w.id" @click="rate(w, s.value)">
|
||||
{{ s.value }}
|
||||
</button>
|
||||
</template>
|
||||
{{ s.value }} 分:{{ s.label }}
|
||||
</n-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</n-spin>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
<n-modal
|
||||
:show="!!large"
|
||||
preset="card"
|
||||
:title="large?.username ?? '匿名同学的作品'"
|
||||
style="width: 90vw; max-width: 1000px"
|
||||
@update:show="(v: boolean) => !v && (large = null)"
|
||||
>
|
||||
<div v-if="large" class="large">
|
||||
<iframe :sandbox="PREVIEW_SANDBOX" :srcdoc="docOf(large)"></iframe>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from "vue"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { Review, Submission } from "../../api"
|
||||
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../../utils/previewDocument"
|
||||
import type { PeerWork, PeerWorks } from "../../utils/type"
|
||||
|
||||
const show = defineModel<boolean>("show", { required: true })
|
||||
const props = defineProps<{ taskId: number; assetBaseUrl?: string }>()
|
||||
const message = useMessage()
|
||||
|
||||
const scores = [
|
||||
{ value: 1, label: "没完成题目要求" },
|
||||
{ value: 2, label: "完成了一小部分" },
|
||||
{ value: 3, label: "基本完成" },
|
||||
{ value: 4, label: "完成得不错" },
|
||||
{ value: 5, label: "完成且有自己的想法" },
|
||||
]
|
||||
|
||||
const data = ref<PeerWorks | null>(null)
|
||||
const loading = ref(false)
|
||||
const rating = ref<string | null>(null)
|
||||
const large = ref<PeerWork | null>(null)
|
||||
|
||||
const docOf = (w: PeerWork) =>
|
||||
buildPreviewDocument({ html: w.html, css: w.css, js: w.js, assetBaseUrl: props.assetBaseUrl })
|
||||
|
||||
watch(show, async (open) => {
|
||||
if (!open || !props.taskId) return
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await Review.peers(props.taskId)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function rate(w: PeerWork, score: number) {
|
||||
rating.value = w.id
|
||||
try {
|
||||
const res = await Submission.updateScore(w.id, score)
|
||||
w.my_score = score
|
||||
w.username = res.username ?? null
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.detail ?? "打分失败,请重试")
|
||||
} finally {
|
||||
rating.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.work {
|
||||
border: 1px solid var(--wp-line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--wp-surface);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--wp-line);
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.thumb iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 300%;
|
||||
height: 300%;
|
||||
border: 0;
|
||||
transform: scale(0.3333);
|
||||
transform-origin: 0 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.anon {
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.mine {
|
||||
color: var(--wp-brand);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rates {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 4px 10px 10px;
|
||||
}
|
||||
|
||||
.rate {
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
border: 1px solid var(--wp-line);
|
||||
border-radius: 6px;
|
||||
background: var(--wp-surface);
|
||||
color: var(--wp-ink);
|
||||
font: 600 13px var(--wp-font-mono);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rate:hover:not(:disabled) {
|
||||
border-color: var(--wp-brand);
|
||||
background: var(--wp-brand-soft);
|
||||
color: var(--wp-brand);
|
||||
}
|
||||
|
||||
.large {
|
||||
height: 70vh;
|
||||
border: 1px solid var(--wp-line);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.large iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,393 @@
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="head">
|
||||
<div class="who">
|
||||
<b>{{ sub?.username ?? "…" }}</b>
|
||||
<span class="muted">{{ sub?.task_title }}</span>
|
||||
</div>
|
||||
<n-flex :size="4" :wrap="false" align="center">
|
||||
<template v-if="nav">
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button size="small" quaternary :disabled="!nav.prev" @click="emit('prev')">
|
||||
<Icon icon="lucide:arrow-left" />
|
||||
</n-button>
|
||||
</template>
|
||||
上一个学生(K)
|
||||
</n-tooltip>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button size="small" quaternary :disabled="!nav.next" @click="emit('next')">
|
||||
<Icon icon="lucide:arrow-right" />
|
||||
</n-button>
|
||||
</template>
|
||||
下一个学生(J)
|
||||
</n-tooltip>
|
||||
</template>
|
||||
<n-button size="small" quaternary @click="emit('close')">
|
||||
<Icon icon="lucide:x" />
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</div>
|
||||
|
||||
<n-spin :show="loading" class="body">
|
||||
<template v-if="sub">
|
||||
<div class="versions" v-if="versions.length > 1">
|
||||
<span class="muted">共交了 {{ versions.length }} 次:</span>
|
||||
<button
|
||||
v-for="(v, i) in versions"
|
||||
:key="v.id"
|
||||
:class="['ver', { on: v.id === currentId }]"
|
||||
@click="currentId = v.id"
|
||||
>
|
||||
第 {{ versions.length - i }} 次
|
||||
</button>
|
||||
</div>
|
||||
<div class="frame">
|
||||
<iframe :sandbox="PREVIEW_SANDBOX" :srcdoc="doc"></iframe>
|
||||
</div>
|
||||
<n-flex justify="space-between" align="center" class="meta">
|
||||
<span class="muted">
|
||||
{{ parseTime(sub.created, "MM-DD HH:mm") }} 提交 ·
|
||||
{{ sub.score ? `得分 ${sub.score.toFixed(2)}` : "还没有人评分" }}
|
||||
</span>
|
||||
<n-button text size="small" @click="openFull">全屏打开</n-button>
|
||||
</n-flex>
|
||||
|
||||
<section>
|
||||
<h4>打分</h4>
|
||||
<div v-if="sub.my_score" class="muted">你已经给了 {{ sub.my_score }} 分</div>
|
||||
<div v-else class="rates">
|
||||
<button
|
||||
v-for="n in 5"
|
||||
:key="n"
|
||||
class="rate"
|
||||
:disabled="rating"
|
||||
@click="rate(n)"
|
||||
>
|
||||
{{ n }}
|
||||
</button>
|
||||
<span class="muted hint">数字键 1–5</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4>操作</h4>
|
||||
<n-flex :size="8">
|
||||
<n-button
|
||||
size="small"
|
||||
:type="sub.flag === 'present' ? 'primary' : 'default'"
|
||||
@click="togglePresent"
|
||||
>
|
||||
{{ sub.flag === "present" ? "已加入课上展示" : "加入课上展示" }}
|
||||
</n-button>
|
||||
<n-dropdown
|
||||
:options="awardOptions"
|
||||
trigger="click"
|
||||
:disabled="!awardOptions.length"
|
||||
@select="addToAward"
|
||||
>
|
||||
<n-button size="small">加入作品展</n-button>
|
||||
</n-dropdown>
|
||||
</n-flex>
|
||||
<div class="muted hint">P 键:加入 / 取消课上展示</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4>提示词 <span class="muted" v-if="chain.length">· {{ chain.length }} 轮</span></h4>
|
||||
<div v-if="!chain.length" class="muted">这份提交没有关联的提示词</div>
|
||||
<ol class="chain">
|
||||
<li v-for="(r, i) in chain" :key="i">
|
||||
<n-flex :size="6" align="center">
|
||||
<span class="src">{{ sourceLabel(r.source) }}</span>
|
||||
<span v-if="r.prompt_level" class="level">L{{ r.prompt_level }}</span>
|
||||
</n-flex>
|
||||
<div class="q">{{ r.question }}</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { Icon } from "../../icons"
|
||||
import { Showcase, Submission } from "../../api"
|
||||
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../../utils/previewDocument"
|
||||
import { parseTime, sourceLabel } from "../../utils/helper"
|
||||
import type {
|
||||
AwardManageOut,
|
||||
FlagType,
|
||||
PromptRound,
|
||||
SubmissionAll,
|
||||
SubmissionOut,
|
||||
} from "../../utils/type"
|
||||
|
||||
// 老师看一份提交:预览、交过的各个版本、打分、课上展示、加入作品展、提示词链
|
||||
const props = defineProps<{
|
||||
submissionId: string
|
||||
// 作品墙里用:能不能切到上一个 / 下一个学生
|
||||
nav?: { prev: boolean; next: boolean }
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
prev: []
|
||||
next: []
|
||||
close: []
|
||||
// 让作品墙同步更新这张卡片
|
||||
changed: [patch: { id: string; my_score?: number; score?: number; flag?: FlagType }]
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const currentId = ref(props.submissionId)
|
||||
const sub = ref<SubmissionAll | null>(null)
|
||||
const versions = ref<SubmissionOut[]>([])
|
||||
const chain = ref<PromptRound[]>([])
|
||||
const rating = ref(false)
|
||||
|
||||
let awardsCache: Promise<AwardManageOut[]> | null = null
|
||||
const awards = ref<AwardManageOut[]>([])
|
||||
const awardOptions = computed(() =>
|
||||
awards.value.filter((a) => a.is_active).map((a) => ({ label: a.name, key: a.id })),
|
||||
)
|
||||
|
||||
const doc = computed(() =>
|
||||
sub.value
|
||||
? buildPreviewDocument({
|
||||
html: sub.value.html,
|
||||
css: sub.value.css,
|
||||
js: sub.value.js,
|
||||
assetBaseUrl: `/media/tasks/${sub.value.task_type}/${sub.value.task_display}/`,
|
||||
})
|
||||
: "",
|
||||
)
|
||||
|
||||
let seq = 0
|
||||
async function load(id: string) {
|
||||
const s = ++seq
|
||||
loading.value = true
|
||||
try {
|
||||
const data: SubmissionAll = await Submission.get(id)
|
||||
if (s !== seq) return
|
||||
sub.value = data
|
||||
const [list, rounds] = await Promise.all([
|
||||
Submission.listByUserTask(data.userid, data.task_id),
|
||||
Submission.getPromptChain(id).catch(() => [] as PromptRound[]),
|
||||
])
|
||||
if (s !== seq) return
|
||||
versions.value = list
|
||||
chain.value = rounds
|
||||
} finally {
|
||||
if (s === seq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.submissionId,
|
||||
(id) => {
|
||||
currentId.value = id
|
||||
},
|
||||
)
|
||||
watch(currentId, load, { immediate: true })
|
||||
|
||||
async function rate(score: number) {
|
||||
if (!sub.value || sub.value.my_score || rating.value) return
|
||||
rating.value = true
|
||||
try {
|
||||
const res = await Submission.updateScore(sub.value.id, score)
|
||||
sub.value.my_score = score
|
||||
message.success(res.message)
|
||||
// 重新取一次得分(加权平均由服务端算)
|
||||
const fresh: SubmissionAll = await Submission.get(sub.value.id)
|
||||
sub.value.score = fresh.score
|
||||
emit("changed", { id: sub.value.id, my_score: score, score: fresh.score })
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.detail ?? "打分失败")
|
||||
} finally {
|
||||
rating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePresent() {
|
||||
if (!sub.value) return
|
||||
const flag: FlagType = sub.value.flag === "present" ? null : "present"
|
||||
await Submission.updateFlag(sub.value.id, flag)
|
||||
sub.value.flag = flag
|
||||
emit("changed", { id: sub.value.id, flag })
|
||||
}
|
||||
|
||||
async function addToAward(awardId: number) {
|
||||
if (!sub.value) return
|
||||
try {
|
||||
await Showcase.addAwardItem(awardId, { submission_id: sub.value.id, sort_order: 0 })
|
||||
const name = awards.value.find((a) => a.id === awardId)?.name
|
||||
message.success(`已加入作品展「${name}」`)
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.detail ?? "加入失败")
|
||||
}
|
||||
}
|
||||
|
||||
function openFull() {
|
||||
if (sub.value) window.open(`/submission/${sub.value.id}`, "_blank")
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const t = e.target as HTMLElement
|
||||
if (t.closest("input, textarea, [contenteditable]") || e.ctrlKey || e.metaKey || e.altKey) return
|
||||
if (/^[1-5]$/.test(e.key)) rate(Number(e.key))
|
||||
else if (e.key === "p" || e.key === "P") togglePresent()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener("keydown", onKey)
|
||||
awardsCache ??= Showcase.listManageAwards()
|
||||
awards.value = await awardsCache.catch(() => [])
|
||||
})
|
||||
onUnmounted(() => window.removeEventListener("keydown", onKey))
|
||||
</script>
|
||||
<style scoped>
|
||||
.panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--wp-surface);
|
||||
}
|
||||
|
||||
.head {
|
||||
height: 44px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 8px 0 14px;
|
||||
border-bottom: 1px solid var(--wp-line);
|
||||
}
|
||||
|
||||
.who {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px 14px 20px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--wp-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.versions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ver {
|
||||
padding: 1px 8px;
|
||||
border: 1px solid var(--wp-line);
|
||||
border-radius: 4px;
|
||||
background: var(--wp-surface);
|
||||
color: var(--wp-ink);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ver.on {
|
||||
border-color: var(--wp-brand);
|
||||
color: var(--wp-brand);
|
||||
}
|
||||
|
||||
.frame {
|
||||
height: 260px;
|
||||
border: 1px solid var(--wp-line);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.frame iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rates {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rate {
|
||||
width: 40px;
|
||||
padding: 6px 0;
|
||||
border: 1px solid var(--wp-line);
|
||||
border-radius: 6px;
|
||||
background: var(--wp-surface);
|
||||
color: var(--wp-ink);
|
||||
font: 600 14px var(--wp-font-mono);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rate:hover:not(:disabled) {
|
||||
border-color: var(--wp-brand);
|
||||
background: var(--wp-brand-soft);
|
||||
color: var(--wp-brand);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.chain {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.src {
|
||||
font-size: 11px;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.level {
|
||||
font: 600 11px var(--wp-font-mono);
|
||||
color: var(--wp-brand);
|
||||
}
|
||||
|
||||
.q {
|
||||
font-size: 13px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,74 +0,0 @@
|
||||
<template>
|
||||
<n-popover v-if="isAdmin" trigger="click">
|
||||
<template #trigger>
|
||||
<span :style="dotStyle" />
|
||||
</template>
|
||||
<n-space vertical size="small">
|
||||
<n-button
|
||||
v-for="opt in FLAG_OPTIONS"
|
||||
:key="opt.value"
|
||||
text
|
||||
@click="$emit('update:flag', opt.value)"
|
||||
>
|
||||
<span style="display: flex; align-items: center; gap: 6px">
|
||||
<span
|
||||
:style="{
|
||||
display: 'inline-block',
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: opt.color,
|
||||
}"
|
||||
/>
|
||||
{{ opt.label }}
|
||||
</span>
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="flag"
|
||||
text
|
||||
block
|
||||
type="error"
|
||||
@click="$emit('update:flag', null)"
|
||||
>
|
||||
清除
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-popover>
|
||||
<span v-else :style="dotStyle" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import type { FlagType } from "../../utils/type"
|
||||
|
||||
const FLAG_OPTIONS: {
|
||||
value: NonNullable<FlagType>
|
||||
color: string
|
||||
label: string
|
||||
}[] = [
|
||||
{ value: "red", color: "#e03030", label: "值得展示" },
|
||||
{ value: "blue", color: "#2080f0", label: "需要讲解" },
|
||||
{ value: "green", color: "#18a058", label: "优秀作品" },
|
||||
{ value: "yellow", color: "#f0a020", label: "需要改进" },
|
||||
]
|
||||
|
||||
const props = defineProps<{
|
||||
flag: FlagType
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{ "update:flag": [value: FlagType] }>()
|
||||
|
||||
const dotStyle = computed(() => {
|
||||
const match = FLAG_OPTIONS.find((f) => f.value === props.flag)
|
||||
return {
|
||||
display: "inline-block",
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: match ? match.color : "transparent",
|
||||
border: match ? "none" : "1px dashed #ccc",
|
||||
cursor: props.isAdmin ? "pointer" : "default",
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -37,6 +37,14 @@
|
||||
<template #icon><Icon icon="lucide:image" /></template>
|
||||
素材
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="authed && taskTab === TASK_TYPE.Tutorial && taskId"
|
||||
size="small"
|
||||
quaternary
|
||||
@click="showPeers = true"
|
||||
>
|
||||
同学作品
|
||||
</n-button>
|
||||
<n-button v-if="roleSuper" size="small" quaternary @click="edit">
|
||||
<template #icon><Icon icon="lucide:edit" /></template>
|
||||
编辑
|
||||
@@ -54,6 +62,11 @@
|
||||
<TutorialContent v-if="taskTab === TASK_TYPE.Tutorial" />
|
||||
<ChallengeList v-else />
|
||||
</div>
|
||||
<PeersDrawer
|
||||
v-model:show="showPeers"
|
||||
:task-id="taskId"
|
||||
:asset-base-url="`/media/tasks/tutorial/${step}/`"
|
||||
/>
|
||||
<n-modal
|
||||
v-model:show="showAssets"
|
||||
preset="card"
|
||||
@@ -89,7 +102,8 @@ import {
|
||||
prevDisabled,
|
||||
nextDisabled,
|
||||
} from "../../store/tutorial"
|
||||
import { roleSuper } from "../../store/user"
|
||||
import { authed, roleSuper } from "../../store/user"
|
||||
import PeersDrawer from "../review/PeersDrawer.vue"
|
||||
import { taskTab, taskId, challengeDisplay } from "../../store/task"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { TASK_TYPE } from "../../utils/const"
|
||||
@@ -100,6 +114,7 @@ import TutorialContent from "./TutorialContent.vue"
|
||||
|
||||
const tutorialAssets = ref<TaskAsset[]>([])
|
||||
const showAssets = ref(false)
|
||||
const showPeers = ref(false)
|
||||
|
||||
async function loadTutorialAssets(display: number) {
|
||||
if (!display) {
|
||||
|
||||
@@ -279,53 +279,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标记统计 -->
|
||||
<div>
|
||||
<div
|
||||
style="
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
color: #333;
|
||||
"
|
||||
>
|
||||
标记统计
|
||||
</div>
|
||||
<n-flex :size="8" style="flex-wrap: wrap">
|
||||
<div
|
||||
v-for="flag in flagBadges"
|
||||
:key="flag.label"
|
||||
:style="{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '4px 10px',
|
||||
background: flag.bg,
|
||||
borderRadius: '4px',
|
||||
border: `1px solid ${flag.border}`,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
:style="{
|
||||
width: '7px',
|
||||
height: '7px',
|
||||
background: flag.color,
|
||||
borderRadius: '50%',
|
||||
display: 'inline-block',
|
||||
}"
|
||||
></span>
|
||||
<span :style="{ color: flag.color, fontSize: '12px' }">{{
|
||||
flag.label
|
||||
}}</span>
|
||||
<span style="font-weight: 700; font-size: 13px; color: #333">{{
|
||||
flag.value
|
||||
}}</span>
|
||||
</div>
|
||||
</n-flex>
|
||||
</div>
|
||||
|
||||
<!-- 人气前五 -->
|
||||
<div style="margin-top: 12px">
|
||||
<div>
|
||||
<div
|
||||
style="
|
||||
font-weight: 600;
|
||||
@@ -486,41 +441,6 @@ const countBuckets = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
const flagBadges = computed(() => {
|
||||
if (!stats.value) return []
|
||||
const f = stats.value.flag_stats
|
||||
return [
|
||||
{
|
||||
label: "值得展示",
|
||||
value: f.red,
|
||||
color: "#d03050",
|
||||
bg: "#fff0f0",
|
||||
border: "#ffd0d0",
|
||||
},
|
||||
{
|
||||
label: "需要讲解",
|
||||
value: f.blue,
|
||||
color: "#2080f0",
|
||||
bg: "#f0f7ff",
|
||||
border: "#c8deff",
|
||||
},
|
||||
{
|
||||
label: "优秀作品",
|
||||
value: f.green,
|
||||
color: "#18a058",
|
||||
bg: "#f0fff4",
|
||||
border: "#b8e8c8",
|
||||
},
|
||||
{
|
||||
label: "需要改进",
|
||||
value: f.yellow,
|
||||
color: "#f0a020",
|
||||
bg: "#fffbf0",
|
||||
border: "#ffe8a0",
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
// Load when modal opens
|
||||
watch(
|
||||
() => props.show,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
</n-tag>
|
||||
</template>
|
||||
<template #tools>
|
||||
<n-button quaternary size="small" @click="showPeers = true">同学作品</n-button>
|
||||
<n-button v-if="assets.length" quaternary size="small" @click="showAssets = true">
|
||||
素材
|
||||
</n-button>
|
||||
@@ -53,6 +54,7 @@
|
||||
</template>
|
||||
</Workbench>
|
||||
<TaskStatsModal v-model:show="showStats" :task-id="taskId" />
|
||||
<PeersDrawer v-model:show="showPeers" :task-id="taskId" :asset-base-url="assetBaseUrl" />
|
||||
<n-modal
|
||||
v-model:show="showAssets"
|
||||
preset="card"
|
||||
@@ -80,6 +82,7 @@ import copyFn from "copy-text-to-clipboard"
|
||||
import { Icon } from "../icons"
|
||||
import Workbench from "../components/workbench/Workbench.vue"
|
||||
import TaskStatsModal from "../components/task/TaskStatsModal.vue"
|
||||
import PeersDrawer from "../components/review/PeersDrawer.vue"
|
||||
import { Challenge, TaskAssets } from "../api"
|
||||
import type { TaskAsset } from "../utils/type"
|
||||
import { taskId, taskTab, challengeDisplay } from "../store/task"
|
||||
@@ -119,6 +122,7 @@ const assets = ref<TaskAsset[]>([])
|
||||
const exampleCode = ref<Code | null>(null)
|
||||
const showStats = ref(false)
|
||||
const showAssets = ref(false)
|
||||
const showPeers = ref(false)
|
||||
|
||||
const assetBaseUrl = computed(
|
||||
() => `/media/tasks/challenge/${challengeDisplay.value}/`,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -83,9 +83,18 @@
|
||||
style="flex: 1; min-height: 0;"
|
||||
/>
|
||||
</n-flex>
|
||||
<n-drawer
|
||||
:show="!!reviewId"
|
||||
:width="400"
|
||||
placement="right"
|
||||
@update:show="(v: boolean) => !v && (reviewId = null)"
|
||||
>
|
||||
<ReviewPanel v-if="reviewId" :submission-id="reviewId" @close="reviewId = null" />
|
||||
</n-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ReviewPanel from "../components/review/ReviewPanel.vue"
|
||||
import { computed, h, onMounted, reactive, ref, watch } from "vue"
|
||||
import { Icon } from "../icons"
|
||||
import { watchDebounced } from "@vueuse/core"
|
||||
@@ -96,7 +105,6 @@ import {
|
||||
useMessage,
|
||||
type DataTableColumn,
|
||||
} from "naive-ui"
|
||||
import { useRouter } from "vue-router"
|
||||
import { Account, Gradebook } from "../api"
|
||||
import { displayGradebookStudentName } from "../utils/gradebook"
|
||||
import type {
|
||||
@@ -108,7 +116,6 @@ import type {
|
||||
GradebookTaskType,
|
||||
} from "../utils/type"
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const classesLoading = ref(false)
|
||||
const loading = ref(false)
|
||||
@@ -146,13 +153,11 @@ function taskTitle(task: GradebookTask) {
|
||||
return `${typeLabel}${task.display}`
|
||||
}
|
||||
|
||||
// 点分数:在右侧打开和批改页一样的详情,能直接打分、看提示词
|
||||
const reviewId = ref<string | null>(null)
|
||||
function openSubmission(cell: GradebookCell) {
|
||||
if (!cell.submitted || !cell.submission_id) return
|
||||
const { href } = router.resolve({
|
||||
name: "submission",
|
||||
params: { id: cell.submission_id },
|
||||
})
|
||||
window.open(href, "_blank")
|
||||
reviewId.value = cell.submission_id
|
||||
}
|
||||
|
||||
function gradeTagType(grade: string) {
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
<template>
|
||||
<div class="review">
|
||||
<aside class="tasks">
|
||||
<n-select
|
||||
v-model:value="classname"
|
||||
:options="classOptions"
|
||||
placeholder="选择班级"
|
||||
size="small"
|
||||
/>
|
||||
<div v-if="classname" class="task-list">
|
||||
<template v-for="group in taskGroups" :key="group.label">
|
||||
<div class="group-label">{{ group.label }}</div>
|
||||
<button
|
||||
v-for="t in group.items"
|
||||
:key="t.id"
|
||||
:class="['task', { on: t.id === taskId }]"
|
||||
@click="taskId = t.id"
|
||||
>
|
||||
<span class="task-title">{{ t.display }}. {{ t.title }}</span>
|
||||
<span class="task-count">{{ progressOf(t.id) }}</span>
|
||||
<span class="bar"><span :style="{ width: percentOf(t.id) + '%' }"></span></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<n-empty v-else description="先选一个班级" size="small" style="margin-top: 40px" />
|
||||
</aside>
|
||||
|
||||
<main class="wall">
|
||||
<template v-if="task && classname">
|
||||
<div class="wall-head">
|
||||
<div class="wall-title">
|
||||
<b>{{ task.display }}. {{ task.title }}</b>
|
||||
<span class="chip">已交 {{ submittedCount }} / {{ students.length }}</span>
|
||||
<span v-if="unratedCount" class="chip warn">我未评 {{ unratedCount }}</span>
|
||||
</div>
|
||||
<n-flex :size="8" align="center" :wrap="false">
|
||||
<n-radio-group v-model:value="filter" size="small">
|
||||
<n-radio-button value="all">全部</n-radio-button>
|
||||
<n-radio-button value="unrated">我未评</n-radio-button>
|
||||
<n-radio-button value="missing">未交</n-radio-button>
|
||||
<n-radio-button value="present">课上展示</n-radio-button>
|
||||
</n-radio-group>
|
||||
<n-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!presentList.length"
|
||||
@click="startPresent"
|
||||
>
|
||||
开始讲评({{ presentList.length }})
|
||||
</n-button>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button size="small" quaternary @click="loadWall">
|
||||
<Icon icon="lucide:refresh-cw" />
|
||||
</n-button>
|
||||
</template>
|
||||
刷新
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
</div>
|
||||
<n-spin :show="loadingWall" class="grid-wrap">
|
||||
<div class="grid">
|
||||
<button
|
||||
v-for="s in visible"
|
||||
:key="s.user_id"
|
||||
:class="['card', { sel: s.submission?.id === selectedId, missing: !s.submission }]"
|
||||
:disabled="!s.submission"
|
||||
@click="s.submission && (selectedId = s.submission.id)"
|
||||
>
|
||||
<div class="thumb">
|
||||
<iframe
|
||||
v-if="s.submission"
|
||||
:sandbox="PREVIEW_SANDBOX"
|
||||
:srcdoc="docOf(s)"
|
||||
loading="lazy"
|
||||
tabindex="-1"
|
||||
></iframe>
|
||||
<span v-else>未交</span>
|
||||
</div>
|
||||
<div class="cap">
|
||||
<b>{{ s.username }}</b>
|
||||
<span v-if="!s.submission" class="state grey">未交</span>
|
||||
<span v-else-if="!s.submission.my_score" class="state warn">我未评</span>
|
||||
<span v-else class="state mono">{{ s.submission.my_score }} 分</span>
|
||||
</div>
|
||||
<span v-if="s.submission?.flag === 'present'" class="present">讲评</span>
|
||||
</button>
|
||||
</div>
|
||||
<n-empty
|
||||
v-if="!loadingWall && !visible.length"
|
||||
description="没有符合条件的学生"
|
||||
style="margin-top: 60px"
|
||||
/>
|
||||
</n-spin>
|
||||
</template>
|
||||
<n-empty v-else description="选一个班级和任务开始批改" style="margin-top: 120px" />
|
||||
</main>
|
||||
|
||||
<aside v-if="selectedId" class="detail">
|
||||
<ReviewPanel
|
||||
:submission-id="selectedId"
|
||||
:nav="{ prev: neighbor(-1) !== null, next: neighbor(1) !== null }"
|
||||
@prev="move(-1)"
|
||||
@next="move(1)"
|
||||
@close="selectedId = null"
|
||||
@changed="applyPatch"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div v-if="presenting" class="present-mode">
|
||||
<div class="present-head">
|
||||
<b>{{ presentList[presentIndex]?.username }}</b>
|
||||
<span>{{ presentIndex + 1 }} / {{ presentList.length }} · ← → 切换,Esc 退出</span>
|
||||
<button class="exit" @click="presenting = false">退出讲评</button>
|
||||
</div>
|
||||
<iframe
|
||||
v-if="presentList[presentIndex]"
|
||||
:sandbox="PREVIEW_SANDBOX"
|
||||
:srcdoc="docOf(presentList[presentIndex]!)"
|
||||
></iframe>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { Icon } from "../icons"
|
||||
import ReviewPanel from "../components/review/ReviewPanel.vue"
|
||||
import { Review } from "../api"
|
||||
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../utils/previewDocument"
|
||||
import type { FlagType, ReviewOptions, ReviewProgress, ReviewStudent } from "../utils/type"
|
||||
|
||||
// 老师批改:选班级、选任务,看全班的作品墙,点开右侧详情打分
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const options = ref<ReviewOptions>({ classes: [], tasks: [] })
|
||||
const progress = ref<ReviewProgress | null>(null)
|
||||
const students = ref<ReviewStudent[]>([])
|
||||
const loadingWall = ref(false)
|
||||
const classname = ref<string | null>((route.query.class as string) || null)
|
||||
const taskId = ref<number | null>(route.query.task ? Number(route.query.task) : null)
|
||||
const filter = ref<"all" | "unrated" | "missing" | "present">("all")
|
||||
const selectedId = ref<string | null>(null)
|
||||
const presenting = ref(false)
|
||||
const presentIndex = ref(0)
|
||||
|
||||
const classOptions = computed(() => options.value.classes.map((c) => ({ label: `${c} 班`, value: c })))
|
||||
const task = computed(() => options.value.tasks.find((t) => t.id === taskId.value))
|
||||
const taskGroups = computed(() => [
|
||||
{ label: "教程", items: options.value.tasks.filter((t) => t.task_type === "tutorial") },
|
||||
{ label: "挑战", items: options.value.tasks.filter((t) => t.task_type === "challenge") },
|
||||
])
|
||||
|
||||
const progressOf = (id: number) =>
|
||||
progress.value ? `${progress.value.submitted[id] ?? 0}/${progress.value.student_count}` : ""
|
||||
const percentOf = (id: number) =>
|
||||
progress.value?.student_count
|
||||
? Math.round(((progress.value.submitted[id] ?? 0) / progress.value.student_count) * 100)
|
||||
: 0
|
||||
|
||||
const submittedCount = computed(() => students.value.filter((s) => s.submission).length)
|
||||
const unratedCount = computed(
|
||||
() => students.value.filter((s) => s.submission && !s.submission.my_score).length,
|
||||
)
|
||||
const visible = computed(() =>
|
||||
students.value.filter((s) => {
|
||||
if (filter.value === "unrated") return s.submission && !s.submission.my_score
|
||||
if (filter.value === "missing") return !s.submission
|
||||
if (filter.value === "present") return s.submission?.flag === "present"
|
||||
return true
|
||||
}),
|
||||
)
|
||||
const presentList = computed(() => students.value.filter((s) => s.submission?.flag === "present"))
|
||||
|
||||
const docs = new Map<string, string>()
|
||||
function docOf(s: ReviewStudent) {
|
||||
const sub = s.submission!
|
||||
let d = docs.get(sub.id)
|
||||
if (!d) {
|
||||
const t = task.value
|
||||
d = buildPreviewDocument({
|
||||
html: sub.html,
|
||||
css: sub.css,
|
||||
js: sub.js,
|
||||
// 学生代码里用相对路径引用题目素材
|
||||
assetBaseUrl: t ? `/media/tasks/${t.task_type}/${t.display}/` : undefined,
|
||||
})
|
||||
docs.set(sub.id, d)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
let wallSeq = 0
|
||||
async function loadWall() {
|
||||
if (!classname.value || !taskId.value) return
|
||||
const s = ++wallSeq
|
||||
loadingWall.value = true
|
||||
try {
|
||||
const data = await Review.classWall(classname.value, taskId.value)
|
||||
if (s === wallSeq) students.value = data
|
||||
} finally {
|
||||
if (s === wallSeq) loadingWall.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProgress() {
|
||||
progress.value = null
|
||||
if (classname.value) progress.value = await Review.progress(classname.value)
|
||||
}
|
||||
|
||||
watch(classname, (c) => {
|
||||
if (c) localStorage.setItem("review-class", c)
|
||||
loadProgress()
|
||||
})
|
||||
watch([classname, taskId], () => {
|
||||
router.replace({ query: { class: classname.value ?? undefined, task: taskId.value ?? undefined } })
|
||||
selectedId.value = null
|
||||
filter.value = "all"
|
||||
students.value = []
|
||||
loadWall()
|
||||
})
|
||||
|
||||
// 作品墙里有提交的学生,按当前筛选的顺序切换
|
||||
function neighbor(step: number): string | null {
|
||||
const list = visible.value.filter((s) => s.submission)
|
||||
const i = list.findIndex((s) => s.submission!.id === selectedId.value)
|
||||
return list[i + step]?.submission?.id ?? null
|
||||
}
|
||||
function move(step: number) {
|
||||
const id = neighbor(step)
|
||||
if (id) selectedId.value = id
|
||||
}
|
||||
|
||||
function applyPatch(p: { id: string; my_score?: number; score?: number; flag?: FlagType }) {
|
||||
const s = students.value.find((x) => x.submission?.id === p.id)?.submission
|
||||
if (!s) return
|
||||
if (p.my_score !== undefined) s.my_score = p.my_score
|
||||
if (p.score !== undefined) s.score = p.score
|
||||
if (p.flag !== undefined) s.flag = p.flag
|
||||
}
|
||||
|
||||
function startPresent() {
|
||||
presentIndex.value = 0
|
||||
presenting.value = true
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (presenting.value) {
|
||||
if (e.key === "Escape") presenting.value = false
|
||||
else if (e.key === "ArrowRight") presentIndex.value = Math.min(presentIndex.value + 1, presentList.value.length - 1)
|
||||
else if (e.key === "ArrowLeft") presentIndex.value = Math.max(presentIndex.value - 1, 0)
|
||||
return
|
||||
}
|
||||
const t = e.target as HTMLElement
|
||||
if (t.closest("input, textarea, [contenteditable]") || e.ctrlKey || e.metaKey || e.altKey) return
|
||||
if (e.key === "j" || e.key === "J") move(1)
|
||||
else if (e.key === "k" || e.key === "K") move(-1)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener("keydown", onKey)
|
||||
options.value = await Review.options()
|
||||
if (!classname.value) {
|
||||
const saved = localStorage.getItem("review-class")
|
||||
classname.value = saved && options.value.classes.includes(saved) ? saved : null
|
||||
} else {
|
||||
loadProgress()
|
||||
loadWall()
|
||||
}
|
||||
})
|
||||
onUnmounted(() => window.removeEventListener("keydown", onKey))
|
||||
</script>
|
||||
<style scoped>
|
||||
.review {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tasks {
|
||||
width: 232px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-right: 1px solid var(--wp-line);
|
||||
background: var(--wp-surface);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.group-label {
|
||||
padding: 8px 6px 2px;
|
||||
font-size: 12px;
|
||||
color: var(--wp-faint);
|
||||
}
|
||||
|
||||
.task {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 2px 8px;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--wp-ink);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task:hover {
|
||||
background: var(--wp-paper);
|
||||
}
|
||||
|
||||
.task.on {
|
||||
background: var(--wp-brand-soft);
|
||||
color: var(--wp-brand);
|
||||
}
|
||||
|
||||
.task-title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.task-count {
|
||||
font: 12px var(--wp-font-mono);
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.bar {
|
||||
grid-column: 1 / -1;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--wp-line);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--wp-brand);
|
||||
}
|
||||
|
||||
.wall {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--wp-paper);
|
||||
}
|
||||
|
||||
.wall-head {
|
||||
flex: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--wp-line);
|
||||
background: var(--wp-surface);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wall-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--wp-brand-soft);
|
||||
color: var(--wp-brand);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip.warn {
|
||||
background: var(--wp-warn-soft);
|
||||
color: var(--wp-warn);
|
||||
}
|
||||
|
||||
.grid-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
border: 1px solid var(--wp-line);
|
||||
border-radius: 8px;
|
||||
background: var(--wp-surface);
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: var(--wp-ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover:not(:disabled) {
|
||||
border-color: var(--wp-brand);
|
||||
}
|
||||
|
||||
.card.sel {
|
||||
outline: 2px solid var(--wp-brand);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.card:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.card:focus-visible {
|
||||
outline: 2px solid var(--wp-brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
height: 100px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--wp-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.missing .thumb {
|
||||
background: var(--wp-paper);
|
||||
}
|
||||
|
||||
/* 按 4 倍大小渲染再缩小 */
|
||||
.thumb iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 400%;
|
||||
height: 400%;
|
||||
border: 0;
|
||||
transform: scale(0.25);
|
||||
transform-origin: 0 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cap {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cap b {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.state {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.state.warn {
|
||||
background: var(--wp-warn-soft);
|
||||
color: var(--wp-warn);
|
||||
}
|
||||
|
||||
.state.grey {
|
||||
background: var(--wp-paper);
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.state.mono {
|
||||
font-family: var(--wp-font-mono);
|
||||
color: var(--wp-brand);
|
||||
}
|
||||
|
||||
.present {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--wp-brand);
|
||||
color: var(--wp-surface);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.detail {
|
||||
width: 380px;
|
||||
flex: none;
|
||||
border-left: 1px solid var(--wp-line);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.present-mode {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--wp-ink);
|
||||
}
|
||||
|
||||
.present-head {
|
||||
height: 48px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 16px;
|
||||
color: var(--wp-surface);
|
||||
}
|
||||
|
||||
.present-head span {
|
||||
flex: 1;
|
||||
opacity: 0.7;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.exit {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--wp-faint);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--wp-surface);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.exit:hover {
|
||||
border-color: var(--wp-surface);
|
||||
}
|
||||
|
||||
.present-mode iframe {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -12,28 +12,18 @@
|
||||
style="height: 100%; padding-right: 10px; overflow: hidden"
|
||||
>
|
||||
<n-flex align="center" justify="end">
|
||||
<n-select
|
||||
:value="query.flag"
|
||||
style="width: 100px"
|
||||
clearable
|
||||
placeholder="标记"
|
||||
:options="flagFilterOptions"
|
||||
@update:value="handleFlagSelect"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="query.zone"
|
||||
style="width: 100px"
|
||||
style="width: 110px"
|
||||
clearable
|
||||
placeholder="从夯到拉"
|
||||
:options="[
|
||||
{ label: '夯爆了', value: 'featured' },
|
||||
{ label: 'NPC', value: 'pending' },
|
||||
{ label: '拉完了', value: 'low' },
|
||||
]"
|
||||
placeholder="得分分区"
|
||||
:options="zoneOptions"
|
||||
/>
|
||||
<n-input
|
||||
v-if="isAdmin"
|
||||
style="width: 120px"
|
||||
v-model:value="query.username"
|
||||
placeholder="搜索学生"
|
||||
clearable
|
||||
/>
|
||||
<n-pagination
|
||||
@@ -100,7 +90,7 @@ import { computed, h, onMounted, onUnmounted, reactive, ref, watch } from "vue"
|
||||
import { NButton, NDataTable, NTag, useMessage, type DataTableColumn } from "naive-ui"
|
||||
import { Icon } from "../icons"
|
||||
import { Submission } from "../api"
|
||||
import type { SubmissionOut, FlagType } from "../utils/type"
|
||||
import type { SubmissionOut } from "../utils/type"
|
||||
import { parseTime } from "../utils/helper"
|
||||
import { goHome } from "../utils/helper"
|
||||
import { saveTaskCode } from "../composables/useTaskCode"
|
||||
@@ -112,7 +102,6 @@ import Preview from "../components/editor/Preview.vue"
|
||||
import TaskTitle from "../components/submissions/TaskTitle.vue"
|
||||
import CodeModal from "../components/submissions/CodeModal.vue"
|
||||
import ChainModal from "../components/submissions/ChainModal.vue"
|
||||
import FlagCell from "../components/submissions/FlagCell.vue"
|
||||
import ExpandedSubTable from "../components/submissions/ExpandedSubTable.vue"
|
||||
|
||||
import { submission } from "../store/submission"
|
||||
@@ -131,7 +120,6 @@ const query = reactive({
|
||||
username: (Array.isArray(route.query.username)
|
||||
? ""
|
||||
: (route.query.username ?? "")) as string,
|
||||
flag: null as string | null,
|
||||
zone: null as string | null,
|
||||
})
|
||||
|
||||
@@ -154,37 +142,12 @@ const expandedLoading = reactive(new Set<string>())
|
||||
// 管理员判断
|
||||
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<SubmissionOut>[] = [
|
||||
}),
|
||||
},
|
||||
{
|
||||
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,
|
||||
() => {
|
||||
|
||||
@@ -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" }
|
||||
})
|
||||
@@ -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<string, number>
|
||||
}
|
||||
|
||||
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[]
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Hono } from "hono"
|
||||
import { z } from "zod"
|
||||
import { all, get, iso, uuidOut } from "../db"
|
||||
import { isStaff, requireUser, type User } from "../auth"
|
||||
import { HttpError, intParam, orNotFound, query, type Env } from "../http"
|
||||
|
||||
// 老师按“班级 × 任务”批改,学生看本班同学的作品。挂在 /submission/review 下
|
||||
const router = new Hono<Env>()
|
||||
|
||||
function requireStaff(user: User) {
|
||||
if (!isStaff(user)) throw new HttpError(403, "没有权限")
|
||||
}
|
||||
|
||||
// 和成绩册一致:班里的学生包括当助教的 admin
|
||||
const CLASS_MEMBERS = `SELECT id FROM account_user
|
||||
WHERE classname = $classname AND role IN ('normal', 'admin') AND is_active = 1`
|
||||
|
||||
// 每个人在这个任务上最新的一次提交,以及提交了几次。
|
||||
// 先只在 slim 覆盖索引上找,再按 rowid 读这几行的完整内容(代码是宽列,见 CLAUDE.md)
|
||||
function latestByUser(taskId: number, members: string, params: Record<string, unknown>) {
|
||||
const rows = all<{ rid: number; user_id: number; created: string }>(
|
||||
`SELECT s.rowid AS rid, s.user_id, s.created FROM submission_submission s
|
||||
WHERE s.task_id = $task_id AND s.user_id IN (${members})`,
|
||||
{ task_id: taskId, ...params },
|
||||
)
|
||||
const latest = new Map<number, { rid: number; created: string; count: number }>()
|
||||
for (const r of rows) {
|
||||
const cur = latest.get(r.user_id)
|
||||
if (!cur) latest.set(r.user_id, { rid: r.rid, created: r.created, count: 1 })
|
||||
else {
|
||||
cur.count++
|
||||
if (r.created > cur.created) Object.assign(cur, { rid: r.rid, created: r.created })
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
function loadRows(rids: number[], me: number) {
|
||||
if (!rids.length) return new Map<number, any>()
|
||||
const rows = all(
|
||||
`SELECT s.rowid AS rid, s.id, s.user_id, s.score, s.html, s.css, s.js, s.flag,
|
||||
s.view_count, s.created,
|
||||
(SELECT r.score FROM submission_rating r WHERE r.user_id = $me AND r.submission_id = s.id) AS my_score,
|
||||
(SELECT COUNT(*) FROM submission_rating r WHERE r.submission_id = s.id) AS rating_count
|
||||
FROM submission_submission s WHERE s.rowid IN (SELECT value FROM json_each($rids))`,
|
||||
{ rids: JSON.stringify(rids), me },
|
||||
)
|
||||
return new Map(rows.map((r) => [r.user_id as number, r]))
|
||||
}
|
||||
|
||||
// 班级列表和可批改的任务(教程、挑战)
|
||||
router.get("/options", (c) => {
|
||||
requireStaff(requireUser(c))
|
||||
const classes = all<{ classname: string }>(
|
||||
`SELECT DISTINCT classname FROM account_user
|
||||
WHERE role IN ('normal', 'admin') AND classname != '' ORDER BY classname`,
|
||||
).map((r) => r.classname)
|
||||
const tasks = all(
|
||||
`SELECT id, task_type, display, title, is_public FROM task_task
|
||||
WHERE task_type IN ('tutorial', 'challenge')
|
||||
ORDER BY task_type = 'challenge', display`,
|
||||
).map((t) => ({ ...t, is_public: !!t.is_public }))
|
||||
return c.json({ classes, tasks })
|
||||
})
|
||||
|
||||
// 一个班每个任务交了几个人
|
||||
router.get("/progress", (c) => {
|
||||
requireStaff(requireUser(c))
|
||||
const { classname } = query(c, z.object({ classname: z.string().min(1) }))
|
||||
const studentCount = get<{ n: number }>(
|
||||
`SELECT COUNT(*) AS n FROM (${CLASS_MEMBERS})`,
|
||||
{ classname },
|
||||
)!.n
|
||||
const rows = all<{ task_id: number; n: number }>(
|
||||
`SELECT s.task_id, COUNT(DISTINCT s.user_id) AS n FROM submission_submission s
|
||||
WHERE s.user_id IN (${CLASS_MEMBERS}) GROUP BY s.task_id`,
|
||||
{ classname },
|
||||
)
|
||||
return c.json({
|
||||
student_count: studentCount,
|
||||
submitted: Object.fromEntries(rows.map((r) => [r.task_id, r.n])),
|
||||
})
|
||||
})
|
||||
|
||||
// 作品墙:班里每个学生在这个任务上最新的一次提交(没交的 submission 为 null)
|
||||
router.get("/class", (c) => {
|
||||
const user = requireUser(c)
|
||||
requireStaff(user)
|
||||
const q = query(c, z.object({ classname: z.string().min(1), task_id: z.coerce.number().int() }))
|
||||
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: q.task_id }), "任务不存在")
|
||||
const students = all<{ id: number; username: string }>(
|
||||
`SELECT id, username FROM account_user
|
||||
WHERE classname = $classname AND role IN ('normal', 'admin') AND is_active = 1
|
||||
ORDER BY username, id`,
|
||||
{ classname: q.classname },
|
||||
)
|
||||
const latest = latestByUser(q.task_id, CLASS_MEMBERS, { classname: q.classname })
|
||||
const rows = loadRows([...latest.values()].map((l) => l.rid), user.id)
|
||||
return c.json(
|
||||
students.map((s) => {
|
||||
const r = rows.get(s.id)
|
||||
return {
|
||||
user_id: s.id,
|
||||
username: s.username,
|
||||
submission: r
|
||||
? {
|
||||
id: uuidOut(r.id),
|
||||
score: r.score,
|
||||
my_score: r.my_score || 0,
|
||||
rating_count: r.rating_count,
|
||||
flag: r.flag,
|
||||
view_count: r.view_count,
|
||||
submit_count: latest.get(s.id)!.count,
|
||||
created: iso(r.created),
|
||||
html: r.html,
|
||||
css: r.css,
|
||||
js: r.js,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// 学生看本班同学在这道题上的作品:自己交过才开放;自己打分之前不显示作者和得分
|
||||
router.get("/peers/:task_id", (c) => {
|
||||
const user = requireUser(c)
|
||||
const taskId = intParam(c, "task_id")
|
||||
orNotFound(get(`SELECT 1 FROM task_task WHERE id = $id`, { id: taskId }), "任务不存在")
|
||||
const submitted = get(
|
||||
`SELECT 1 FROM submission_submission WHERE user_id = $me AND task_id = $task_id LIMIT 1`,
|
||||
{ me: user.id, task_id: taskId },
|
||||
)
|
||||
if (!submitted || !user.classname) return c.json({ locked: true, items: [] })
|
||||
const members = `SELECT id FROM account_user
|
||||
WHERE classname = $classname AND role = 'normal' AND is_active = 1 AND id != $me`
|
||||
const params = { classname: user.classname, me: user.id }
|
||||
const latest = latestByUser(taskId, members, params)
|
||||
const rows = loadRows([...latest.values()].map((l) => l.rid), user.id)
|
||||
const names = new Map(
|
||||
all<{ id: number; username: string }>(`SELECT id, username FROM account_user WHERE id IN (${members})`, params).map(
|
||||
(u) => [u.id, u.username],
|
||||
),
|
||||
)
|
||||
const items = [...rows.values()]
|
||||
.sort((a, b) => (a.created < b.created ? 1 : -1))
|
||||
.map((r) => {
|
||||
const rated = (r.my_score || 0) > 0
|
||||
return {
|
||||
id: uuidOut(r.id),
|
||||
username: rated ? names.get(r.user_id) : null,
|
||||
score: rated ? r.score : null,
|
||||
my_score: r.my_score || 0,
|
||||
created: iso(r.created),
|
||||
html: r.html,
|
||||
css: r.css,
|
||||
js: r.js,
|
||||
}
|
||||
})
|
||||
return c.json({ locked: false, items })
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono"
|
||||
import { z } from "zod"
|
||||
import { all, get, run, tx, now, iso, likeEscape, newUuidHex, uuidHex, uuidOut } from "../db"
|
||||
import { Role, isStaff, requireSuper, requireUser } from "../auth"
|
||||
import { Role, isStaff, requireSuper, requireUser, type User } from "../auth"
|
||||
import {
|
||||
HttpError,
|
||||
ValidationError,
|
||||
@@ -30,10 +30,35 @@ import {
|
||||
} from "../services/prompt"
|
||||
import { pyRound } from "../services/pyround"
|
||||
import showcase from "./showcase"
|
||||
import review from "./review"
|
||||
|
||||
const router = new Hono<Env>()
|
||||
|
||||
router.route("/showcase", showcase)
|
||||
router.route("/review", review)
|
||||
|
||||
// 学生能看的提交:自己的、进了作品展的、本班同学在自己也交过的题上的。老师都能看
|
||||
function canView(user: User, sub: { id: string; user_id: number; task_id: number }) {
|
||||
if (isStaff(user) || sub.user_id === user.id) return true
|
||||
if (get(`SELECT 1 FROM submission_submissionaward WHERE submission_id = $id`, { id: sub.id })) return true
|
||||
if (!user.classname) return false
|
||||
return !!get(
|
||||
`SELECT 1 FROM account_user u
|
||||
WHERE u.id = $owner AND u.classname = $classname
|
||||
AND EXISTS (SELECT 1 FROM submission_submission s WHERE s.user_id = $me AND s.task_id = $task_id)`,
|
||||
{ owner: sub.user_id, classname: user.classname, me: user.id, task_id: sub.task_id },
|
||||
)
|
||||
}
|
||||
|
||||
function requireView(user: User, id: string) {
|
||||
const sub = orNotFound(
|
||||
get<{ id: string; user_id: number; task_id: number }>(
|
||||
`SELECT id, user_id, task_id FROM submission_submission WHERE id = $id`,
|
||||
{ id },
|
||||
),
|
||||
)
|
||||
if (!canView(user, sub)) throw new HttpError(403, "交过这道题以后才能看同学的作品")
|
||||
}
|
||||
|
||||
// 当天(UTC 零点起)的评分次数,与原实现保持一致
|
||||
function todayRatingCount(userId: number) {
|
||||
@@ -221,13 +246,19 @@ router.get("/", (c) => {
|
||||
where.push("t.task_type = $task_type")
|
||||
params.task_type = f.task_type
|
||||
}
|
||||
if (f.username) {
|
||||
where.push(`u.username LIKE $username ESCAPE '\\'`)
|
||||
params.username = likeEscape(f.username)
|
||||
}
|
||||
if (f.user_id) {
|
||||
// 学生只能看自己的提交(同学的作品走 /review/peers)
|
||||
if (user.role === Role.NORMAL) {
|
||||
where.push("s.user_id = $user_id")
|
||||
params.user_id = f.user_id
|
||||
params.user_id = user.id
|
||||
} else {
|
||||
if (f.username) {
|
||||
where.push(`u.username LIKE $username ESCAPE '\\'`)
|
||||
params.username = likeEscape(f.username)
|
||||
}
|
||||
if (f.user_id) {
|
||||
where.push("s.user_id = $user_id")
|
||||
params.user_id = f.user_id
|
||||
}
|
||||
}
|
||||
// flag / zone 前的一元 + 让 SQLite 不用这两列的单列索引(那样要回表读大行),改走 slim 覆盖索引
|
||||
if (f.flag === "any") where.push("+s.flag IS NOT NULL")
|
||||
@@ -288,6 +319,7 @@ router.get("/", (c) => {
|
||||
router.get("/by-user-task", (c) => {
|
||||
const me = requireUser(c)
|
||||
const q = query(c, z.object({ user_id: qs.int, task_id: qs.int }))
|
||||
if (me.role === Role.NORMAL && q.user_id !== me.id) throw new HttpError(403, "没有权限")
|
||||
const rows = all(
|
||||
`${SUBMISSION_SELECT} ${SUBMISSION_FROM}
|
||||
WHERE s.user_id = $user_id AND s.task_id = $task_id ORDER BY s.created DESC`,
|
||||
@@ -333,14 +365,14 @@ router.get("/random-for-rating", (c) => {
|
||||
get<{ rid: number }>(`${base} ORDER BY RANDOM() LIMIT 1`, params)
|
||||
if (!picked) return c.json(null)
|
||||
const pick = get(
|
||||
`SELECT s.id, u.username, t.title AS task_title, t.display AS task_display,
|
||||
`SELECT s.id, t.title AS task_title, t.display AS task_display,
|
||||
t.task_type, s.html, s.css, s.js
|
||||
${SUBMISSION_FROM} WHERE s.rowid = $rid`,
|
||||
{ rid: picked.rid },
|
||||
)!
|
||||
// 打分前不告诉作者是谁(打完分 PUT /:id/score 会返回)
|
||||
return c.json({
|
||||
submission_id: uuidOut(pick.id),
|
||||
username: pick.username,
|
||||
task_title: pick.task_title,
|
||||
task_display: pick.task_display,
|
||||
task_type: pick.task_type,
|
||||
@@ -350,13 +382,6 @@ router.get("/random-for-rating", (c) => {
|
||||
})
|
||||
})
|
||||
|
||||
router.delete("/flags", (c) => {
|
||||
const user = requireUser(c)
|
||||
if (!isStaff(user)) throw new HttpError(403, "没有权限")
|
||||
const res = run(`UPDATE submission_submission SET flag = NULL WHERE flag IS NOT NULL`)
|
||||
return c.json({ cleared: res.changes })
|
||||
})
|
||||
|
||||
// ── 统计 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
router.get("/stats/:task_id", (c) => {
|
||||
@@ -510,9 +535,9 @@ router.delete("/:id", (c) => {
|
||||
})
|
||||
|
||||
router.get("/:id/prompt-chain", (c) => {
|
||||
requireUser(c)
|
||||
const user = requireUser(c)
|
||||
const id = uuidParam(c, "id")
|
||||
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id }))
|
||||
requireView(user, id)
|
||||
const msg = get<MessageRow>(`SELECT * FROM prompt_message WHERE submission_id = $id`, { id })
|
||||
if (!msg) throw new HttpError(404, "该提交没有关联提示词链")
|
||||
return c.json(buildPromptRounds(msg))
|
||||
@@ -521,6 +546,7 @@ router.get("/:id/prompt-chain", (c) => {
|
||||
router.get("/:id", (c) => {
|
||||
const user = requireUser(c)
|
||||
const id = uuidParam(c, "id")
|
||||
requireView(user, id)
|
||||
const s = orNotFound(get(`${SUBMISSION_SELECT} ${SUBMISSION_FROM} WHERE s.id = $id`, { me: user.id, id }))
|
||||
return c.json(submissionOut(s))
|
||||
})
|
||||
@@ -539,7 +565,13 @@ router.put("/:id/score", async (c) => {
|
||||
const id = uuidParam(c, "id")
|
||||
const { score } = await body(c, z.object({ score: z.number().int().max(5) }))
|
||||
if (score <= 0) throw new HttpError(400, "分数不能为零")
|
||||
const sub = orNotFound(get<{ user_id: number }>(`SELECT user_id FROM submission_submission WHERE id = $id`, { id }))
|
||||
const sub = orNotFound(
|
||||
get<{ user_id: number; username: string }>(
|
||||
`SELECT s.user_id, u.username FROM submission_submission s
|
||||
JOIN account_user u ON u.id = s.user_id WHERE s.id = $id`,
|
||||
{ id },
|
||||
),
|
||||
)
|
||||
if (sub.user_id === user.id) throw new HttpError(400, "不能给自己的提交打分")
|
||||
|
||||
const exists = get(`SELECT 1 FROM submission_rating WHERE user_id = $user_id AND submission_id = $id`, {
|
||||
@@ -558,7 +590,8 @@ router.put("/:id/score", async (c) => {
|
||||
)
|
||||
updateSubmissionScore(id)
|
||||
})
|
||||
return c.json({ message: "打分成功" })
|
||||
// 互评时打分前是匿名的,打完分再告诉作者是谁
|
||||
return c.json({ message: "打分成功", username: sub.username })
|
||||
})
|
||||
|
||||
router.put("/:id/flag", async (c) => {
|
||||
@@ -567,7 +600,9 @@ router.put("/:id/flag", async (c) => {
|
||||
const id = uuidParam(c, "id")
|
||||
const { flag } = await body(
|
||||
c,
|
||||
z.object({ flag: z.enum(["red", "blue", "green", "yellow"]).nullable().default(null) }),
|
||||
z.object({
|
||||
flag: z.enum(["present", "red", "blue", "green", "yellow"]).nullable().default(null),
|
||||
}),
|
||||
)
|
||||
orNotFound(get(`SELECT 1 FROM submission_submission WHERE id = $id`, { id }))
|
||||
run(`UPDATE submission_submission SET flag = $flag WHERE id = $id`, { flag, id })
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user