- 学生“同学作品”加“本班 / 全部班级”切换(peers?scope=all),作品标班级,仍是交过才开放、打分前匿名; 服务端放开:交过某题后能看任何班同学在这道题上的提交 - 批改页班级下拉加“全部班级”:一道题全校作品按班级分组,没人交的班收起成一行; /review/class、/review/progress 不传 classname 就是全部班级 - 点开作品默认大图:作品墙收起,预览占满中间,打分 / 课上展示 / 提示词放右栏; Esc 返回作品墙,可切回小面板(记住选择);成绩册弹出的详情也用大图布局 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8653d411a6
commit
75e20a3ae1
11 files changed
+265
-68
No files matched your search
+6
-4
@@ -465,18 +465,20 @@ export const Review = {
|
||||
async options(): Promise<ReviewOptions> {
|
||||
return (await http.get("/submission/review/options")).data
|
||||
},
|
||||
async progress(classname: string): Promise<ReviewProgress> {
|
||||
// classname 不传就是全部班级
|
||||
async progress(classname?: string): Promise<ReviewProgress> {
|
||||
return (await http.get("/submission/review/progress", { params: { classname } })).data
|
||||
},
|
||||
async classWall(classname: string, taskId: number): Promise<ReviewStudent[]> {
|
||||
async classWall(classname: string | undefined, 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
|
||||
// scope:class 只看本班,all 看所有班
|
||||
async peers(taskId: number, scope: "class" | "all" = "class"): Promise<PeerWorks> {
|
||||
return (await http.get(`/submission/review/peers/${taskId}`, { params: { scope } })).data
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,15 @@
|
||||
style="margin-top: 40px"
|
||||
/>
|
||||
<template v-else-if="data">
|
||||
<p class="hint">
|
||||
本班同学在这道题上最新的作品。打分之前不显示是谁做的,打完分才显示。
|
||||
</p>
|
||||
<n-flex justify="space-between" align="center" class="bar">
|
||||
<span class="hint">
|
||||
同学们在这道题上最新的作品。打分之前不显示是谁做的,打完分才显示。
|
||||
</span>
|
||||
<n-radio-group v-model:value="scope" size="small">
|
||||
<n-radio-button value="class">本班</n-radio-button>
|
||||
<n-radio-button value="all">全部班级</n-radio-button>
|
||||
</n-radio-group>
|
||||
</n-flex>
|
||||
<n-empty v-if="!data.items.length" description="还没有同学交这道题" />
|
||||
<div class="grid">
|
||||
<div v-for="w in data.items" :key="w.id" class="work">
|
||||
@@ -25,7 +31,10 @@
|
||||
></iframe>
|
||||
</button>
|
||||
<div class="info">
|
||||
<span :class="{ anon: !w.username }">{{ w.username ?? "匿名同学" }}</span>
|
||||
<span :class="{ anon: !w.username }">
|
||||
<span v-if="scope === 'all' && w.classname" class="cls">{{ w.classname }} 班</span>
|
||||
{{ w.username ?? "匿名同学" }}
|
||||
</span>
|
||||
<span v-if="w.my_score" class="mine">我给了 {{ w.my_score }} 分</span>
|
||||
</div>
|
||||
<div v-if="!w.my_score" class="rates">
|
||||
@@ -79,19 +88,26 @@ const data = ref<PeerWorks | null>(null)
|
||||
const loading = ref(false)
|
||||
const rating = ref<string | null>(null)
|
||||
const large = ref<PeerWork | null>(null)
|
||||
const scope = ref<"class" | "all">("class")
|
||||
|
||||
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
|
||||
let seq = 0
|
||||
async function load() {
|
||||
if (!props.taskId) return
|
||||
const s = ++seq
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await Review.peers(props.taskId)
|
||||
const res = await Review.peers(props.taskId, scope.value)
|
||||
if (s === seq) data.value = res
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (s === seq) loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(show, (open) => open && load())
|
||||
watch(scope, load)
|
||||
|
||||
async function rate(w: PeerWork, score: number) {
|
||||
rating.value = w.id
|
||||
@@ -107,12 +123,22 @@ async function rate(w: PeerWork, score: number) {
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.bar {
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: nowrap !important;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.cls {
|
||||
margin-right: 4px;
|
||||
color: var(--wp-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div :class="['panel', { wide }]">
|
||||
<div class="head">
|
||||
<div class="who">
|
||||
<b>{{ sub?.username ?? "…" }}</b>
|
||||
@@ -24,14 +24,31 @@
|
||||
下一个学生(J)
|
||||
</n-tooltip>
|
||||
</template>
|
||||
<n-button size="small" quaternary @click="emit('close')">
|
||||
<Icon icon="lucide:x" />
|
||||
<n-button
|
||||
v-if="canWiden"
|
||||
size="small"
|
||||
quaternary
|
||||
@click="emit('toggle-wide')"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon :icon="wide ? 'lucide:minimize-2' : 'lucide:maximize-2'" />
|
||||
</template>
|
||||
{{ wide ? "小面板" : "大图" }}
|
||||
</n-button>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button size="small" quaternary @click="emit('close')">
|
||||
<Icon icon="lucide:x" />
|
||||
</n-button>
|
||||
</template>
|
||||
{{ wide ? "返回作品墙(Esc)" : "关闭" }}
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
</div>
|
||||
|
||||
<n-spin :show="loading" class="body">
|
||||
<template v-if="sub">
|
||||
<div v-if="sub" class="layout">
|
||||
<div class="stage">
|
||||
<div class="versions" v-if="versions.length > 1">
|
||||
<span class="muted">共交了 {{ versions.length }} 次:</span>
|
||||
<button
|
||||
@@ -53,6 +70,8 @@
|
||||
</span>
|
||||
<n-button text size="small" @click="openFull">全屏打开</n-button>
|
||||
</n-flex>
|
||||
</div>
|
||||
<div class="side">
|
||||
|
||||
<section>
|
||||
<h4>打分</h4>
|
||||
@@ -106,7 +125,8 @@
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
@@ -128,6 +148,10 @@ import type {
|
||||
// 老师看一份提交:预览、交过的各个版本、打分、课上展示、加入作品展、提示词链
|
||||
const props = defineProps<{
|
||||
submissionId: string
|
||||
// 大图:预览占满左边,打分和提示词放右边一栏
|
||||
wide?: boolean
|
||||
// 是否显示“大图 / 小面板”切换
|
||||
canWiden?: boolean
|
||||
// 作品墙里用:能不能切到上一个 / 下一个学生
|
||||
nav?: { prev: boolean; next: boolean }
|
||||
}>()
|
||||
@@ -135,6 +159,7 @@ const emit = defineEmits<{
|
||||
prev: []
|
||||
next: []
|
||||
close: []
|
||||
"toggle-wide": []
|
||||
// 让作品墙同步更新这张卡片
|
||||
changed: [patch: { id: string; my_score?: number; score?: number; flag?: FlagType }]
|
||||
}>()
|
||||
@@ -282,6 +307,41 @@ onUnmounted(() => window.removeEventListener("keydown", onKey))
|
||||
padding: 12px 14px 20px;
|
||||
}
|
||||
|
||||
/* 大图:左边预览占满高度,右边一栏单独滚动 */
|
||||
.wide .body {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wide .body :deep(.n-spin-content) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.wide .layout {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
}
|
||||
|
||||
.wide .stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.wide .stage .frame {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wide .side {
|
||||
border-left: 1px solid var(--wp-line);
|
||||
overflow-y: auto;
|
||||
padding: 0 14px 20px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--wp-muted);
|
||||
font-size: 12px;
|
||||
@@ -361,6 +421,11 @@ h4 {
|
||||
|
||||
.hint {
|
||||
margin-left: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wide .rates {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chain {
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
Lightbulb,
|
||||
List,
|
||||
Lock,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
LogOut,
|
||||
Monitor,
|
||||
Moon,
|
||||
@@ -54,6 +56,8 @@ const registry: Record<string, Component> = {
|
||||
"chevron-up": ChevronUp,
|
||||
globe: Globe,
|
||||
lock: Lock,
|
||||
"maximize-2": Maximize2,
|
||||
"minimize-2": Minimize2,
|
||||
"check-circle": CircleCheck,
|
||||
download: Download,
|
||||
eye: Eye,
|
||||
|
||||
@@ -85,11 +85,11 @@
|
||||
</n-flex>
|
||||
<n-drawer
|
||||
:show="!!reviewId"
|
||||
:width="400"
|
||||
width="min(1180px, 92vw)"
|
||||
placement="right"
|
||||
@update:show="(v: boolean) => !v && (reviewId = null)"
|
||||
>
|
||||
<ReviewPanel v-if="reviewId" :submission-id="reviewId" @close="reviewId = null" />
|
||||
<ReviewPanel v-if="reviewId" :submission-id="reviewId" wide @close="reviewId = null" />
|
||||
</n-drawer>
|
||||
</template>
|
||||
|
||||
|
||||
+66
-10
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="review">
|
||||
<aside class="tasks">
|
||||
<aside v-show="!fullDetail" class="tasks">
|
||||
<n-select
|
||||
v-model:value="classname"
|
||||
:options="classOptions"
|
||||
@@ -25,7 +25,7 @@
|
||||
<n-empty v-else description="先选一个班级" size="small" style="margin-top: 40px" />
|
||||
</aside>
|
||||
|
||||
<main class="wall">
|
||||
<main v-show="!fullDetail" class="wall">
|
||||
<template v-if="task && classname">
|
||||
<div class="wall-head">
|
||||
<div class="wall-title">
|
||||
@@ -59,9 +59,15 @@
|
||||
</n-flex>
|
||||
</div>
|
||||
<n-spin :show="loadingWall" class="grid-wrap">
|
||||
<div class="grid">
|
||||
<template v-for="g in groups" :key="g.name">
|
||||
<div v-if="g.name" class="group-head">
|
||||
<b>{{ g.name }} 班</b>
|
||||
<span>已交 {{ g.submitted }} / {{ g.total }}</span>
|
||||
<span v-if="g.collapsed">· 这个班还没有人交,已收起</span>
|
||||
</div>
|
||||
<div v-if="!g.collapsed" class="grid">
|
||||
<button
|
||||
v-for="s in visible"
|
||||
v-for="s in g.items"
|
||||
:key="s.user_id"
|
||||
:class="['card', { sel: s.submission?.id === selectedId, missing: !s.submission }]"
|
||||
:disabled="!s.submission"
|
||||
@@ -86,6 +92,7 @@
|
||||
<span v-if="s.submission?.flag === 'present'" class="present">讲评</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<n-empty
|
||||
v-if="!loadingWall && !visible.length"
|
||||
description="没有符合条件的学生"
|
||||
@@ -96,9 +103,12 @@
|
||||
<n-empty v-else description="选一个班级和任务开始批改" style="margin-top: 120px" />
|
||||
</main>
|
||||
|
||||
<aside v-if="selectedId" class="detail">
|
||||
<aside v-if="selectedId" :class="['detail', { full: fullDetail }]">
|
||||
<ReviewPanel
|
||||
:submission-id="selectedId"
|
||||
:wide="fullDetail"
|
||||
can-widen
|
||||
@toggle-wide="bigPreview = !bigPreview"
|
||||
:nav="{ prev: neighbor(-1) !== null, next: neighbor(1) !== null }"
|
||||
@prev="move(-1)"
|
||||
@next="move(1)"
|
||||
@@ -124,6 +134,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import { useStorage } from "@vueuse/core"
|
||||
import { Icon } from "../icons"
|
||||
import ReviewPanel from "../components/review/ReviewPanel.vue"
|
||||
import { Review } from "../api"
|
||||
@@ -143,9 +154,18 @@ const taskId = ref<number | null>(route.query.task ? Number(route.query.task) :
|
||||
const filter = ref<"all" | "unrated" | "missing" | "present">("all")
|
||||
const selectedId = ref<string | null>(null)
|
||||
const presenting = ref(false)
|
||||
// 点开作品默认用大图(预览占满中间),老师也可以切回旁边的小面板;记住选择
|
||||
const bigPreview = useStorage("review-big-preview", true)
|
||||
const fullDetail = computed(() => !!selectedId.value && bigPreview.value)
|
||||
const presentIndex = ref(0)
|
||||
|
||||
const classOptions = computed(() => options.value.classes.map((c) => ({ label: `${c} 班`, value: c })))
|
||||
// 全部班级:一道题全校的作品放在一面墙上,按班级分组
|
||||
const ALL = "__all__"
|
||||
const classOptions = computed(() => [
|
||||
{ label: "全部班级", value: ALL },
|
||||
...options.value.classes.map((c) => ({ label: `${c} 班`, value: c })),
|
||||
])
|
||||
const classParam = () => (classname.value === ALL ? undefined : classname.value ?? undefined)
|
||||
const task = computed(() => options.value.tasks.find((t) => t.id === taskId.value))
|
||||
const taskGroups = computed(() => [
|
||||
{ label: "教程", items: options.value.tasks.filter((t) => t.task_type === "tutorial") },
|
||||
@@ -171,6 +191,22 @@ const visible = computed(() =>
|
||||
return true
|
||||
}),
|
||||
)
|
||||
const groups = computed(() => {
|
||||
if (classname.value !== ALL) {
|
||||
return [{ name: "", items: visible.value, submitted: 0, total: 0, collapsed: false }]
|
||||
}
|
||||
const map = new Map<string, ReviewStudent[]>()
|
||||
for (const s of visible.value) {
|
||||
if (!map.has(s.classname)) map.set(s.classname, [])
|
||||
map.get(s.classname)!.push(s)
|
||||
}
|
||||
return [...map].map(([name, items]) => {
|
||||
const all = students.value.filter((s) => s.classname === name)
|
||||
const submitted = all.filter((s) => s.submission).length
|
||||
// 全校一起看时,没人交的班(这道题没布置给它)只留一行标题,不然一屏都是“未交”
|
||||
return { name, items, submitted, total: all.length, collapsed: !submitted && filter.value !== "missing" }
|
||||
})
|
||||
})
|
||||
const presentList = computed(() => students.value.filter((s) => s.submission?.flag === "present"))
|
||||
|
||||
const docs = new Map<string, string>()
|
||||
@@ -197,7 +233,7 @@ async function loadWall() {
|
||||
const s = ++wallSeq
|
||||
loadingWall.value = true
|
||||
try {
|
||||
const data = await Review.classWall(classname.value, taskId.value)
|
||||
const data = await Review.classWall(classParam(), taskId.value)
|
||||
if (s === wallSeq) students.value = data
|
||||
} finally {
|
||||
if (s === wallSeq) loadingWall.value = false
|
||||
@@ -206,7 +242,7 @@ async function loadWall() {
|
||||
|
||||
async function loadProgress() {
|
||||
progress.value = null
|
||||
if (classname.value) progress.value = await Review.progress(classname.value)
|
||||
if (classname.value) progress.value = await Review.progress(classParam())
|
||||
}
|
||||
|
||||
watch(classname, (c) => {
|
||||
@@ -254,7 +290,8 @@ function onKey(e: KeyboardEvent) {
|
||||
}
|
||||
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)
|
||||
if (e.key === "Escape" && selectedId.value) selectedId.value = null
|
||||
else if (e.key === "j" || e.key === "J") move(1)
|
||||
else if (e.key === "k" || e.key === "K") move(-1)
|
||||
}
|
||||
|
||||
@@ -263,7 +300,8 @@ onMounted(async () => {
|
||||
options.value = await Review.options()
|
||||
if (!classname.value) {
|
||||
const saved = localStorage.getItem("review-class")
|
||||
classname.value = saved && options.value.classes.includes(saved) ? saved : null
|
||||
classname.value =
|
||||
saved && (saved === ALL || options.value.classes.includes(saved)) ? saved : null
|
||||
} else {
|
||||
loadProgress()
|
||||
loadWall()
|
||||
@@ -401,6 +439,18 @@ onUnmounted(() => window.removeEventListener("keydown", onKey))
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.group-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 14px 14px 0;
|
||||
}
|
||||
|
||||
.group-head span {
|
||||
font-size: 12px;
|
||||
color: var(--wp-muted);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||
@@ -522,6 +572,12 @@ onUnmounted(() => window.removeEventListener("keydown", onKey))
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.detail.full {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.present-mode {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -410,6 +410,7 @@ export interface ReviewSubmission {
|
||||
export interface ReviewStudent {
|
||||
user_id: number
|
||||
username: string
|
||||
classname: string
|
||||
submission: ReviewSubmission | null
|
||||
}
|
||||
|
||||
@@ -417,6 +418,7 @@ export interface PeerWork {
|
||||
id: string
|
||||
// 自己打分之前是 null
|
||||
username: string | null
|
||||
classname: string
|
||||
score: number | null
|
||||
my_score: number
|
||||
created: string
|
||||
|
||||
Reference in new issue
Block a user