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
@@ -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>
|
||||
Reference in new issue
Block a user