Deploy / deploy (push) Has been cancelled
AI 是按分值从高到低返回的:`逻辑正确性(40) → 完整性(30) → 规范性(20) → 清晰度(10)`。 但 `ai_criteria_details` 存在 jsonb 列里,而 Postgres 的 jsonb **不保留键序** —— 它按「键长度 + 字节序」重排,读出来变成 `完整性 → 清晰度 → 规范性 → 逻辑正确性`, 权重最高的那项被排到了最后。 评分弹框和教师端的评分详情都是直接 `v-for` 遍历这个对象,所以两处都乱。 统计面板因为用的是写死的 `CRITERIA_ORDER`,一直是对的 —— 于是同一份数据在两个 地方的顺序还不一致。 把顺序抽到 `utils/constants` 共享,三处统一走 `sortFlowchartCriteria()`; 表里没有的键排到后面,AI 万一返回别的评分项也不会丢。 顺带把限流的提示改得能看懂:撞上 429 时原来只显示「流程图提交失败」,学生会以为 是自己的图有问题然后反复点,越点等得越久。现在按错误码分支,提示「提交太频繁了, 缓一会儿再交」。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
575 lines
14 KiB
Vue
575 lines
14 KiB
Vue
<template>
|
||
<n-flex align="center">
|
||
<n-input
|
||
placeholder="用户(可选)"
|
||
v-model:value="query.username"
|
||
style="width: 150px"
|
||
clearable
|
||
/>
|
||
<n-input
|
||
placeholder="题号(可选)"
|
||
v-model:value="query.problem"
|
||
style="width: 120px"
|
||
clearable
|
||
/>
|
||
<n-select
|
||
style="width: 120px"
|
||
v-model:value="query.duration"
|
||
:options="durationOptions"
|
||
/>
|
||
<n-button type="primary" @click="handleStatistics">统计</n-button>
|
||
</n-flex>
|
||
|
||
<n-empty
|
||
v-if="data.totalCount === 0"
|
||
description="暂无数据"
|
||
style="margin: 40px 0"
|
||
/>
|
||
|
||
<template v-if="data.totalCount > 0">
|
||
<n-divider style="margin: 16px 0" />
|
||
<n-flex justify="space-around">
|
||
<div class="stat-item">
|
||
<n-text>总提交</n-text>
|
||
<n-gradient-text type="info" font-size="28">
|
||
{{ data.totalCount }}
|
||
</n-gradient-text>
|
||
</div>
|
||
<div class="stat-item">
|
||
<n-text>平均分</n-text>
|
||
<n-gradient-text type="primary" font-size="28">
|
||
{{ data.avgScore }}
|
||
</n-gradient-text>
|
||
</div>
|
||
<template v-if="data.personCount > 0">
|
||
<div class="stat-item">
|
||
<n-text>完成人数</n-text>
|
||
<n-gradient-text type="error" font-size="28">
|
||
{{ data.completedCount }}
|
||
</n-gradient-text>
|
||
</div>
|
||
<div class="stat-item">
|
||
<n-text>班级人数</n-text>
|
||
<n-gradient-text type="warning" font-size="28">
|
||
{{ data.personCount }}
|
||
</n-gradient-text>
|
||
</div>
|
||
<div class="stat-item">
|
||
<n-text>完成度</n-text>
|
||
<n-gradient-text type="success" font-size="28">
|
||
{{ completionRate }}
|
||
</n-gradient-text>
|
||
</div>
|
||
</template>
|
||
</n-flex>
|
||
<n-divider style="margin: 16px 0" />
|
||
|
||
<n-tabs animated type="line">
|
||
<n-tab-pane name="charts" tab="数据图表">
|
||
<n-grid :cols="2" :x-gap="20" :y-gap="20" style="margin-top: 12px">
|
||
<!-- 1. Grade pie chart -->
|
||
<n-gi>
|
||
<n-card title="等级分布">
|
||
<div class="chart-container">
|
||
<Doughnut :data="gradeChartData" :options="doughnutOptions" />
|
||
</div>
|
||
</n-card>
|
||
</n-gi>
|
||
<!-- 3. Completion doughnut -->
|
||
<n-gi v-if="data.personCount > 0">
|
||
<n-card title="班级完成度">
|
||
<div class="chart-container">
|
||
<Doughnut
|
||
:data="completionChartData"
|
||
:options="doughnutOptions"
|
||
/>
|
||
</div>
|
||
</n-card>
|
||
</n-gi>
|
||
<!-- 2. Radar chart -->
|
||
<n-gi v-if="hasRadarData">
|
||
<n-card title="四维评分雷达图">
|
||
<div class="chart-container">
|
||
<Radar :data="radarChartData" :options="radarOptions" />
|
||
</div>
|
||
</n-card>
|
||
</n-gi>
|
||
<!-- 4. Criteria bar chart (only when class exists, pairs with radar) -->
|
||
<n-gi v-if="data.personCount > 0 && hasRadarData">
|
||
<n-card title="各维度平均得分">
|
||
<div class="chart-container">
|
||
<Bar :data="criteriaBarChartData" :options="barOptions" />
|
||
</div>
|
||
</n-card>
|
||
</n-gi>
|
||
<!-- 4. Word cloud -->
|
||
<n-gi :span="2" v-if="data.wordFrequencies.length > 0">
|
||
<n-card title="常见问题高频词">
|
||
<div class="wordcloud-container">
|
||
<canvas ref="wordcloudCanvas"></canvas>
|
||
</div>
|
||
</n-card>
|
||
</n-gi>
|
||
</n-grid>
|
||
</n-tab-pane>
|
||
|
||
<n-tab-pane
|
||
v-if="data.dataUnaccepted.length > 0"
|
||
name="unaccepted"
|
||
:tab="`未完成(${visibleUnaccepted.length})`"
|
||
>
|
||
<n-flex align="center" style="margin: 12px 0">
|
||
<n-switch v-model:value="hideMode" size="large">
|
||
<template #checked>请假隐藏中</template>
|
||
<template #unchecked>请假隐藏</template>
|
||
</n-switch>
|
||
<n-button
|
||
v-if="hiddenCount > 0"
|
||
size="small"
|
||
type="info"
|
||
@click="showAll"
|
||
>
|
||
恢复 {{ hiddenCount }} 位
|
||
</n-button>
|
||
</n-flex>
|
||
<n-flex size="large" align="center">
|
||
<n-gradient-text
|
||
v-if="visibleUnaccepted.length === 0"
|
||
font-size="24"
|
||
type="success"
|
||
>
|
||
全都完成了
|
||
</n-gradient-text>
|
||
<template v-for="item in visibleUnaccepted" :key="item.username">
|
||
<n-tag
|
||
v-if="hideMode"
|
||
closable
|
||
size="large"
|
||
style="font-size: 20px"
|
||
@close="hideStudent(item.username)"
|
||
>
|
||
{{ item.realName }}
|
||
</n-tag>
|
||
<span v-else style="font-size: 24px">{{ item.realName }}</span>
|
||
</template>
|
||
</n-flex>
|
||
</n-tab-pane>
|
||
</n-tabs>
|
||
</template>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { formatISO, sub, type Duration } from "date-fns"
|
||
import type { FlowchartStatistics } from "@oj2/contract"
|
||
import { getFlowchartStatistics } from "oj/api"
|
||
import { DURATION_OPTIONS, FLOWCHART_CRITERIA_ORDER } from "utils/constants"
|
||
import { Doughnut, Radar, Bar } from "vue-chartjs"
|
||
import {
|
||
Chart as ChartJS,
|
||
ArcElement,
|
||
Title,
|
||
Tooltip,
|
||
Legend,
|
||
RadialLinearScale,
|
||
PointElement,
|
||
LineElement,
|
||
Filler,
|
||
LinearScale,
|
||
BarElement,
|
||
CategoryScale,
|
||
} from "chart.js"
|
||
import { WordCloudController, WordElement } from "chartjs-chart-wordcloud"
|
||
|
||
ChartJS.register(
|
||
ArcElement,
|
||
Title,
|
||
Tooltip,
|
||
Legend,
|
||
RadialLinearScale,
|
||
PointElement,
|
||
LineElement,
|
||
Filler,
|
||
LinearScale,
|
||
BarElement,
|
||
CategoryScale,
|
||
WordCloudController,
|
||
WordElement,
|
||
)
|
||
|
||
interface Props {
|
||
problem: string
|
||
username: string
|
||
}
|
||
|
||
const props = defineProps<Props>()
|
||
|
||
const message = useMessage()
|
||
|
||
const durationOptions: SelectOption[] = [
|
||
{ label: "10分钟内", value: "minutes:10" },
|
||
{ label: "20分钟内", value: "minutes:20" },
|
||
{ label: "30分钟内", value: "minutes:30" },
|
||
...DURATION_OPTIONS,
|
||
{ label: "全部时段", value: "all" },
|
||
]
|
||
|
||
const query = reactive({
|
||
username: props.username,
|
||
problem: props.problem,
|
||
duration: durationOptions[0].value,
|
||
})
|
||
|
||
const data = reactive<FlowchartStatistics>({
|
||
totalCount: 0,
|
||
avgScore: 0,
|
||
gradeDistribution: {},
|
||
criteriaAverages: {},
|
||
personCount: 0,
|
||
completedCount: 0,
|
||
wordFrequencies: [],
|
||
dataUnaccepted: [],
|
||
})
|
||
|
||
const wordcloudCanvas = useTemplateRef<HTMLCanvasElement>("wordcloudCanvas")
|
||
let wordcloudChart: ChartJS | null = null
|
||
|
||
const HIDE_DURATION = 2 * 60 * 60 * 1000
|
||
const STORAGE_KEY = "oj_hidden_students_flowchart"
|
||
|
||
function loadHidden(): Record<string, number> {
|
||
try {
|
||
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}")
|
||
} catch {
|
||
return {}
|
||
}
|
||
}
|
||
|
||
const hiddenStudents = ref<Record<string, number>>(loadHidden())
|
||
const hideMode = ref(false)
|
||
|
||
function saveHidden(d: Record<string, number>) {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(d))
|
||
}
|
||
|
||
function hideStudent(username: string) {
|
||
hiddenStudents.value = {
|
||
...hiddenStudents.value,
|
||
[username]: Date.now() + HIDE_DURATION,
|
||
}
|
||
saveHidden(hiddenStudents.value)
|
||
}
|
||
|
||
function showAll() {
|
||
hiddenStudents.value = {}
|
||
saveHidden({})
|
||
}
|
||
|
||
const visibleUnaccepted = computed(() => {
|
||
const now = Date.now()
|
||
return data.dataUnaccepted.filter((item) => {
|
||
const exp = hiddenStudents.value[item.username]
|
||
return !exp || exp <= now
|
||
})
|
||
})
|
||
|
||
const hiddenCount = computed(() => {
|
||
const now = Date.now()
|
||
return data.dataUnaccepted.filter((item) => {
|
||
const exp = hiddenStudents.value[item.username]
|
||
return !!exp && exp > now
|
||
}).length
|
||
})
|
||
|
||
const adjustedPersonCount = computed(() =>
|
||
Math.max(0, data.personCount - hiddenCount.value),
|
||
)
|
||
|
||
onMounted(() => {
|
||
const now = Date.now()
|
||
const cleaned = Object.fromEntries(
|
||
Object.entries(hiddenStudents.value).filter(([, exp]) => exp > now),
|
||
)
|
||
hiddenStudents.value = cleaned
|
||
saveHidden(cleaned)
|
||
})
|
||
|
||
const completionRate = computed(() => {
|
||
if (adjustedPersonCount.value <= 0) return "0%"
|
||
const rate = Math.min(
|
||
100,
|
||
(data.completedCount / adjustedPersonCount.value) * 100,
|
||
)
|
||
return `${Math.round(rate * 100) / 100}%`
|
||
})
|
||
|
||
const GRADE_COLORS: Record<string, { bg: string; border: string }> = {
|
||
S: { bg: "rgba(24, 160, 88, 0.6)", border: "rgba(24, 160, 88, 1)" },
|
||
A: { bg: "rgba(32, 128, 240, 0.6)", border: "rgba(32, 128, 240, 1)" },
|
||
B: { bg: "rgba(240, 160, 32, 0.6)", border: "rgba(240, 160, 32, 1)" },
|
||
C: { bg: "rgba(208, 48, 80, 0.6)", border: "rgba(208, 48, 80, 1)" },
|
||
}
|
||
|
||
const gradeChartData = computed(() => {
|
||
const grades = ["S", "A", "B", "C"]
|
||
const counts = grades.map((g) => data.gradeDistribution[g] || 0)
|
||
const labels = grades.map((g) => `${g}级 (${data.gradeDistribution[g] || 0})`)
|
||
return {
|
||
labels,
|
||
datasets: [
|
||
{
|
||
data: counts,
|
||
backgroundColor: grades.map((g) => GRADE_COLORS[g].bg),
|
||
borderColor: grades.map((g) => GRADE_COLORS[g].border),
|
||
borderWidth: 2,
|
||
},
|
||
],
|
||
}
|
||
})
|
||
|
||
const completionChartData = computed(() => {
|
||
const uncompleted = Math.max(
|
||
0,
|
||
adjustedPersonCount.value - data.completedCount,
|
||
)
|
||
return {
|
||
labels: ["已完成", "未完成"],
|
||
datasets: [
|
||
{
|
||
data: [data.completedCount, uncompleted],
|
||
backgroundColor: ["rgba(106, 176, 76, 0.6)", "rgba(255, 159, 64, 0.6)"],
|
||
borderColor: ["rgba(106, 176, 76, 1)", "rgba(255, 159, 64, 1)"],
|
||
borderWidth: 2,
|
||
},
|
||
],
|
||
}
|
||
})
|
||
|
||
const doughnutOptions = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: { position: "bottom" as const },
|
||
tooltip: {
|
||
callbacks: {
|
||
label(context: any) {
|
||
const label = context.label || ""
|
||
const value = context.parsed || 0
|
||
const total = context.dataset.data.reduce(
|
||
(a: number, b: number) => a + b,
|
||
0,
|
||
)
|
||
const pct = ((value / total) * 100).toFixed(1)
|
||
return `${label}: ${value} (${pct}%)`
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
// 与评分明细共用同一份顺序,见 utils/constants
|
||
const CRITERIA_ORDER = FLOWCHART_CRITERIA_ORDER
|
||
|
||
const hasRadarData = computed(() =>
|
||
CRITERIA_ORDER.some((k) => k in data.criteriaAverages),
|
||
)
|
||
|
||
const radarChartData = computed(() => {
|
||
const labels = CRITERIA_ORDER
|
||
const values = CRITERIA_ORDER.map((k) => {
|
||
const item = data.criteriaAverages[k]
|
||
if (!item) return 0
|
||
return Math.round((item.avg / item.max) * 100)
|
||
})
|
||
return {
|
||
labels,
|
||
datasets: [
|
||
{
|
||
label: "平均得分率 (%)",
|
||
data: values,
|
||
backgroundColor: "rgba(32, 128, 240, 0.2)",
|
||
borderColor: "rgba(32, 128, 240, 1)",
|
||
borderWidth: 2,
|
||
pointBackgroundColor: "rgba(32, 128, 240, 1)",
|
||
},
|
||
],
|
||
}
|
||
})
|
||
|
||
const radarOptions = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
scales: {
|
||
r: {
|
||
beginAtZero: true,
|
||
max: 100,
|
||
ticks: { stepSize: 20 },
|
||
},
|
||
},
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: {
|
||
callbacks: {
|
||
label(context: any) {
|
||
const key = CRITERIA_ORDER[context.dataIndex]
|
||
const item = data.criteriaAverages[key]
|
||
if (!item) return ""
|
||
return `${key}: ${item.avg}/${item.max} (${context.parsed.r}%)`
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
const criteriaBarChartData = computed(() => {
|
||
const labels = CRITERIA_ORDER.filter((k) => k in data.criteriaAverages)
|
||
return {
|
||
labels,
|
||
datasets: [
|
||
{
|
||
label: "平均得分",
|
||
data: labels.map((k) => data.criteriaAverages[k]?.avg ?? 0),
|
||
backgroundColor: labels.map(
|
||
(_, i) => GRADE_COLORS[["S", "A", "B", "C"][i]].bg,
|
||
),
|
||
borderColor: labels.map(
|
||
(_, i) => GRADE_COLORS[["S", "A", "B", "C"][i]].border,
|
||
),
|
||
borderWidth: 2,
|
||
},
|
||
],
|
||
}
|
||
})
|
||
|
||
const barOptions = {
|
||
responsive: true,
|
||
aspectRatio: 1,
|
||
scales: {
|
||
y: { beginAtZero: true },
|
||
},
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: {
|
||
callbacks: {
|
||
label(context: any) {
|
||
const key = context.label
|
||
const item = data.criteriaAverages[key]
|
||
if (!item) return ""
|
||
return `${item.avg} / ${item.max}`
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
const WORD_COLORS = [
|
||
"#2080f0",
|
||
"#18a058",
|
||
"#f0a020",
|
||
"#d03050",
|
||
"#722ed1",
|
||
"#13c2c2",
|
||
"#1890ff",
|
||
"#52c41a",
|
||
"#faad14",
|
||
"#f5222d",
|
||
]
|
||
|
||
function renderWordCloud() {
|
||
if (!wordcloudCanvas.value || data.wordFrequencies.length === 0) return
|
||
|
||
if (wordcloudChart) {
|
||
wordcloudChart.destroy()
|
||
wordcloudChart = null
|
||
}
|
||
|
||
const words = data.wordFrequencies
|
||
const maxCount = Math.max(...words.map((w) => w.count))
|
||
|
||
wordcloudChart = new ChartJS(wordcloudCanvas.value, {
|
||
type: "wordCloud" as any,
|
||
data: {
|
||
labels: words.map((w) => w.word),
|
||
datasets: [
|
||
{
|
||
label: "",
|
||
data: words.map((w) => 10 + (w.count / maxCount) * 50),
|
||
color: words.map((_, i) => WORD_COLORS[i % WORD_COLORS.length]),
|
||
rotate: 0,
|
||
} as any,
|
||
],
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: {
|
||
callbacks: {
|
||
label(context: any) {
|
||
const word = words[context.dataIndex]
|
||
return word ? `${word.word}: ${word.count}次` : ""
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
})
|
||
}
|
||
|
||
const subOptions = computed<Duration>(() => {
|
||
const dur =
|
||
durationOptions.find((it) => it.value === query.duration) ??
|
||
durationOptions[0]
|
||
const x = dur.value!.toString().split(":")
|
||
return { [x[0]]: parseInt(x[1]) }
|
||
})
|
||
|
||
async function handleStatistics() {
|
||
const current = Date.now()
|
||
const end = formatISO(current)
|
||
const duration =
|
||
query.duration === "all"
|
||
? { end }
|
||
: { start: formatISO(sub(current, subOptions.value)), end }
|
||
try {
|
||
const res = await getFlowchartStatistics(
|
||
duration,
|
||
query.problem,
|
||
query.username,
|
||
)
|
||
Object.assign(data, res)
|
||
} catch (error) {
|
||
message.error("获取流程图统计失败")
|
||
console.error("获取流程图统计失败:", error)
|
||
return
|
||
}
|
||
await nextTick()
|
||
renderWordCloud()
|
||
}
|
||
|
||
onUnmounted(() => {
|
||
if (wordcloudChart) {
|
||
wordcloudChart.destroy()
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.stat-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.chart-container {
|
||
height: 280px;
|
||
position: relative;
|
||
}
|
||
|
||
.wordcloud-container {
|
||
height: 300px;
|
||
position: relative;
|
||
}
|
||
</style>
|