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 ClassLesson,
type KnowledgeMap,
type ClassComparisonResponse,
type ClassBattleItem,
type ClassDetail,
type ClassPk,
type ClassPkPeriod,
type RankBoard,
type RankPeriod,
type RankScope,
@@ -304,11 +305,10 @@ export function getLastVisit() {
return api.get<LastVisit>("me/last-visit")
}
export function getClassPK(classNames: string[], startTime?: string, endTime?: string) {
return api.post<ClassComparisonResponse>("classes/comparison", {
classNames,
...(startTime ? { startTime } : {}),
...(endTime ? { endTime } : {}),
/** 班级 PK:不给班就用自己的班(老师用默认的班),只给一个班就配一个同年级的对手 */
export function getClassPk(classes: string[], period: ClassPkPeriod) {
return api.get<ClassPk>("rankings/pk", {
params: { period, ...(classes.length ? { classes: classes.join(",") } : {}) },
})
}
+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
/** 放在手机页签卡里:不要自己的边框和标题 */
bare?: boolean
/** 显示「班级 PK」入口(原来排名页上那个按钮,管理员角色才有) */
/** 显示「班级 PK」入口(登录了就有:学生也能比) */
pk?: boolean
}>()
const emit = defineEmits<{ pick: [className: string] }>()
@@ -56,7 +56,12 @@ function shortLabel(className: string) {
<div class="battle" :class="{ bare }">
<div class="title">
<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
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" />
</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
>
</div>
+14 -4
View File
@@ -480,7 +480,17 @@ async function restore(userId: number) {
>
班级详情 · AI 分析
</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 class="hidden-list">
<span class="hidden-title">不计入排名的人</span>
@@ -506,7 +516,7 @@ async function restore(userId: number) {
:items="battle"
:mine="classContext"
:teacher="teacher"
:pk="userStore.isAdminRole"
:pk="userStore.isAuthed"
@pick="openDetail"
/>
</aside>
@@ -560,7 +570,7 @@ async function restore(userId: number) {
:items="battle"
:mine="classContext"
:teacher="teacher"
:pk="userStore.isAdminRole"
:pk="userStore.isAuthed"
@pick="openDetail"
/>
<RankTrend
@@ -586,7 +596,7 @@ async function restore(userId: number) {
v-model:show="showDetail"
:class-name="detailClass"
:teacher="teacher"
:pk="userStore.isAdminRole"
:pk="userStore.isAuthed"
/>
</div>
</template>
+1
View File
@@ -93,6 +93,7 @@ export const ojs: RouteRecordRaw = {
path: "class",
name: "class",
component: () => import("oj/class/pk.vue"),
meta: { requiresAuth: true },
},
{
path: "announcement",
-2
View File
@@ -275,8 +275,6 @@ export type Rank = RankProfile
/** 本周进步榜:`rank` 是周榜名次,跟存量总榜的名次没有关系 */
export type { WeeklyRank, WeeklyRankItem } from "@oj2/contract"
export type { ClassComparison } from "@oj2/contract"
/** 班里最近一次一起做的题(课上老师点的那几道),见后端 `/me/class-activity` */
export type { ClassActivity, ClassActivityProblem, LastVisit } from "@oj2/contract"