feat(提交列表): 老师也能重新判题;列表能按班级、一次按几道题筛,还带「通过几个测试点」

- 重新判题从只放超管改成老师及以上:前端一直给老师亮着按钮,点了只会「权限不足」
- /submissions、/flowcharts 加 className(user.class_name 精确匹配),题号可以一次填几道
- 列表每行带 caseSummary,口径和详情一致,在库里数完只回两个数
- 新增 GET /classroom/lesson:提交列表「这节课」按钮用,班级和题的取法同课堂看板

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-29 11:08:49 +08:00
1 parent b76a35b5f2
commit ea480d5e0c
12 files changed
+151 -425

No files matched your search

@@ -1,53 +0,0 @@
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import { USERNAME_CLASS_RE } from "utils/constants"
interface Props {
type: "题目" | "用户"
username?: string
}
const props = defineProps<Props>()
const emits = defineEmits(["click", "search", "filterClass"])
// 用同一个正则判断,避免 kstest 这类 ks 开头但没有班级号的用户名
// 也显示出按钮,点了却什么都不发生
const showFilterClass = computed(() => {
return props.type === "用户" && USERNAME_CLASS_RE.test(props.username ?? "")
})
function filterClass() {
const match = props.username!.match(USERNAME_CLASS_RE)
const classname = match ? match[0] : ""
if (!classname) return
emits("filterClass", classname)
}
</script>
<template>
<n-flex align="center">
<n-button text type="info" @click="$emit('click')">
<slot></slot>
</n-button>
<n-tooltip>
<template #trigger>
<n-button text @click="$emit('search')">
<template #icon>
<Icon icon="streamline-emojis:magnifying-glass-tilted-left"></Icon>
</template>
</n-button>
</template>
{{ "搜索" + props.type }}
</n-tooltip>
<n-tooltip v-if="showFilterClass">
<template #trigger>
<n-button text @click="filterClass">
<template #icon>
<Icon icon="streamline-emojis:scroll"></Icon>
</template>
</n-button>
</template>
筛选班级
</n-tooltip>
</n-flex>
</template>
@@ -1,31 +0,0 @@
<template>
<n-button v-if="flowchart.showLink" type="info" text @click="handleClick">
{{ flowchart.id.slice(0, 12) }}
</n-button>
<!-- 没权限时不能挂 @click:后端 GET /flowcharts/:id 会以 404 挡下,
前端只会得到一个静默失败的空白面板 -->
<n-text v-else class="flowchart-id" depth="3">
{{ flowchart.id.slice(0, 12) }}
</n-text>
</template>
<script setup lang="ts">
import type { FlowchartSubmissionListItem } from "utils/types"
interface Props {
flowchart: FlowchartSubmissionListItem
}
const props = defineProps<Props>()
const emit = defineEmits<{
showDetail: [id: string]
}>()
// showLink 由后端逐行下发(见 routes/flowchart.ts 的 canView),与
// GET /flowcharts/:id 的放行条件同源。原来前端自己按「超管或本人」算了一遍,
// 既漏了教师,也和后端对不上。
function handleClick() {
emit("showDetail", props.flowchart.id)
}
</script>
<style scoped></style>
@@ -1,226 +0,0 @@
<template>
<n-grid v-if="submission" :cols="5" :x-gap="16">
<!-- 左侧:流程图预览区域 -->
<n-gi :span="3">
<n-card title="流程图预览">
<template #header-extra>
<n-button
v-if="!renderError && submission?.mermaidCode"
quaternary
size="small"
@click="showLargeImage = true"
>
<template #icon>
<Icon icon="ph:corners-out" />
</template>
查看大图
</n-button>
</template>
<div class="flowchart">
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
{{ renderError }}
</n-alert>
<Teleport to="body" :disabled="!showLargeImage">
<div
v-show="!renderError"
:class="['flowchart', { 'flowchart-fullscreen': showLargeImage }]"
ref="mermaidContainer"
></div>
<div v-if="showLargeImage" class="fullscreen-toolbar">
<n-button secondary round @click="showLargeImage = false">
<template #icon>
<Icon icon="ph:corners-in" />
</template>
退出大图
</n-button>
</div>
</Teleport>
</div>
</n-card>
</n-gi>
<!-- 右侧:评分详情区域 -->
<n-gi :span="2">
<!-- AI反馈 -->
<n-card v-if="submission.aiFeedback" size="small" title="AI反馈" style="margin-bottom: 16px">
<n-text>{{ submission.aiFeedback }}</n-text>
</n-card>
<!-- 改进建议 -->
<n-card
v-if="suggestionLines.length"
size="small"
title="改进建议"
style="margin-bottom: 16px"
>
<n-flex vertical :size="6">
<n-text v-for="(suggestion, index) in suggestionLines" :key="`${index}-${suggestion}`">
{{ suggestion }}
</n-text>
</n-flex>
</n-card>
<!-- 详细评分 -->
<n-card v-if="sortedCriteria.length > 0" size="small" title="详细评分">
<div v-for="[key, detail] in sortedCriteria" :key="key" style="margin-bottom: 12px">
<!-- 评分项标题和分数 -->
<n-flex justify="space-between" align="center" style="margin-bottom: 4px">
<n-text strong>{{ key }}</n-text>
<n-tag :type="getPercentType(detail.score / detail.max)" size="small" round>
{{ detail.score || 0 }}分 / {{ detail.max }}分
</n-tag>
</n-flex>
<!-- 评分项详细说明 -->
<n-text v-if="detail.comment" depth="3" style="font-size: 12px">
{{ detail.comment }}
</n-text>
</div>
</n-card>
</n-gi>
</n-grid>
<n-spin v-else :show="loading" class="loading-container"> </n-spin>
</template>
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { FlowchartSubmission } from "utils/types"
import { useMermaid } from "shared/composables/useMermaid"
import { sortFlowchartCriteria } from "utils/constants"
interface Props {
submissionId: string
}
const props = defineProps<Props>()
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
const { renderError, renderFlowchart } = useMermaid()
const submission = ref<FlowchartSubmission | null>(null)
/**
* 评分项明细。契约里是 `Record<string, unknown>` —— 内容是 AI 模型原样吐出的 JSON,
* 后端不校验形状,所以这里只能按约定断言,字段缺失时用 0 / 空串兜底。
*/
const criteriaDetails = computed<Record<string, { score: number; max: number; comment: string }>>(
() => {
const raw = submission.value?.aiCriteriaDetails ?? {}
return Object.fromEntries(
Object.entries(raw).map(([key, value]) => {
const item = (value ?? {}) as Partial<{
score: number
max: number
comment: string
}>
return [
key,
{
score: item.score ?? 0,
max: item.max ?? 0,
comment: item.comment ?? "",
},
]
}),
)
},
)
// jsonb 不保留键序,直接遍历会把 40 分的「逻辑正确性」排到最后
const sortedCriteria = computed(() => sortFlowchartCriteria(criteriaDetails.value))
const loading = ref(false)
const rendering = ref(false)
const showLargeImage = ref(false)
const suggestionLines = computed(() => splitSuggestionLines(submission.value?.aiSuggestions))
function splitSuggestionLines(suggestions?: string | null) {
return suggestions
? suggestions
.split("\n")
.map((suggestion) => suggestion.trim())
.filter(Boolean)
: []
}
function getPercentType(percent: number) {
if (percent >= 0.8) return "primary"
else if (percent >= 0.6) return "info"
else if (percent >= 0.4) return "warning"
return "error"
}
async function loadSubmission() {
if (!props.submissionId) return
showLargeImage.value = false
loading.value = true
try {
const { getFlowchartSubmission } = await import("oj/api")
const res = await getFlowchartSubmission(props.submissionId)
submission.value = res
// 渲染流程图
if (submission.value?.mermaidCode) {
rendering.value = true
await nextTick()
await renderFlowchart(mermaidContainer.value, submission.value.mermaidCode)
rendering.value = false
}
} catch (error) {
console.error("Failed to load submission:", error)
} finally {
loading.value = false
}
}
watch(() => props.submissionId, loadSubmission, { immediate: true })
</script>
<style scoped>
.flowchart {
height: 500px;
display: flex;
justify-content: center;
align-items: center;
}
/* 全屏大图:覆盖整个视口,脱离弹框宽度限制 */
.flowchart-fullscreen {
position: fixed;
inset: 0;
z-index: 4000;
width: 100vw;
height: 100vh;
padding: 32px;
box-sizing: border-box;
background: #ffffff;
/* 改为可滚动块布局,超出视口的大图可以滚动查看 */
display: block;
overflow: auto;
}
.fullscreen-toolbar {
position: fixed;
top: 16px;
right: 16px;
z-index: 4001;
}
/* 确保 SVG 图表占满容器 */
:deep(.flowchart > svg) {
height: 100%;
}
/* 全屏时按自然尺寸显示并水平居中,配合容器滚动 */
:deep(.flowchart-fullscreen > svg) {
display: block;
margin: 0 auto;
width: auto;
height: auto;
max-width: none;
}
.loading-container {
min-height: 600px;
display: flex;
justify-content: center;
align-items: center;
}
</style>
@@ -1,26 +0,0 @@
<template>
<n-text :type="gradeType(grade)">
<span>{{ score }}</span>
<span>({{ grade }})</span>
</n-text>
</template>
<script setup lang="ts">
import type { Grade } from "utils/types"
defineProps<{
score: number
grade: Grade
}>()
function gradeType(grade: Grade) {
return (
{
"": "default",
S: "success",
A: "info",
B: "warning",
C: "error",
} as const
)[grade]
}
</script>
<style scoped></style>
@@ -1,50 +0,0 @@
<template>
<n-flex v-if="props.submission.showLink" align="center">
<n-button text type="info" @click="$emit('showCode')">
{{ props.submission.id.slice(0, 12) }}
</n-button>
<n-tooltip>
<template #trigger>
<n-button text @click="goto">
<template #icon>
<Icon icon="catppuccin:folder-debug"></Icon>
</template>
</n-button>
</template>
查看测试详情
</n-tooltip>
</n-flex>
<n-flex v-else-if="isOwnSubmission" align="center">
<span>{{ props.submission.id.slice(0, 12) }}</span>
<n-tooltip>
<template #trigger>
<n-button text>
<template #icon>
<Icon icon="catppuccin:lock"></Icon>
</template>
</n-button>
</template>
这道题在你已经加入的题单中,只有在题单中完成此题,代码才可见。
</n-tooltip>
</n-flex>
<span v-else>{{ props.submission.id.slice(0, 12) }}</span>
</template>
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useUserStore } from "shared/store/user"
import type { SubmissionListItem } from "utils/types"
interface Props {
submission: SubmissionListItem
}
const props = defineProps<Props>()
defineEmits(["showCode"])
const userStore = useUserStore()
const isOwnSubmission = computed(() => userStore.profile?.user?.id === props.submission.userId)
function goto() {
window.open("/submission/" + props.submission.id, "_blank")
}
</script>