Deploy / deploy (push) Canceled after 0s
- classLabel / groupClassOptions / classSelectOptions 挪到 utils/classes.ts,useTone 挪到 shared/composables/tone.ts:原来放在 oj/submission/ 下,被前台、后台、shared 引了 50 多处 - 流程图编辑器的草稿键改由题目页传进来(键名不变,学生存着的草稿照样读得到)。原来编辑器 自己读题目页的 store 和路由拼,后台出题页也用这个组件,那边 store 是空的 - SQL 表名补全改由题目页传表进来。原来补全直接读题目页的 store,自学页的 SQL 代码块会 补出上一道看过的题的表名 实跑:提交列表、题目页、排名、后台题目页、SQL 题、流程图题打开无报错;SQL 题输入 stu 补出 students;流程图加一步,草稿照旧存在 flowchart-v2-problem-<题号>。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
621 lines
17 KiB
Vue
621 lines
17 KiB
Vue
<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 "shared/composables/tone"
|
||
import { classLabel } from "utils/classes"
|
||
import { submissionDayText } from "oj/submission/utils"
|
||
import { DIFFICULTY, JUDGE_STATUS } from "utils/constants"
|
||
import { getTagColor, parseTime } from "utils/functions"
|
||
import type { ProblemStats, SUBMISSION_RESULT } from "utils/types"
|
||
import { useProblemPageContext } from "../composables/problemPageContext"
|
||
|
||
/**
|
||
* 「统计」页签(设计稿「题目页统计重设计」学生 A)。讲的是**这道题**:难不难 → 大家几次
|
||
* 做对 → 常错在哪,全部按人算。学生多一行「你们班」、自己卡住了再多一张卡;老师多一张
|
||
* 各班的表(只到班级这一层,不选班、不列人 —— 按人看是提交页「统计」的活)。
|
||
* 原来的描述表、击败用户、饼图、历年 AC 率都去掉了。
|
||
*/
|
||
const emit = defineEmits<{ openSubmissions: [] }>()
|
||
|
||
const { problem } = storeToRefs(useProblemStore())
|
||
const contestStore = useContestStore()
|
||
const ctx = useProblemPageContext()
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const theme = useThemeVars()
|
||
const tone = useTone()
|
||
|
||
const stats = ref<ProblemStats | null>(null)
|
||
const failed = ref(false)
|
||
|
||
async function load() {
|
||
if (!problem.value) return
|
||
failed.value = false
|
||
try {
|
||
stats.value = await getProblemStats(problem.value.id)
|
||
} catch {
|
||
failed.value = true
|
||
}
|
||
}
|
||
|
||
onMounted(load)
|
||
|
||
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 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 },
|
||
{
|
||
key: "none",
|
||
label: "没做对",
|
||
value: s.tried - s.solved,
|
||
color: theme.value.dividerColor,
|
||
opacity: 1,
|
||
},
|
||
]
|
||
})
|
||
|
||
const failedTotal = computed(() =>
|
||
(stats.value?.failures ?? []).reduce((sum, item) => sum + item.count, 0),
|
||
)
|
||
const topFailures = computed(() => (stats.value?.failures ?? []).slice(0, 3))
|
||
|
||
/**
|
||
* 错得最多的那一种,给一句该怎么办。都指向「运行例子」:提交前自己就能看出来。
|
||
*
|
||
* **答案错误最多时不给**:七成多的题错得最多的都是答案错误(快照 349 / 484),「卡在第 1 个
|
||
* 测试点」也是常态(320 道里 185 道过 60%),这句话几乎每道题都一样,等于没说(用户说的)。
|
||
* 具体错在哪,每次提交的结果里有「题目里的例子 N 就错了」和三栏对照。
|
||
* 运行时错误、编译失败排第一的题少,出现了才说明这题有特点
|
||
*/
|
||
const tip = computed(() => {
|
||
const top = stats.value?.failures[0]
|
||
if (!top) return ""
|
||
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
|
||
})
|
||
|
||
// ---------- 老师:各班的表 ----------
|
||
|
||
/** 班多的老题(1001 这种十几个班都做过)先给 6 个,交过的人多的在前 */
|
||
const allClasses = ref(false)
|
||
const shownClasses = computed(() =>
|
||
allClasses.value ? (stats.value?.classes ?? []) : (stats.value?.classes ?? []).slice(0, 6),
|
||
)
|
||
const hiddenClasses = computed(
|
||
() => (stats.value?.classes?.length ?? 0) - shownClasses.value.length,
|
||
)
|
||
|
||
/** 点一个班、或者表下面那行:去「统计」按人看(新标签,题目页不动) */
|
||
function openStatistics(className?: string) {
|
||
const href = router.resolve({
|
||
name: "statistics",
|
||
query: { problem: problem.value!._id, period: "all", ...(className ? { className } : {}) },
|
||
}).href
|
||
window.open(href, "_blank")
|
||
}
|
||
|
||
function segmentWidth(value: number, total: number) {
|
||
return total ? `${(value / total) * 100}%` : "0"
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<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-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
|
||
v-if="!contestStore.rankHidden"
|
||
:to="{ name: 'contest rank', params: { contestID: route.params.contestID } }"
|
||
class="link"
|
||
>
|
||
去看比赛排名 ›
|
||
</router-link>
|
||
</div>
|
||
|
||
<!-- ==================== 这道题(学生、老师都看) ==================== -->
|
||
<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.classes?.length" class="section">
|
||
<b>各班做得怎样</b>
|
||
<table class="classes">
|
||
<thead>
|
||
<tr>
|
||
<th>班级</th>
|
||
<th>做完</th>
|
||
<th>交了没对</th>
|
||
<th>没交</th>
|
||
<th>正确率</th>
|
||
<th>最近一次</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr
|
||
v-for="item in shownClasses"
|
||
:key="item.className ?? ''"
|
||
:class="{ pickable: item.className }"
|
||
:title="item.className ? '新标签看这个班的每个人' : undefined"
|
||
@click="item.className && openStatistics(item.className)"
|
||
>
|
||
<td>
|
||
<b>{{ item.className ? classLabel(item.className) : "没有班级" }}</b>
|
||
</td>
|
||
<td>
|
||
<span class="done-bar">
|
||
<span
|
||
:style="{
|
||
width: segmentWidth(item.solved, item.classSize),
|
||
background: theme.successColor,
|
||
}"
|
||
></span>
|
||
</span>
|
||
{{ item.solved }}/{{ item.classSize }}
|
||
</td>
|
||
<td :style="{ color: item.unsolved ? tone('warning').color : undefined }">
|
||
{{ item.unsolved }}
|
||
</td>
|
||
<td :style="{ color: item.untouched ? tone('error').color : undefined }">
|
||
{{ item.untouched }}
|
||
</td>
|
||
<td>
|
||
<n-text depth="2">{{
|
||
item.correctRate === null ? "—" : `${item.correctRate}%`
|
||
}}</n-text>
|
||
</td>
|
||
<td>
|
||
<n-text depth="2">{{ submissionDayText(item.lastTime) }}</n-text>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<div class="foot">
|
||
<a v-if="hiddenClasses" href="#" class="link small" @click.prevent="allClasses = true">
|
||
还有 {{ hiddenClasses }} 个班 ›
|
||
</a>
|
||
<span class="spacer"></span>
|
||
<a href="#" class="link small" @click.prevent="openStatistics()">
|
||
按人看谁没做对 <Icon icon="ph:arrow-square-out" :width="13" />
|
||
</a>
|
||
</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>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.meta-title {
|
||
margin-right: 4px;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.foot {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.classes {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.classes th {
|
||
white-space: nowrap;
|
||
text-align: left;
|
||
font-weight: normal;
|
||
color: v-bind("theme.textColor3");
|
||
padding: 4px 8px 6px 0;
|
||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||
}
|
||
|
||
.classes td {
|
||
padding: 8px 8px 8px 0;
|
||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.classes tbody tr.pickable {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.done-bar {
|
||
display: inline-flex;
|
||
width: 60px;
|
||
height: 6px;
|
||
margin-right: 6px;
|
||
border-radius: 3px;
|
||
overflow: hidden;
|
||
vertical-align: middle;
|
||
background: v-bind("theme.dividerColor");
|
||
}
|
||
|
||
.classes tbody tr.pickable:hover {
|
||
background: v-bind("theme.hoverColor");
|
||
}
|
||
</style>
|