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:
xuyueandClaude Opus 5.5 committed 2026-10-27 00:10:18 +08:00
1 parent 9c6c01eba1
commit df3f842dac
18 files changed
+1616 -275

No files matched your search

+29 -6
View File
@@ -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>
+16 -1
View File
@@ -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) {
+1 -81
View File
@@ -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(() => {
+4
View File
@@ -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}/`,
+9 -1
View File
@@ -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,
},
],
},
+12 -7
View File
@@ -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) {
+569
View File
@@ -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>
+17 -72
View File
@@ -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,
() => {
+8 -7
View File
@@ -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" }
})
+58 -2
View File
@@ -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[]
}