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,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(() => {
|
||||
|
||||
Reference in new issue
Block a user