Files
web/client/src/components/review/ReviewPanel.vue
T
xuyueandClaude Opus 5.5 df3f842dac 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>
2026-10-27 00:10:18 +08:00

394 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>