feat(班级PK): 班级 PK 页重做:两个班看比分和每道题谁赢,三个班以上看题目对照格,学生也能比
Deploy / deploy (push) Canceled after 0s

- 两个班:大比分 + 一次就对副比分,每道题左右两根条、差得最多的在上,下面每周人均和全班分布
- 三个班以上:一行一道题、一格一个班,越深做对的人越多,皇冠是这道做得最好的班;右边谁领先的题多和赛跑
- 学生自己的班标「你们班」,输掉的题写「再 N 人做对就打平」;AI 分析只给老师,直接在页面里出
- 去掉综合分、雷达图、十张柱状图;手机版按稿改成上下排和页签
- 排名页、班级对抗、班级详情的入口对登录的人都开放,带上当前的班

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-10 09:06:21 +08:00
1 parent d83804b814
commit ce0093fc29
12 files changed
+2230 -873

No files matched your search

+6 -6
View File
@@ -14,9 +14,10 @@ import {
type ClassBoard, type ClassBoard,
type ClassLesson, type ClassLesson,
type KnowledgeMap, type KnowledgeMap,
type ClassComparisonResponse,
type ClassBattleItem, type ClassBattleItem,
type ClassDetail, type ClassDetail,
type ClassPk,
type ClassPkPeriod,
type RankBoard, type RankBoard,
type RankPeriod, type RankPeriod,
type RankScope, type RankScope,
@@ -304,11 +305,10 @@ export function getLastVisit() {
return api.get<LastVisit>("me/last-visit") return api.get<LastVisit>("me/last-visit")
} }
export function getClassPK(classNames: string[], startTime?: string, endTime?: string) { /** 班级 PK:不给班就用自己的班(老师用默认的班),只给一个班就配一个同年级的对手 */
return api.post<ClassComparisonResponse>("classes/comparison", { export function getClassPk(classes: string[], period: ClassPkPeriod) {
classNames, return api.get<ClassPk>("rankings/pk", {
...(startTime ? { startTime } : {}), params: { period, ...(classes.length ? { classes: classes.join(",") } : {}) },
...(endTime ? { endTime } : {}),
}) })
} }
+808
View File
@@ -0,0 +1,808 @@
<script setup lang="ts">
import type { ClassPk, ClassPkCell } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { classLabel } from "oj/submission/utils"
import { parseTime } from "utils/functions"
import { needToTie, pkColor, plateau, shortLabels, tint } from "../utils"
import PkHist from "./PkHist.vue"
/**
* 两个班对决(设计稿「班级 PK」B / 学生 · 两个班 / 手机 · 两个班):只比两个班都布置过的题,
* 谁做对的人多谁赢这道 → 大比分;副比分是一次就对。下面一道一行左右两根条,差得最多的在上;
* 再下面每周人均、全班分布。学生看自己班输掉的题,标「再 N 人做对就打平」。
*/
const props = defineProps<{ pk: ClassPk; compact: boolean }>()
const theme = useThemeVars()
const left = computed(() => props.pk.classes[0]!)
const right = computed(() => props.pk.classes[1]!)
const colors = [pkColor(0), pkColor(1)] as const
const shorts = computed(() => shortLabels(props.pk))
const mineIndex = computed(() =>
props.pk.classes.findIndex((item) => item.className === props.pk.mine),
)
function nameOf(index: number) {
return index === mineIndex.value ? "你们班" : shorts.value[index]!
}
type Side = "left" | "right" | "tie"
const rows = computed(() =>
props.pk.problems
.map((problem) => {
const [a, b] = problem.cells as [ClassPkCell, ClassPkCell]
const winner: Side = a.best && b.best ? "tie" : a.best ? "left" : "right"
const first: Side =
a.firstPercent === b.firstPercent
? "tie"
: a.firstPercent > b.firstPercent
? "left"
: "right"
let tie: number | null = null
const mine = mineIndex.value
if (mine >= 0 && winner !== "tie" && winner !== (mine === 0 ? "left" : "right")) {
const own = mine === 0 ? a : b
const other = mine === 0 ? b : a
tie = needToTie(own, props.pk.classes[mine]!.members, other.percent)
}
return { problem, a, b, winner, first, gap: Math.abs(a.percent - b.percent), tie }
})
.sort((x, y) => y.gap - x.gap || x.problem.problemId - y.problem.problemId),
)
const score = computed(() => {
const count = (key: "winner" | "first", side: Side) =>
rows.value.filter((row) => row[key] === side).length
return {
left: count("winner", "left"),
right: count("winner", "right"),
tie: count("winner", "tie"),
firstLeft: count("first", "left"),
firstRight: count("first", "right"),
}
})
const steadier = computed(() => {
const { firstLeft, firstRight } = score.value
if (firstLeft === firstRight) return "两个班一样稳"
return `${nameOf(firstLeft > firstRight ? 0 : 1)}更稳`
})
/** 只有一个班布置过的题:不比,列几道名字 */
const soloText = computed(() => {
const { solo } = props.pk
if (!solo.length) return ""
const names = solo.slice(0, 2).map((item) => `「${item.title}」`)
const owners = new Set(solo.map((item) => item.className))
const who =
owners.size === 1
? `只有${nameOf(props.pk.classes.findIndex((item) => item.className === solo[0]!.className))}做过的`
: "只有一个班做过的"
return `${who} ${solo.length} 道不比:${names.join("、")}${solo.length > 2 ? " 等" : ""}`
})
const SHOW_MOBILE = 7
const expanded = ref(false)
const shownRows = computed(() =>
props.compact && !expanded.value ? rows.value.slice(0, SHOW_MOBILE) : rows.value,
)
/** 每周人均:最近 8 周,两班并排的柱 */
const weeks = computed(() => {
const from = Math.max(0, props.pk.weeks.length - 8)
const list = props.pk.weeks.slice(from).map((week, k) => ({
label: parseTime(week, "M月D日"),
values: [left.value.weekly[from + k] ?? 0, right.value.weekly[from + k] ?? 0],
}))
const max = Math.max(1, ...list.flatMap((week) => week.values))
return { list, max }
})
const plateaus = computed(() => [
plateau(left.value.distribution),
plateau(right.value.distribution),
])
const tab = ref<"weekly" | "dist">("weekly")
</script>
<template>
<div class="duel" :class="{ compact }">
<div
class="score"
:style="{
background: `linear-gradient(90deg, ${tint(colors[0], 0.12)} 0%, transparent 38%, transparent 62%, ${tint(colors[1], 0.12)} 100%)`,
}"
>
<div
v-for="(item, index) in [left, right]"
:key="item.className"
class="side"
:class="{ end: index === 1 }"
>
<span class="side-name" :style="{ color: colors[index] }">
<template v-if="index === mineIndex">
你们班<small>{{ compact ? shorts[index] : classLabel(item.className) }}</small>
</template>
<template v-else>{{ compact ? shorts[index] : classLabel(item.className) }}</template>
</span>
<span class="side-sub">
{{ item.members }} 人 · 人均做对 <b>{{ item.perCapita }}</b>
<template v-if="!compact && item.firstPercent !== null">
· 一次就对 {{ item.firstPercent }}%</template
>
</span>
</div>
<div class="mid">
<span class="muted">两个班都做过的 {{ rows.length }} 道题 · 谁做对的人多谁赢这道</span>
<span class="big">
<b :style="{ color: colors[0] }">{{ score.left }}</b>
<i>:</i>
<b :style="{ color: colors[1] }">{{ score.right }}</b>
</span>
<span class="mid-sub">
平 {{ score.tie }} 道 · 比一次就对:
<b :style="{ color: colors[0] }">{{ score.firstLeft }}</b> :
<b :style="{ color: colors[1] }">{{ score.firstRight }}</b
>,{{ steadier }}
</span>
</div>
</div>
<div class="card">
<div class="card-head">
<b>同一批题</b>
<span class="muted">
条 = 全班做对的人占几成<template v-if="!compact">
· 皇冠 = 这道赢了 · 按两班差距从大到小</template
>
</span>
<div class="spacer" />
<span v-if="soloText && !compact" class="muted">{{ soloText }}</span>
</div>
<template v-if="!compact">
<div v-for="row in shownRows" :key="row.problem.problemId" class="row">
<span class="info left">
<span class="value" :class="{ win: row.winner === 'left' }">
<svg
v-if="row.winner === 'left'"
class="crown"
viewBox="0 0 24 24"
aria-label="这道赢了"
>
<path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" />
</svg>
{{ row.a.percent }}%
</span>
<span class="sub">
{{ row.a.solved }}/{{ left.members }} 人 · 一次就对 {{ row.a.firstPercent }}%
</span>
</span>
<span class="bar-box left">
<span
class="bar"
:style="{
width: `${row.a.percent}%`,
background: colors[0],
opacity: row.winner === 'right' ? 0.45 : 1,
}"
/>
</span>
<span class="title">
<router-link class="ell" :to="`/problem/${row.problem.displayId}`">{{
row.problem.title
}}</router-link>
<span class="tags">
<span class="muted">#{{ row.problem.displayId }}</span>
<span v-if="row.winner === 'tie'" class="muted">平</span>
<span v-else-if="row === rows[0] && row.gap > 0" class="most">差得最多</span>
<span v-if="row.tie !== null" class="tie" :class="{ close: row.tie <= 2 }">
再 {{ row.tie }} 人做对就打平
</span>
</span>
</span>
<span class="bar-box">
<span
class="bar"
:style="{
width: `${row.b.percent}%`,
background: colors[1],
opacity: row.winner === 'left' ? 0.45 : 1,
}"
/>
</span>
<span class="info">
<span class="value" :class="{ win: row.winner === 'right' }">
{{ row.b.percent }}%
<svg
v-if="row.winner === 'right'"
class="crown"
viewBox="0 0 24 24"
aria-label="这道赢了"
>
<path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" />
</svg>
</span>
<span class="sub">
{{ row.b.solved }}/{{ right.members }} 人 · 一次就对 {{ row.b.firstPercent }}%
</span>
</span>
</div>
</template>
<template v-else>
<div v-for="row in shownRows" :key="row.problem.problemId" class="m-row">
<div class="m-title">
<router-link class="ell" :to="`/problem/${row.problem.displayId}`">{{
row.problem.title
}}</router-link>
<span v-if="row.tie !== null" class="tie" :class="{ close: row.tie <= 2 }"
>再 {{ row.tie }} 人就平</span
>
<span v-else-if="row.winner === 'tie'" class="muted">平</span>
</div>
<div v-for="(cell, index) in [row.a, row.b]" :key="index" class="m-line">
<span class="m-name" :style="{ color: colors[index] }">{{ shorts[index] }}</span>
<span class="bar-box">
<span
class="bar"
:style="{
width: `${cell.percent}%`,
background: colors[index],
opacity:
row.winner !== 'tie' && row.winner !== (index === 0 ? 'left' : 'right')
? 0.45
: 1,
}"
/>
</span>
<span class="m-value" :class="{ win: cell.best && row.winner !== 'tie' }"
>{{ cell.percent }}%</span
>
<svg
v-if="cell.best && row.winner !== 'tie'"
class="crown"
viewBox="0 0 24 24"
aria-label="这道赢了"
>
<path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" />
</svg>
<span v-else class="crown" />
</div>
</div>
<button v-if="rows.length > SHOW_MOBILE" class="more" @click="expanded = !expanded">
{{ expanded ? "收起" : `再看 ${rows.length - SHOW_MOBILE} 道 ›` }}
</button>
</template>
</div>
<div class="bottom" :class="{ compact }">
<div v-if="compact" class="tabs" role="tablist">
<button :class="{ on: tab === 'weekly' }" role="tab" @click="tab = 'weekly'">
每周人均
</button>
<button :class="{ on: tab === 'dist' }" role="tab" @click="tab = 'dist'">全班分布</button>
</div>
<div v-if="!compact || tab === 'weekly'" class="card pad">
<div v-if="!compact" class="card-title">
<b>每周人均新做对</b><span class="muted">这学期,最近 {{ weeks.list.length }} 周</span>
</div>
<div class="weeks">
<div v-for="week in weeks.list" :key="week.label" class="week">
<div class="pair">
<div v-for="(value, index) in week.values" :key="index" class="bar-col">
<span class="v" :style="{ color: colors[index] }">{{ value }}</span>
<span
class="wbar"
:style="{
height: `${Math.max(2, (value / weeks.max) * (compact ? 80 : 110))}px`,
background: colors[index],
}"
/>
</div>
</div>
<span class="axis">{{ week.label }}</span>
</div>
</div>
</div>
<div v-if="!compact || tab === 'dist'" class="card pad dist">
<div v-if="!compact" class="card-title">
<b>全班分布</b><span class="muted">一根柱一档,横轴是做对几道 · 虚线是中间那位</span>
</div>
<div v-for="(item, index) in [left, right]" :key="item.className" class="dist-one">
<span class="dist-name" :style="{ color: colors[index] }">
{{ index === mineIndex ? "你们班" : classLabel(item.className) }} · 中间那位
{{ item.median }} 道
<template v-if="plateaus[index]">
· {{ plateaus[index]!.count }} 个人停在 {{ plateaus[index]!.solved }} 道</template
>
</span>
<PkHist :values="item.distribution" :median="item.median" :color="colors[index]" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.duel {
display: flex;
flex-direction: column;
gap: 14px;
}
.score {
padding: 18px 28px;
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 24px;
}
.side {
display: flex;
flex-direction: column;
gap: 4px;
grid-row: 1;
grid-column: 1;
}
.side.end {
grid-column: 3;
align-items: flex-end;
text-align: right;
}
.side-name {
font-size: 22px;
font-weight: 800;
display: flex;
align-items: baseline;
gap: 8px;
}
.side-name small {
font-size: 14px;
font-weight: 600;
color: v-bind("theme.textColor3");
}
.side-sub {
font-size: 13px;
color: v-bind("theme.textColor2");
}
.side-sub b {
font-size: 15px;
font-variant-numeric: tabular-nums;
}
.mid {
grid-row: 1;
grid-column: 2;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
text-align: center;
}
.big {
display: flex;
align-items: center;
gap: 18px;
line-height: 1.05;
}
.big b {
font-size: 52px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.big i {
font-style: normal;
font-size: 28px;
font-weight: 700;
color: v-bind("theme.textColor3");
}
.mid-sub {
font-size: 13px;
color: v-bind("theme.textColor2");
}
.muted {
font-size: 12px;
font-weight: 400;
color: v-bind("theme.textColor3");
}
.card {
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
padding: 12px 16px 4px;
min-width: 0;
}
.card.pad {
padding: 12px 16px;
}
.card-head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 8px;
padding-bottom: 6px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.card-title {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 8px;
}
.spacer {
flex-grow: 1;
}
.row {
min-height: 44px;
display: grid;
grid-template-columns: 168px minmax(0, 1fr) 210px minmax(0, 1fr) 168px;
align-items: center;
gap: 12px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.row:last-child {
border-bottom: 0;
}
.info {
display: flex;
flex-direction: column;
}
.info.left {
align-items: flex-end;
}
.value {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: v-bind("theme.textColor3");
}
.value.win {
color: v-bind("theme.textColor1");
}
.sub {
font-size: 11px;
color: v-bind("theme.textColor3");
font-variant-numeric: tabular-nums;
}
.crown {
width: 14px;
height: 14px;
flex-shrink: 0;
fill: #c58b00;
stroke: #c58b00;
stroke-width: 1.5;
stroke-linejoin: round;
}
.bar-box {
height: 16px;
display: flex;
background: v-bind("theme.actionColor");
border-radius: 3px;
overflow: hidden;
}
.bar-box.left {
justify-content: flex-end;
}
.bar {
height: 100%;
}
.title {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
min-width: 0;
}
.title a,
.m-title a {
max-width: 100%;
font-size: 13px;
font-weight: 600;
color: v-bind("theme.textColor1");
text-decoration: none;
}
.title a:hover,
.m-title a:hover {
color: v-bind("theme.primaryColor");
}
.ell {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tags {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
}
.tags .muted {
font-size: 11px;
}
.most {
font-size: 11px;
font-weight: 600;
color: #b54708;
}
.tie {
font-size: 11px;
font-weight: 600;
white-space: nowrap;
color: v-bind("theme.textColor3");
}
.tie.close {
color: #18a058;
background: rgba(24, 160, 88, 0.12);
border-radius: 3px;
padding: 0 5px;
}
.bottom {
display: flex;
gap: 16px;
}
.bottom > .card {
flex: 1;
}
.weeks {
display: flex;
gap: 4px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.week {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.pair {
height: 130px;
display: flex;
align-items: flex-end;
gap: 4px;
}
.bar-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.v {
font-size: 11px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.wbar {
width: 18px;
border-radius: 3px 3px 0 0;
}
.axis {
font-size: 11px;
color: v-bind("theme.textColor3");
white-space: nowrap;
}
.dist {
display: flex;
flex-direction: column;
gap: 10px;
}
.dist-one {
display: flex;
flex-direction: column;
gap: 4px;
}
.dist-name {
font-size: 12px;
font-weight: 600;
}
/* ---------- 手机 ---------- */
.compact .score {
padding: 12px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 6px;
}
.compact .side {
grid-row: 1;
}
.compact .side.end {
grid-column: 2;
}
.compact .side-name {
font-size: 15px;
}
.compact .side-sub {
font-size: 12px;
}
.compact .mid {
grid-row: 2;
grid-column: 1 / 3;
}
.compact .big b {
font-size: 44px;
}
.compact .mid-sub {
font-size: 12px;
}
.compact .card {
padding: 10px 12px 2px;
}
.m-row {
padding: 8px 0;
border-bottom: 1px solid v-bind("theme.dividerColor");
display: flex;
flex-direction: column;
gap: 4px;
}
.m-title {
display: flex;
align-items: baseline;
gap: 6px;
}
.m-title a {
flex: 1;
min-width: 0;
}
.m-line {
display: flex;
align-items: center;
gap: 6px;
}
.m-line .bar-box {
flex: 1;
height: 12px;
}
.m-name {
width: 36px;
font-size: 12px;
font-weight: 600;
}
.m-value {
width: 40px;
text-align: right;
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.m-value.win {
font-weight: 700;
}
.m-line .crown {
width: 14px;
height: 14px;
}
.more {
width: 100%;
height: 40px;
border: 0;
background: none;
font: inherit;
font-size: 13px;
color: v-bind("theme.primaryColor");
cursor: pointer;
}
.bottom.compact {
flex-direction: column;
gap: 0;
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
padding: 0 12px 12px;
}
.bottom.compact > .card {
border: 0;
padding: 10px 0 0;
}
.tabs {
display: flex;
gap: 18px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.tabs button {
height: 34px;
padding: 0;
border: 0;
background: none;
font: inherit;
font-size: 14px;
color: v-bind("theme.textColor2");
cursor: pointer;
}
.tabs button.on {
color: #18a058;
font-weight: 600;
box-shadow: inset 0 -2px 0 #18a058;
}
.compact .pair {
height: 100px;
gap: 2px;
}
.compact .wbar {
width: 12px;
}
.compact .axis {
font-size: 10px;
}
</style>
+625
View File
@@ -0,0 +1,625 @@
<script setup lang="ts">
import type { ClassPk } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { classLabel } from "oj/submission/utils"
import { useRankPalette } from "oj/rank/palette"
import { needToTie, pkColor, shortLabels, tint } from "../utils"
import PkRace from "./PkRace.vue"
/**
* 三个班以上(设计稿「班级 PK」C / 学生 · 三个班 / 手机 · 三个班以上):题目对照格,一行
* 一道题、一格一个班,越深做对的人越多,皇冠 = 这道做得最好(一样多都算)。右边谁领先的题多、
* 一句话(学生:离领先最近的那道;老师:差得最多的那道)、赛跑。
*/
const props = defineProps<{ pk: ClassPk; compact: boolean }>()
const theme = useThemeVars()
const palette = useRankPalette()
const shorts = computed(() => shortLabels(props.pk))
const mineIndex = computed(() =>
props.pk.classes.findIndex((item) => item.className === props.pk.mine),
)
const rows = computed(() =>
props.pk.problems.map((problem) => {
const top = Math.max(...problem.cells.map((cell) => cell?.percent ?? -1))
const winner = problem.cells.findIndex((cell) => cell?.best)
const own = mineIndex.value >= 0 ? problem.cells[mineIndex.value] : null
const tie =
own && !own.best ? needToTie(own, props.pk.classes[mineIndex.value]!.members, top) : null
return {
problem,
winner,
tie,
cells: problem.cells.map((cell, index) => {
if (!cell) return null
// 做对比例 45% 以下最浅,100% 最深;深到压白字时字换白
const alpha = Math.max(0.1, Math.min(0.85, ((cell.percent - 45) / 55) * 0.85))
return {
...cell,
background: tint(pkColor(index), alpha),
dark: alpha > 0.5,
hint: `${props.pk.classes[index]!.className.slice(2)}班:做对 ${cell.solved}/${props.pk.classes[index]!.members} 人、交了 ${cell.submissions} 次、一次就对 ${cell.firstPercent}%`,
}
}),
}
}),
)
const ranking = computed(() =>
props.pk.classes
.map((item, index) => ({ item, index }))
.sort((a, b) => b.item.lead - a.item.lead || b.item.perCapita - a.item.perCapita),
)
/** 右边那一句:学生看离领先最近的那道,老师看差得最多的那道 */
const insight = computed(() => {
if (mineIndex.value >= 0) {
const close = rows.value.filter((row) => row.tie !== null).sort((a, b) => a.tie! - b.tie!)[0]
if (!close) return { mine: true, text: "同一批题你们班都做得最好" }
return {
mine: true,
text: `离领先最近:「${close.problem.title}」再 ${close.tie} 人做对就和${shorts.value[close.winner]}打平`,
}
}
let worst: { title: string; low: number; lowIndex: number; others: number } | null = null
for (const row of rows.value) {
const present = row.cells
.map((cell, index) => (cell ? { percent: cell.percent, index } : null))
.filter((cell): cell is { percent: number; index: number } => !!cell)
.sort((a, b) => a.percent - b.percent)
const gap = present[1]!.percent - present[0]!.percent
if (!worst || gap > worst.others - worst.low)
worst = {
title: row.problem.title,
low: present[0]!.percent,
lowIndex: present[0]!.index,
others: present[1]!.percent,
}
}
if (!worst || worst.others === worst.low) return null
return {
mine: false,
text: `差得最多的是「${worst.title}」:${shorts.value[worst.lowIndex]}只有 ${worst.low}% 做对,别的班都在 ${worst.others}% 以上`,
}
})
const SHOW_MOBILE = 8
const expanded = ref(false)
const shownRows = computed(() =>
props.compact && !expanded.value ? rows.value.slice(0, SHOW_MOBILE) : rows.value,
)
const soloCount = computed(() => props.pk.solo.length)
const tab = ref<"race" | "lead">("race")
const medal = (index: number) => palette.value.medal[index]
</script>
<template>
<div class="grid-pk" :class="{ compact }">
<div class="card table">
<div class="card-title">
<b>同一批题 · 一格一个班</b>
<span class="muted">
格子里是全班做对的人占几成,越深越多<template v-if="!compact">
· 皇冠 = 这道题做得最好的班(一样多就都算)</template
>
</span>
</div>
<div class="line heads">
<span v-if="!compact" class="name muted">至少两个班布置过的 {{ rows.length }} 道</span>
<span
v-for="(item, index) in pk.classes"
:key="item.className"
class="head"
:class="{ mine: index === mineIndex }"
:style="{ background: tint(pkColor(index), 0.1) }"
>
<span class="head-name" :style="{ color: pkColor(index) }">
{{ compact ? shorts[index] : classLabel(item.className) }}
<span v-if="index === mineIndex" class="mine-tag">{{
compact ? "你们" : "你们班"
}}</span>
</span>
<span class="head-lead">
<span v-if="!compact" class="muted">领先</span>
<b :style="{ color: pkColor(index) }">{{ item.lead }}</b>
<span class="muted">{{ compact ? "道领先" : "道" }}</span>
</span>
<span v-if="!compact" class="muted per">人均做对 {{ item.perCapita }}</span>
</span>
</div>
<div
v-for="row in shownRows"
:key="row.problem.problemId"
class="line"
:class="{ stacked: compact }"
>
<span class="name">
<span v-if="!compact" class="muted id">#{{ row.problem.displayId }}</span>
<router-link class="ell" :to="`/problem/${row.problem.displayId}`">{{
row.problem.title
}}</router-link>
<span v-if="row.tie !== null" class="tie" :class="{ close: row.tie <= 2 }"
>再 {{ row.tie }} 人就平</span
>
</span>
<div class="cells">
<template v-for="(cell, index) in row.cells" :key="index">
<span v-if="!cell" class="cell none">{{ compact ? "—" : "没布置" }}</span>
<span
v-else
class="cell"
:class="{ best: cell.best, dark: cell.dark, mine: index === mineIndex }"
:style="{ background: cell.background }"
:title="cell.hint"
>
<svg v-if="cell.best" class="crown" viewBox="0 0 24 24" aria-label="这道做得最好">
<path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" />
</svg>
<b>{{ cell.percent }}{{ compact ? "" : "%" }}</b>
<span v-if="!compact" class="first">一次 {{ cell.firstPercent }}%</span>
</span>
</template>
</div>
</div>
<button
v-if="compact && rows.length > SHOW_MOBILE"
class="more"
@click="expanded = !expanded"
>
{{ expanded ? "收起" : `再看 ${rows.length - SHOW_MOBILE} 道 ›` }}
</button>
<span v-if="!compact && soloCount" class="muted foot">
只有一个班做过的 {{ soloCount }} 道不在格子里 · 鼠标放到格子上看做对几个人、交了几次
</span>
</div>
<div class="side" :class="{ compact }">
<div v-if="compact" class="tabs" role="tablist">
<button :class="{ on: tab === 'race' }" role="tab" @click="tab = 'race'">赛跑</button>
<button :class="{ on: tab === 'lead' }" role="tab" @click="tab = 'lead'">
谁领先的题多
</button>
</div>
<div v-if="!compact || tab === 'lead'" class="card pad">
<div v-if="!compact" class="card-title">
<b>谁领先的题多</b><span class="muted">同一批题里</span>
</div>
<div
v-for="({ item, index }, place) in ranking"
:key="item.className"
class="lead-row"
:class="{ mine: index === mineIndex }"
>
<span
class="medal"
:style="
medal(place)
? { color: medal(place)!.color, background: medal(place)!.background }
: undefined
"
>{{ place + 1 }}</span
>
<span class="lead-name" :style="{ color: pkColor(index) }">
{{ classLabel(item.className) }}
<span v-if="index === mineIndex" class="mine-tag">你们班</span>
</span>
<span
><b class="num">{{ item.lead }}</b
><span class="muted"> 道领先</span></span
>
</div>
<div v-if="insight" class="insight" :class="{ mine: insight.mine }">{{ insight.text }}</div>
</div>
<div v-if="!compact || tab === 'race'" class="card pad">
<div v-if="!compact" class="card-title">
<b>赛跑</b><span class="muted">这学期累计人均做对</span>
</div>
<PkRace :pk="pk" :width="420" :height="compact ? 220 : 230" />
</div>
</div>
</div>
</template>
<style scoped>
.grid-pk {
display: flex;
gap: 16px;
align-items: flex-start;
}
.grid-pk.compact {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.card {
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
min-width: 0;
}
.table {
flex: 1.75;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 6px;
}
.pad {
padding: 12px 16px;
}
.card-title {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 8px;
}
.muted {
font-size: 12px;
font-weight: 400;
color: v-bind("theme.textColor3");
}
.line {
display: flex;
align-items: center;
gap: 6px;
min-height: 40px;
}
.line.heads {
align-items: stretch;
}
.name {
width: 290px;
flex-shrink: 0;
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.heads .name {
align-items: flex-end;
padding-bottom: 6px;
}
.name a {
font-size: 13px;
font-weight: 600;
color: v-bind("theme.textColor1");
text-decoration: none;
min-width: 0;
}
.name a:hover {
color: v-bind("theme.primaryColor");
}
.id {
flex-shrink: 0;
font-size: 11px;
}
.ell {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.head {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 0;
border-radius: 6px;
}
.head.mine {
box-shadow: inset 0 0 0 2px #9fd8b8;
}
.head-name {
display: flex;
align-items: center;
gap: 6px;
font-weight: 700;
}
.head-lead {
display: flex;
align-items: baseline;
gap: 4px;
white-space: nowrap;
}
.per {
font-size: 11px;
white-space: nowrap;
}
.head-lead b {
font-size: 22px;
font-variant-numeric: tabular-nums;
}
.mine-tag {
font-size: 11px;
background: rgba(24, 160, 88, 0.12);
color: #18a058;
border-radius: 3px;
padding: 0 5px;
height: 16px;
display: inline-flex;
align-items: center;
font-weight: 600;
white-space: nowrap;
}
.cells {
flex: 1;
min-width: 0;
display: flex;
gap: 6px;
}
.heads .head,
.cells .cell {
flex: 1;
}
.cell {
min-width: 0;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: 4px;
color: v-bind("theme.textColor1");
font-variant-numeric: tabular-nums;
}
.cell.dark {
color: #ffffff;
}
.cell.best {
box-shadow: inset 0 0 0 2px #c58b00;
}
.cell b {
font-size: 14px;
}
.first {
font-size: 11px;
opacity: 0.85;
}
.cell.none {
font-size: 12px;
color: v-bind("theme.textColorDisabled");
background: repeating-linear-gradient(
135deg,
v-bind("theme.actionColor") 0 6px,
v-bind("theme.hoverColor") 6px 12px
);
}
.crown {
width: 12px;
height: 12px;
flex-shrink: 0;
fill: #c58b00;
stroke: #c58b00;
stroke-width: 1.5;
stroke-linejoin: round;
}
.cell.dark .crown {
fill: #ffffff;
stroke: #ffffff;
}
.tie {
flex-shrink: 0;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
color: v-bind("theme.textColor3");
}
.tie.close {
color: #18a058;
background: rgba(24, 160, 88, 0.12);
border-radius: 3px;
padding: 0 5px;
}
.foot {
padding-top: 4px;
}
.side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.lead-row {
height: 34px;
display: flex;
align-items: center;
gap: 10px;
}
.lead-row.mine {
background: rgba(24, 160, 88, 0.08);
border-radius: 6px;
padding: 0 6px;
margin: 0 -6px;
}
.medal {
width: 26px;
height: 26px;
border-radius: 13px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 13px;
flex-shrink: 0;
color: v-bind("theme.textColor2");
}
.lead-name {
flex: 1;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
}
.num {
font-size: 16px;
font-variant-numeric: tabular-nums;
}
.insight {
margin-top: 8px;
font-size: 13px;
color: v-bind("theme.textColor2");
padding: 8px 10px;
background: v-bind("theme.actionColor");
border-radius: 6px;
}
.insight.mine {
background: transparent;
border: 1px dashed #9fd8b8;
color: #18a058;
font-weight: 600;
}
.more {
width: 100%;
height: 40px;
border: 0;
background: none;
font: inherit;
font-size: 13px;
color: v-bind("theme.primaryColor");
cursor: pointer;
}
/* ---------- 手机 ---------- */
.compact .table {
padding: 10px 12px 2px;
}
.compact .line.heads {
min-height: 0;
padding-top: 4px;
}
.compact .head {
padding: 6px 0;
}
.compact .head-name {
font-size: 12px;
flex-direction: column;
gap: 0;
}
.compact .per {
font-size: 11px;
white-space: nowrap;
}
.head-lead b {
font-size: 20px;
}
.line.stacked {
flex-direction: column;
align-items: stretch;
gap: 4px;
padding: 6px 0;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.line.stacked .name {
width: auto;
}
.compact .cells {
gap: 4px;
}
.compact .cell {
height: 28px;
gap: 2px;
font-size: 12px;
}
.compact .cell b {
font-size: 12px;
}
.compact .crown {
width: 10px;
height: 10px;
}
.side.compact {
gap: 0;
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
padding: 0 12px 12px;
}
.side.compact .card {
border: 0;
padding: 10px 0 0;
}
.tabs {
display: flex;
gap: 18px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.tabs button {
height: 34px;
padding: 0;
border: 0;
background: none;
font: inherit;
font-size: 14px;
color: v-bind("theme.textColor2");
cursor: pointer;
}
.tabs button.on {
color: #18a058;
font-weight: 600;
box-shadow: inset 0 -2px 0 #18a058;
}
</style>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
/**
* 全班分布:横轴做对几道(0–30,再多的堆在最右一档),一根柱一档,虚线是中间那位同学。
* 人少的档(不到 3 个)淡一点,一眼看到大部分人停在哪。
*/
const props = withDefaults(
defineProps<{ values: number[]; median: number; color: string; height?: number }>(),
{ height: 44 },
)
const theme = useThemeVars()
const CAP = 30
const WIDTH = 560
const bars = computed(() => {
const counts = new Map<number, number>()
for (const value of props.values) {
const key = Math.min(value, CAP)
counts.set(key, (counts.get(key) ?? 0) + 1)
}
const tallest = Math.max(16, ...counts.values())
const width = WIDTH / (CAP + 1)
return {
width,
list: [...counts].map(([value, n]) => {
const h = Math.max(2, (props.height * n) / tallest)
return { value, n, x: value * width + 0.5, y: props.height - h, h }
}),
median: (Math.min(props.median, CAP) + 0.5) * width,
}
})
</script>
<template>
<svg
class="hist"
:viewBox="`0 0 ${WIDTH} ${height + 12}`"
role="img"
aria-label="全班做对几道的分布"
>
<line :x1="0" :x2="WIDTH" :y1="height + 0.5" :y2="height + 0.5" :stroke="theme.dividerColor" />
<rect
v-for="bar in bars.list"
:key="bar.value"
:x="bar.x"
:y="bar.y"
:width="bars.width - 1"
:height="bar.h"
rx="1"
:fill="color"
:opacity="bar.n >= 3 ? 0.9 : 0.55"
>
<title>做对 {{ bar.value }}{{ bar.value === CAP ? " 道以上" : " 道" }}:{{ bar.n }} 人</title>
</rect>
<line
:x1="bars.median"
:x2="bars.median"
y1="0"
:y2="height"
:stroke="theme.textColor2"
stroke-dasharray="2 2"
/>
<text x="0" :y="height + 11" font-size="9" :fill="theme.textColor3">0</text>
<text
:x="15 * bars.width"
:y="height + 11"
font-size="9"
:fill="theme.textColor3"
text-anchor="middle"
>
15
</text>
<text :x="WIDTH" :y="height + 11" font-size="9" :fill="theme.textColor3" text-anchor="end">
30+
</text>
</svg>
</template>
<style scoped>
.hist {
display: block;
width: 100%;
height: auto;
}
</style>
+135
View File
@@ -0,0 +1,135 @@
<script setup lang="ts">
import type { ClassPk } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { parseTime } from "utils/functions"
import { pkColor, shortLabels } from "../utils"
/**
* 赛跑:每个班这学期累计人均做对,一周一个点,线尾写班名和到现在的人均。
* 按 viewBox 等比缩放,放在多宽的格子里都行。
*/
const props = withDefaults(defineProps<{ pk: ClassPk; width?: number; height?: number }>(), {
width: 700,
height: 280,
})
const theme = useThemeVars()
const PAD = { left: 34, right: 92, top: 10, bottom: 26 }
const series = computed(() =>
props.pk.classes.map((item) => {
let sum = 0
return item.weekly.map((value) => Math.round((sum += value) * 10) / 10)
}),
)
const chart = computed(() => {
const inner = {
w: props.width - PAD.left - PAD.right,
h: props.height - PAD.top - PAD.bottom,
}
const top = Math.max(1, ...series.value.flat())
const step = top > 30 ? 10 : top > 12 ? 6 : top > 5 ? 2 : 1
const max = Math.ceil(top / step) * step
const count = props.pk.weeks.length
const x = (index: number) =>
PAD.left + (count > 1 ? (inner.w * index) / (count - 1) : inner.w / 2)
const y = (value: number) => PAD.top + inner.h * (1 - value / max)
const ticks = Array.from({ length: max / step + 1 }, (_, k) => ({
value: k * step,
y: y(k * step),
}))
// 周多了横轴字会挤:最多标 8 个,最后一周一定标
const every = Math.max(1, Math.ceil(count / 8))
const labels = props.pk.weeks
.map((week, index) => ({ index, x: x(index), text: parseTime(week, "M月D日") }))
.filter(({ index }) => index === count - 1 || (count - 1 - index) % every === 0)
const labelsShort = shortLabels(props.pk)
const lines = series.value.map((points, index) => ({
color: pkColor(index),
path: points.map((value, k) => `${x(k).toFixed(1)},${y(value).toFixed(1)}`).join(" "),
dots: points.map((value, k) => ({ cx: x(k), cy: y(value) })),
end: { y: y(points.at(-1) ?? 0), text: `${labelsShort[index]} ${points.at(-1) ?? 0}` },
}))
// 线尾的字别叠在一起:按高低排,挨得太近的往下推
const ends = lines.map((line, index) => ({ index, y: line.end.y })).sort((a, b) => a.y - b.y)
for (let k = 1; k < ends.length; k++)
if (ends[k]!.y - ends[k - 1]!.y < 14) ends[k]!.y = ends[k - 1]!.y + 14
for (const end of ends) lines[end.index]!.end.y = end.y
return { ticks, labels, lines, endX: x(count - 1) + 10, right: PAD.left + inner.w }
})
</script>
<template>
<svg
class="race"
:viewBox="`0 0 ${width} ${height}`"
role="img"
aria-label="各班这学期累计人均做对,一周一个点"
>
<g v-for="tick in chart.ticks" :key="tick.value">
<line
:x1="PAD.left"
:x2="chart.right"
:y1="tick.y"
:y2="tick.y"
:stroke="theme.dividerColor"
/>
<text
:x="PAD.left - 8"
:y="tick.y + 4"
font-size="11"
:fill="theme.textColor3"
text-anchor="end"
>
{{ tick.value }}
</text>
</g>
<text
v-for="label in chart.labels"
:key="label.index"
:x="label.x"
:y="height - 6"
font-size="11"
:fill="theme.textColor3"
text-anchor="middle"
>
{{ label.text }}
</text>
<g v-for="(line, index) in chart.lines" :key="index">
<polyline
:points="line.path"
fill="none"
:stroke="line.color"
stroke-width="2.5"
stroke-linejoin="round"
stroke-linecap="round"
/>
<circle
v-for="(dot, k) in line.dots"
:key="k"
:cx="dot.cx"
:cy="dot.cy"
r="3.2"
:fill="theme.cardColor"
:stroke="line.color"
stroke-width="2"
/>
<text :x="chart.endX" :y="line.end.y + 4" font-size="12" font-weight="700" :fill="line.color">
{{ line.end.text }}
</text>
</g>
</svg>
</template>
<style scoped>
.race {
display: block;
width: 100%;
height: auto;
}
</style>
File diff suppressed because it is too large. Load diff
+59
View File
@@ -0,0 +1,59 @@
import type { ClassPk, ClassPkCell } from "@oj2/contract"
import { classLabel } from "oj/submission/utils"
/**
* 每个班一种颜色,按选班的顺序给。蓝 / 橙打头:两个班对决时最常见,这两个色盲也分得开;
* 后面几个亮度错开,对照格的深浅才看得出来。
*/
const COLORS = [
"#2f6fd0",
"#e0702f",
"#18a058",
"#8e5bd0",
"#b8901a",
"#d0457a",
"#0f8f9f",
"#5f6b7a",
]
export function pkColor(index: number) {
return COLORS[index % COLORS.length]!
}
/** 颜色加透明度:对照格按做对比例取深浅,比分卡两头的底色也用它(暗色下一样能用) */
export function tint(hex: string, alpha: number) {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
/** 同一个年级的几个班只说「3班」,跨年级说全名 */
export function shortLabels(pk: ClassPk) {
const grades = new Set(pk.classes.map((item) => item.className.slice(0, 2)))
return pk.classes.map((item) =>
grades.size === 1 ? `${item.className.slice(2)}班` : classLabel(item.className),
)
}
/**
* 你们班再有几个人做对,这道就能和 `target` 打平。比的是显示出来的整数百分比
* (后端 `percent` 就是这么比输赢的),所以按同样的取整往上加。
*/
export function needToTie(cell: ClassPkCell, members: number, target: number) {
let more = 0
while (
Math.round(((cell.solved + more) / members) * 100) < target &&
cell.solved + more < members
)
more++
return more
}
/** 一档同分人最多、且至少 5 个人,才说「N 个人停在 x 道」 */
export function plateau(values: number[]) {
const counts = new Map<number, number>()
for (const value of values) if (value > 0) counts.set(value, (counts.get(value) ?? 0) + 1)
const [top] = [...counts].filter(([, n]) => n >= 5).sort((a, b) => b[1] - a[1] || b[0] - a[0])
return top ? { solved: top[0], count: top[1] } : null
}
@@ -14,7 +14,7 @@ const props = defineProps<{
teacher?: boolean teacher?: boolean
/** 放在手机页签卡里:不要自己的边框和标题 */ /** 放在手机页签卡里:不要自己的边框和标题 */
bare?: boolean bare?: boolean
/** 显示「班级 PK」入口(原来排名页上那个按钮,管理员角色才有) */ /** 显示「班级 PK」入口(登录了就有:学生也能比) */
pk?: boolean pk?: boolean
}>() }>()
const emit = defineEmits<{ pick: [className: string] }>() const emit = defineEmits<{ pick: [className: string] }>()
@@ -56,7 +56,12 @@ function shortLabel(className: string) {
<div class="battle" :class="{ bare }"> <div class="battle" :class="{ bare }">
<div class="title"> <div class="title">
<b v-if="!bare">班级对抗</b><span class="muted">人均做对 · 全服 · 点班名看详情</span> <b v-if="!bare">班级对抗</b><span class="muted">人均做对 · 全服 · 点班名看详情</span>
<router-link v-if="pk" to="/class" class="pk">班级 PK ›</router-link> <router-link
v-if="pk"
:to="{ path: '/class', query: mine ? { classes: mine } : {} }"
class="pk"
>班级 PK ›</router-link
>
</div> </div>
<div <div
v-for="item in shown" v-for="item in shown"
@@ -245,7 +245,7 @@ function go(path: string, query: Record<string, string>) {
<MdPreview v-if="report" class="report" :model-value="report" /> <MdPreview v-if="report" class="report" :model-value="report" />
</section> </section>
<a v-if="pk" href="#" class="pk" @click.prevent="go('/class', {})" <a v-if="pk" href="#" class="pk" @click.prevent="go('/class', { classes: className! })"
>和别的班比 → 班级 PK ›</a >和别的班比 → 班级 PK ›</a
> >
</div> </div>
+14 -4
View File
@@ -480,7 +480,17 @@ async function restore(userId: number) {
> >
班级详情 · AI 分析 班级详情 · AI 分析
</n-button> </n-button>
<n-button size="small" secondary @click="router.push('/class')">班级 PK</n-button> <n-button
size="small"
secondary
@click="
router.push({
path: '/class',
query: classContext ? { classes: classContext } : {},
})
"
>班级 PK</n-button
>
</div> </div>
<div class="hidden-list"> <div class="hidden-list">
<span class="hidden-title">不计入排名的人</span> <span class="hidden-title">不计入排名的人</span>
@@ -506,7 +516,7 @@ async function restore(userId: number) {
:items="battle" :items="battle"
:mine="classContext" :mine="classContext"
:teacher="teacher" :teacher="teacher"
:pk="userStore.isAdminRole" :pk="userStore.isAuthed"
@pick="openDetail" @pick="openDetail"
/> />
</aside> </aside>
@@ -560,7 +570,7 @@ async function restore(userId: number) {
:items="battle" :items="battle"
:mine="classContext" :mine="classContext"
:teacher="teacher" :teacher="teacher"
:pk="userStore.isAdminRole" :pk="userStore.isAuthed"
@pick="openDetail" @pick="openDetail"
/> />
<RankTrend <RankTrend
@@ -586,7 +596,7 @@ async function restore(userId: number) {
v-model:show="showDetail" v-model:show="showDetail"
:class-name="detailClass" :class-name="detailClass"
:teacher="teacher" :teacher="teacher"
:pk="userStore.isAdminRole" :pk="userStore.isAuthed"
/> />
</div> </div>
</template> </template>
+1
View File
@@ -93,6 +93,7 @@ export const ojs: RouteRecordRaw = {
path: "class", path: "class",
name: "class", name: "class",
component: () => import("oj/class/pk.vue"), component: () => import("oj/class/pk.vue"),
meta: { requiresAuth: true },
}, },
{ {
path: "announcement", path: "announcement",
-2
View File
@@ -275,8 +275,6 @@ export type Rank = RankProfile
/** 本周进步榜:`rank` 是周榜名次,跟存量总榜的名次没有关系 */ /** 本周进步榜:`rank` 是周榜名次,跟存量总榜的名次没有关系 */
export type { WeeklyRank, WeeklyRankItem } from "@oj2/contract" export type { WeeklyRank, WeeklyRankItem } from "@oj2/contract"
export type { ClassComparison } from "@oj2/contract"
/** 班里最近一次一起做的题(课上老师点的那几道),见后端 `/me/class-activity` */ /** 班里最近一次一起做的题(课上老师点的那几道),见后端 `/me/class-activity` */
export type { ClassActivity, ClassActivityProblem, LastVisit } from "@oj2/contract" export type { ClassActivity, ClassActivityProblem, LastVisit } from "@oj2/contract"