feat(题目页): 「统计」页签重做——学生看难不难、几次做对、常错在哪,老师按班看谁没做对

- 全部按人算:原来的「通过数」是 AC 条数、「通过率」按提交算;击败用户、饼图、历年 AC 率去掉
- 错得最多的那种给一句该怎么办(都指向「运行例子」),自己交了 3 次以上还没对再多一张卡
- 老师选一个班:做对 / 交了没对(次数、最后结果)/ 没交;班里错得和全站不一样时点出来
- 题库入口工具栏的「课堂统计」并进这里(题单没有页签,按钮留着)
- 新接口 /problems/:id/stats(按内部题号,比赛题不再查到同号公开题),删掉 beat-count、yearly-ac

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-30 00:39:57 +08:00
1 parent 91d9c9c6d7
commit 99319fdcc5
10 files changed
+1017 -317

No files matched your search

+9 -10
View File
@@ -22,7 +22,7 @@ import {
type CreateSubmissionResponse,
type ProblemAuthor,
type ProblemListItem,
type YearlyAc,
type ProblemStats,
type ProblemList,
type CreateFlowchartResponse,
type FlowchartScores,
@@ -115,9 +115,14 @@ export async function getProblem(problemID: string, contestID: string) {
return problem
}
// 未登录返回 "0",登录后返回百分比字符串
export function getProblemBeatRate(problemID: number) {
return api.get<string>(`problems/${problemID}/beat-count`)
/**
* 题目页「统计」页签。problemID 是内部题号(problem.id),比赛题也能查。
* className 只有老师用:看哪个班的明细,不填就是最近做过这题的班
*/
export function getProblemStats(problemID: number, className?: string) {
return api.get<ProblemStats>(`problems/${problemID}/stats`, {
params: className ? { className } : {},
})
}
export async function getSubmission(id: string): Promise<Submission> {
@@ -405,12 +410,6 @@ export function getSimilarProblems(problemId: string) {
.then((response) => response.map(toProblemRow))
}
export type { YearlyAc as YearlyACData } from "@oj2/contract"
export function getProblemYearlyAC(problemId: string) {
return api.get<YearlyAc[]>(`problems/${encodeURIComponent(problemId)}/yearly-ac`)
}
// ==================== 流程图相关API ====================
export function submitFlowchart(data: {
@@ -142,7 +142,7 @@ function goAllSubmissions() {
</button>
</div>
</template>
<ProblemInfo v-if="lastOpened === 'info'" />
<ProblemInfo v-if="lastOpened === 'info'" @open-submissions="open = 'submission'" />
<ProblemReaction v-else-if="lastOpened === 'reaction'" />
<template v-else>
<ProblemSubmission />
+679 -127
View File
@@ -1,164 +1,716 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { storeToRefs } from "pinia"
import { getProblemStats } from "oj/api"
import { useContestStore } from "oj/store/contest"
import { useProblemStore } from "oj/store/problem"
import { useTone, type Tone } from "oj/submission/composables/tone"
import { classLabel } from "oj/submission/utils"
import { DIFFICULTY, JUDGE_STATUS } from "utils/constants"
import type { SUBMISSION_RESULT } from "utils/types"
import { getACRateNumber, getTagColor, parseTime } from "utils/functions"
import { Pie } from "vue-chartjs"
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend, Colors } from "chart.js"
import { getProblemBeatRate } from "oj/api"
import { getProblemYearlyAC, type YearlyACData } from "oj/api"
import ProblemYearlyChart from "./ProblemYearlyChart.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { getTagColor, parseTime } from "utils/functions"
import type { ProblemStats, SUBMISSION_RESULT } from "utils/types"
import { useProblemPageContext } from "../composables/problemPageContext"
// 仅注册饼图所需的 Chart.js 组件
ChartJS.register(ArcElement, Title, Tooltip, Legend, Colors)
/**
* 「统计」页签(设计稿「题目页统计重设计」:学生 A、老师 B)。
*
* 学生看的是「这题难不难 → 大家几次做对 → 常错在哪 → 你们班」,全部按人算;自己卡住了
* 再多一张卡。老师看的是一个班:谁做对了、谁交了没对(卡在哪)、谁还没交 —— 原来工具栏上
* 的「课堂统计」并进了这里。原来的描述表、击败用户、饼图、历年 AC 率都去掉了。
*/
const emit = defineEmits<{ openSubmissions: [] }>()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const { problem } = storeToRefs(useProblemStore())
const contestStore = useContestStore()
const ctx = useProblemPageContext()
const route = useRoute()
const router = useRouter()
const theme = useThemeVars()
const tone = useTone()
const { isDesktop } = useBreakpoints()
const stats = ref<ProblemStats | null>(null)
const failed = ref(false)
const className = ref<string | null>(null)
const beatRate = ref("0")
const yearlyACData = ref<YearlyACData[]>([])
const data = computed(() => {
// statisticInfo 是 { 判题状态码: 次数 } 的 JSONB,契约里是 Record<string, unknown>
const status = problem.value!.statisticInfo as Record<string, number>
const labels: string[] = []
const values: number[] = []
for (const code in status) {
if (status[code] !== 0) {
labels.push(JUDGE_STATUS[Number(code) as SUBMISSION_RESULT].name)
values.push(status[code]!)
}
async function load() {
if (!problem.value) return
failed.value = false
try {
const res = await getProblemStats(problem.value.id, className.value ?? undefined)
stats.value = res
if (res.classDetail) className.value = res.classDetail.className
} catch {
failed.value = true
}
return {
labels,
datasets: [{ data: values, hoverOffset: 5, borderRadius: 10 }],
}
onMounted(load)
function changeClass(value: string) {
className.value = value
load()
}
const teacherView = computed(() => stats.value?.classes != null)
function resultName(result: number) {
return JUDGE_STATUS[result as SUBMISSION_RESULT]?.name ?? "其他"
}
function resultTone(result: number): Tone {
return JUDGE_STATUS[result as SUBMISSION_RESULT]?.type ?? "default"
}
/** 条的颜色:运行时错误橙、答案错误红、编译失败灰 —— 和提交列表的胶囊一个配色 */
function barColor(result: number) {
const kind = resultTone(result)
// 编译失败用灰:它和运行时错误都是 warning,都画成橙色就分不开了
if (result === -2 || kind === "default" || kind === "info") return theme.value.textColor3
return tone(kind).color
}
// ---------- 学生 ----------
const verdict = computed(() => {
const s = stats.value
if (!s || !s.tried) return null
const rate = s.solved / s.tried
const firstShare = s.solved ? s.tries.one / s.solved : 0
const manyShare = s.solved ? s.tries.many / s.solved : 0
const detail = `${s.tried} 人做过,${s.solved} 人做对了`
if (rate >= 0.9 && firstShare >= 0.6) {
return { kind: "success" as const, text: "这题不难:大多数人第一次交就对了", detail }
}
if (problem.value?.difficulty === "Low" && (rate < 0.9 || manyShare >= 0.15)) {
return { kind: "warning" as const, text: "标的是「简单」,但不少人卡住了", detail }
}
if (rate >= 0.75) {
return { kind: "info" as const, text: "多数人做对了,不少人要多试几次", detail }
}
return { kind: "warning" as const, text: "这题有点难:不少人还没做对", detail }
})
const numbers = computed(() => {
const triesSegments = computed(() => {
const s = stats.value
if (!s) return []
const green = theme.value.successColor
return [
{ key: "one", label: "1 次", value: s.tries.one, color: green, opacity: 1 },
{ key: "few", label: "2–3 次", value: s.tries.few, color: green, opacity: 0.6 },
{ key: "many", label: "4 次以上", value: s.tries.many, color: green, opacity: 0.3 },
{
icon: "streamline-ultimate-color:checklist",
title: problem.value?.submissionNumber ?? 0,
content: "总提交",
int: true,
suffix: "",
},
{
icon: "streamline-emojis:woman-raising-hand-2",
title: problem.value?.acceptedNumber ?? 0,
content: "通过数",
int: true,
suffix: "",
},
{
icon: "fluent-emoji:chart-increasing",
title: getACRateNumber(
problem.value?.acceptedNumber ?? 0,
problem.value?.submissionNumber ?? 0,
),
content: "通过率",
int: false,
suffix: "%",
},
{
icon: "streamline-emojis:sparkles",
title: parseFloat(beatRate.value),
content: "击败用户",
int: false,
suffix: "%",
key: "none",
label: "没做对",
value: s.tried - s.solved,
color: theme.value.dividerColor,
opacity: 1,
},
]
})
const options = {
plugins: {
title: { text: "提交结果的比例", display: true, font: { size: 20 } },
},
}
const failedTotal = computed(() =>
(stats.value?.failures ?? []).reduce((sum, item) => sum + item.count, 0),
)
const topFailures = computed(() => (stats.value?.failures ?? []).slice(0, 3))
async function getBeatRate() {
const res = await getProblemBeatRate(problem.value!.id)
beatRate.value = res
}
async function getYearlyAC() {
const res = await getProblemYearlyAC(problem.value!._id)
yearlyACData.value = res
}
onMounted(() => {
getBeatRate()
getYearlyAC()
/** 错得最多的那一种,给一句该怎么办。都指向「运行例子」:提交前自己就能看出来 */
const tip = computed(() => {
const s = stats.value
const top = s?.failures[0]
if (!s || !top) return ""
if (top.result === -1) {
return s.wrongAnswerFirstCase >= top.count * 0.6
? "答案错误几乎都卡在第 1 个测试点:多半是例子都没对上。先点「运行例子」,把你的输出和例子一行一行对一对。"
: "答案错误最多:输出和标准答案对不上。留意空格、换行和标点,交之前先点「运行例子」对一对。"
}
if (top.result === 4) {
return "运行时错误最多:程序跑到一半出错了。交之前先点「运行例子」,看看有没有红色的报错。"
}
if (top.result === -2) {
return "编译失败最多:代码有语法错误,程序根本没跑起来。交之前先点「运行例子」检查一下。"
}
return ""
})
/** 自己交了好几次还没对:比赛里没有举手,也不提「看别人」 */
const stuck = computed(() => {
const me = stats.value?.me
return me && !me.solved && me.attempts >= 3 ? me.attempts : 0
})
// ---------- 老师 ----------
const detail = computed(() => stats.value?.classDetail ?? null)
const classOptions = computed(() =>
(stats.value?.classes ?? []).map((item) => ({
label: `${classLabel(item.className)} · ${item.solved}/${item.tried}`,
value: item.className,
})),
)
const classSegments = computed(() => {
const d = detail.value
if (!d) return []
return [
{ key: "solved", label: "做对", value: d.solved.length, color: theme.value.successColor },
{ key: "unsolved", label: "交了没对", value: d.unsolved.length, color: theme.value.errorColor },
{ key: "untouched", label: "没交", value: d.untouched.length, color: theme.value.dividerColor },
]
})
const classFailedTotal = computed(() =>
(detail.value?.failures ?? []).reduce((sum, item) => sum + item.count, 0),
)
/** 这个班错得最多的和全站不一样时点出来:老师该看的就是这种差别 */
const siteDiffers = computed(() => {
const site = stats.value?.failures[0]
const mine = detail.value?.failures[0]
return site && mine && site.result !== mine.result ? resultName(site.result) : ""
})
const showSolved = ref(false)
const showUntouched = ref(false)
function openStatistics() {
const href = router.resolve({
name: "statistics",
query: { problem: problem.value!._id, className: className.value ?? "", period: "all" },
}).href
window.open(href, "_blank")
}
function openClassSubmissions() {
const href = router.resolve({
name: "submissions",
query: { problem: problem.value!._id, className: className.value ?? "" },
}).href
window.open(href, "_blank")
}
/** 「2025-12-05」→「2025年12月5日」。后端给的已经是东八区的日子,不用再换算 */
function dayText(day: string) {
const [year, month, date] = day.split("-").map(Number)
return `${year}年${month}月${date}日`
}
function segmentWidth(value: number, total: number) {
return total ? `${(value / total) * 100}%` : "0"
}
</script>
<template>
<n-descriptions bordered label-placement="left" :column="isDesktop ? 3 : 1" v-if="problem">
<n-descriptions-item label="编号">
{{ problem._id }}
</n-descriptions-item>
<n-descriptions-item label="出题人">
{{ problem.createdBy.username }}
</n-descriptions-item>
<n-descriptions-item label="创建时间">
{{ parseTime(problem.createTime) }}
</n-descriptions-item>
<!-- 比赛进行中,难度不下发(difficulty 为 null),这一项整个不显示 -->
<n-descriptions-item v-if="problem.difficulty" label="难度">
<n-tag :type="getTagColor(problem.difficulty)">
<div v-if="problem" class="info">
<!-- 原来占一大块的描述表收成一行 -->
<div class="meta">
<b class="meta-title">{{ problem._id }} {{ problem.title }}</b>
<!-- 比赛进行中难度不下发(null),整个不显示 -->
<n-tag
v-if="problem.difficulty"
size="small"
round
:bordered="false"
:type="getTagColor(problem.difficulty)"
>
{{ DIFFICULTY[problem.difficulty] }}
</n-tag>
</n-descriptions-item>
<n-descriptions-item :span="2" label="标签">
<n-flex>
<n-tag type="info" v-for="tag in problem.tags" :key="tag">
{{ tag }}
</n-tag>
</n-flex>
</n-descriptions-item>
</n-descriptions>
<n-grid :cols="isDesktop ? 4 : 2" :x-gap="10" :y-gap="10" class="cards">
<n-gi v-for="item in numbers" :key="item.content">
<n-card hoverable>
<n-flex vertical align="center">
<Icon v-if="isDesktop" :icon="item.icon" width="40" />
<n-h2 class="number">
<n-number-animation :to="item.title" :precision="item.int ? 0 : 2" />
<span v-if="item.suffix">{{ item.suffix }}</span>
</n-h2>
<n-h4 class="number-label">{{ item.content }}</n-h4>
</n-flex>
</n-card>
</n-gi>
</n-grid>
<div class="pie" v-if="problem && problem.submissionNumber > 0">
<Pie :data="data" :options="options" />
<n-tag
v-for="tag in problem.tags"
:key="tag"
size="small"
round
:bordered="false"
type="info"
>
{{ tag }}
</n-tag>
<span class="spacer"></span>
<n-text depth="3" class="meta-author">{{ problem.createdBy.username }} 出题</n-text>
</div>
<n-text v-if="failed" type="error">统计没拉下来,关掉再打开试试</n-text>
<div v-else-if="!stats" class="center"><n-spin size="small" /></div>
<!-- 比赛进行中:别人的情况等比赛结束再公布 -->
<div v-else-if="stats.locked" class="empty">
<span class="empty-icon"><Icon icon="ph:lock-simple" :width="18" /></span>
<b>比赛结束后才能看到大家的情况</b>
<n-text depth="3">
<template v-if="contestStore.contest">
比赛 {{ parseTime(contestStore.contest.endTime, "HH:mm") }} 结束。
</template>
现在只看得到你自己的提交,别人几次做对、错在哪,结束后一起公布。
</n-text>
<router-link
:to="{ name: 'contest rank', params: { contestID: route.params.contestID } }"
class="link"
>
去看比赛排名 ›
</router-link>
</div>
<!-- ==================== 老师:看一个班 ==================== -->
<template v-else-if="teacherView">
<div v-if="!detail" class="empty">
<span class="empty-icon"><Icon icon="ph:users" :width="18" /></span>
<b>还没有班级做过这道题</b>
<n-text depth="3">布置到这节课之后,这里会按班显示谁做对了、谁还没交。</n-text>
<n-button type="primary" secondary @click="router.push('/classroom')">
去课堂看板布置 →
</n-button>
</div>
<template v-else>
<div class="class-head">
<n-select
:value="className"
:options="classOptions"
size="small"
class="class-select"
:consistent-menu-width="false"
@update:value="changeClass"
/>
<n-text depth="3" class="small">{{ dayText(detail.day) }}做的</n-text>
<span class="spacer"></span>
<a href="#" class="link small" @click.prevent="openStatistics">
在数据统计里看 <Icon icon="ph:arrow-square-out" :width="13" />
</a>
</div>
<div class="bar">
<span
v-for="seg in classSegments"
:key="seg.key"
:style="{ width: segmentWidth(seg.value, detail.roster), background: seg.color }"
></span>
</div>
<div class="legend">
<span v-for="seg in classSegments" :key="seg.key">
<i :style="{ background: seg.color }"></i>{{ seg.label }} <b>{{ seg.value }}</b>
</span>
</div>
<div v-if="classFailedTotal" class="line">
<b>没通过的 {{ classFailedTotal }} 次:</b>
<n-text depth="2">
{{
detail.failures
.slice(0, 3)
.map((item) => `${resultName(item.result)} ${item.count}`)
.join(" · ")
}}
</n-text>
<span class="spacer"></span>
<n-text v-if="siteDiffers" depth="3" class="small">全站是{{ siteDiffers }}最多</n-text>
</div>
<template v-if="detail.unsolved.length">
<div class="line">
<b>交了没对({{ detail.unsolved.length }} 人)</b>
<span class="spacer"></span>
<n-text depth="3" class="small">次数多的在前</n-text>
</div>
<div class="people">
<div v-for="(item, index) in detail.unsolved" :key="index" class="person">
<b>{{ item.realName }}</b>
<n-text depth="3">{{ item.attempts }} 次</n-text>
<span :style="{ color: barColor(item.lastResult) }">
{{ resultName(item.lastResult) }}
</span>
</div>
</div>
</template>
<div v-if="detail.solved.length" class="fold">
<div class="fold-head" @click="showSolved = !showSolved">
<b>做对 {{ detail.solved.length }}</b>
<span class="fold-names">
{{ showSolved ? "" : detail.solved.join("、") }}
</span>
<span class="link small">{{ showSolved ? "收起" : "展开" }}</span>
</div>
<div v-if="showSolved" class="fold-body">{{ detail.solved.join("、") }}</div>
</div>
<div v-if="detail.untouched.length" class="fold">
<div class="fold-head" @click="showUntouched = !showUntouched">
<b>没交 {{ detail.untouched.length }}</b>
<span class="fold-names">{{ showUntouched ? "" : "展开看名单" }}</span>
<span class="link small">{{ showUntouched ? "收起" : "展开" }}</span>
</div>
<div v-if="showUntouched" class="fold-body">{{ detail.untouched.join("、") }}</div>
</div>
<div class="foot">
<a href="#" class="link small" @click.prevent="openClassSubmissions">
看这个班这道题的提交 ›
</a>
</div>
</template>
</template>
<!-- ==================== 学生 ==================== -->
<div v-else-if="!stats.tried" class="empty">
<span class="empty-icon"><Icon icon="ph:sparkle" :width="18" /></span>
<b>还没有人交过这道题</b>
<n-text depth="3">你可以做第一个。</n-text>
</div>
<template v-else>
<div
v-if="verdict"
class="verdict"
:style="{
background: tone(verdict.kind).background,
borderColor: tone(verdict.kind).background,
}"
>
<b :style="{ color: tone(verdict.kind).color }">
<Icon icon="ph:gauge" :width="16" />{{ verdict.text }}
</b>
<n-text depth="2" class="small">{{ verdict.detail }}</n-text>
</div>
<div v-if="stats.solved" class="section">
<div class="line">
<b>做对用了几次</b>
<span class="spacer"></span>
<n-text depth="3" class="small">按人算</n-text>
</div>
<div class="bar">
<span
v-for="seg in triesSegments"
:key="seg.key"
:style="{
width: segmentWidth(seg.value, stats.tried),
background: seg.color,
opacity: seg.opacity,
}"
></span>
</div>
<div class="legend">
<span v-for="seg in triesSegments" :key="seg.key">
<i :style="{ background: seg.color, opacity: seg.opacity }"></i>{{ seg.label }}
<b>{{ seg.value }}</b>
</span>
</div>
</div>
<div v-if="topFailures.length" class="section">
<b>没通过的 {{ failedTotal }} 次,错在哪</b>
<div v-for="item in topFailures" :key="item.result" class="fail">
<span class="fail-name">{{ resultName(item.result) }}</span>
<span class="fail-track">
<span
:style="{
width: segmentWidth(item.count, topFailures[0]!.count),
background: barColor(item.result),
}"
></span>
</span>
<span class="fail-count">{{ item.count }} 次</span>
</div>
<div
v-if="tip"
class="tip"
:style="{ background: tone('warning').background, color: tone('warning').color }"
>
<Icon icon="ph:lightbulb" :width="15" class="tip-icon" />
<span>{{ tip }}</span>
</div>
</div>
<div v-if="stats.myClass?.tried" class="class-box">
<div class="line">
<b>你们班 · {{ classLabel(stats.myClass.className) }}</b>
<span class="spacer"></span>
<span>
<b class="big">{{ stats.myClass.solved }}</b>
<n-text depth="3"> / {{ stats.myClass.tried }} 人做对</n-text>
</span>
</div>
<div class="bar thin">
<span
:style="{
width: segmentWidth(stats.myClass.solved, stats.myClass.tried),
background: theme.successColor,
}"
></span>
</div>
</div>
<div
v-if="stuck && ctx.entry !== 'contest'"
class="stuck"
:style="{ borderColor: tone('warning').background, background: tone('warning').background }"
>
<b>你交了 {{ stuck }} 次,还没对上</b>
<n-text depth="2" class="small">
卡了这么久,可以点右上角「举手求助」问老师;也可以去「我的提交」看看每一次错在哪。
</n-text>
<div>
<n-button size="small" @click="emit('openSubmissions')">看我的提交</n-button>
</div>
</div>
</template>
</div>
<ProblemYearlyChart :data="yearlyACData" />
</template>
<style scoped>
.cards {
margin-top: 24px;
.info {
display: flex;
flex-direction: column;
gap: 14px;
font-size: 14px;
}
.number {
margin: 0;
font-weight: bold;
.meta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.number-label {
margin: 0;
.meta-title {
margin-right: 4px;
}
.pie {
width: 100%;
max-width: 500px;
margin: 24px auto;
.meta-author,
.small {
font-size: 13px;
}
.spacer {
flex: 1 1 0;
}
.center {
display: flex;
justify-content: center;
padding: 40px 0;
}
.empty {
padding: 60px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.empty-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: v-bind("theme.actionColor");
color: v-bind("theme.textColor2");
margin-bottom: 4px;
}
.link {
color: v-bind("theme.primaryColor");
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 3px;
cursor: pointer;
}
.verdict {
padding: 12px 14px;
border-radius: 8px;
border: 1px solid;
display: flex;
flex-direction: column;
gap: 4px;
}
.verdict b {
font-size: 16px;
display: flex;
align-items: center;
gap: 6px;
}
.section {
display: flex;
flex-direction: column;
gap: 8px;
}
.line {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.bar {
height: 12px;
border-radius: 3px;
overflow: hidden;
display: flex;
gap: 2px;
background: v-bind("theme.dividerColor");
}
.bar.thin {
height: 8px;
}
.bar > span {
height: 100%;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
font-size: 13px;
color: v-bind("theme.textColor2");
}
.legend i {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
margin-right: 5px;
vertical-align: -1px;
}
.fail {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
}
.fail-name {
width: 72px;
flex: none;
}
.fail-track {
flex: 1;
height: 10px;
border-radius: 5px;
background: v-bind("theme.actionColor");
overflow: hidden;
}
.fail-track > span {
display: block;
height: 100%;
border-radius: 5px;
}
.fail-count {
width: 48px;
flex: none;
text-align: right;
font-variant-numeric: tabular-nums;
}
.tip {
display: flex;
gap: 8px;
padding: 10px 12px;
border-radius: 6px;
font-size: 13px;
line-height: 1.6;
}
.tip-icon {
flex: none;
margin-top: 3px;
}
.class-box {
padding: 12px 14px;
border-radius: 8px;
border: 1px solid v-bind("theme.dividerColor");
display: flex;
flex-direction: column;
gap: 8px;
}
.big {
font-size: 18px;
color: v-bind("theme.successColor");
}
.stuck {
padding: 12px 14px;
border-radius: 8px;
border: 1px solid;
display: flex;
flex-direction: column;
gap: 6px;
}
.class-head {
display: flex;
align-items: center;
gap: 10px;
}
.class-select {
width: 190px;
}
.people {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 6px;
}
.person {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 6px;
border: 1px solid v-bind("theme.dividerColor");
font-size: 13px;
white-space: nowrap;
overflow: hidden;
}
.fold {
border-radius: 6px;
background: v-bind("theme.actionColor");
font-size: 13px;
}
.fold-head {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
}
.fold-names {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: v-bind("theme.textColor2");
}
.fold-body {
padding: 0 12px 10px;
line-height: 1.8;
color: v-bind("theme.textColor2");
}
.foot {
display: flex;
justify-content: flex-end;
}
</style>
@@ -1,77 +0,0 @@
<template>
<div class="yearly-chart" v-if="props.data.length > 1">
<Line :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup lang="ts">
import { Line } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
Filler,
LinearScale,
LineElement,
PointElement,
Title,
Tooltip,
} from "chart.js"
import type { YearlyACData } from "oj/api"
ChartJS.register(CategoryScale, Filler, LinearScale, LineElement, PointElement, Title, Tooltip)
const props = defineProps<{ data: YearlyACData[] }>()
const chartData = computed(() => ({
labels: props.data.map((d) => String(d.year)),
datasets: [
{
label: "AC 率",
data: props.data.map((d) => d.acRate),
fill: true,
tension: 0.3,
backgroundColor: "rgba(99, 179, 237, 0.2)",
borderColor: "rgba(99, 179, 237, 1)",
pointBackgroundColor: "rgba(99, 179, 237, 1)",
},
],
}))
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: "历年 AC 率",
font: { size: 20 },
},
tooltip: {
callbacks: {
label: (context: any) => {
const d = props.data[context.dataIndex]
return [`AC 率: ${d.acRate}%`, `通过: ${d.accepted} / ${d.total}`]
},
},
},
},
scales: {
y: {
min: 0,
max: 100,
ticks: {
callback: (value: any) => `${value}%`,
},
},
},
}))
</script>
<style scoped>
.yearly-chart {
width: 100%;
max-width: 500px;
height: 250px;
margin: 24px auto;
}
</style>
@@ -39,8 +39,9 @@ interface EntryRules {
*/
collab: boolean
/**
* 教师的「课堂统计」。统计接口按 displayId 在公开题库里找题(`submission-statistics.ts`),
* 比赛题号拿过去查到的是另一道公开题,或者 404
* 工具栏上教师的「课堂统计」(新标签开数据统计页)。题库入口已经并进「统计」页签的老师
* 视图(按班看谁没做对,带「在数据统计里看」),只剩没有抽屉的题单入口还要这个按钮。
* 比赛不给:统计接口按 displayId 在公开题库里找题,比赛题号查到的是另一道公开题,或者 404
*/
classStats: boolean
/**
@@ -60,7 +61,7 @@ const RULES: Record<ProblemEntry, EntryRules> = {
backToProblemSet: false,
help: true,
collab: true,
classStats: true,
classStats: false,
allSubmissionsInMenu: false,
},
// 题单里什么「以前的」都不给看:加入题单之前的提交、统计、点评都能拿来抄答案
+1
View File
@@ -81,6 +81,7 @@ export type ProblemStatus = "passed" | "failed" | "not_test"
* shared/api.ts 原来还得用 `Tag & { problemCount: number }` 把它补回来。
*/
export type { Tag, TagCategory } from "@oj2/contract"
export type { ProblemStats, ProblemClassDetail } from "@oj2/contract"
export type {
AdminTag,