Files
OJ2/apps/web/src/oj/class/components/PkDuel.vue
T
xuyueandClaude Opus 5.5 b0700d0bd9
Deploy / deploy (push) Canceled after 0s
fix(界面): 能点的文字平时就是绿色,不用再把鼠标移上去才看得出能点
首页「全部」、提交详情的用户名和题名、题目页「点开看全部」「有 N 道差一点」、
班级对抗的班级名、知识点、后台标题等 30 多处原来和正文同色,鼠标移上去才变色,
手机上根本没有「移上去」。现在一律主题绿,移上去加下划线;没设颜色的文字按钮补上
type="primary"。排名名字列、整行可点的列表、后台 quaternary 次要按钮不动。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:39:49 +08:00

786 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { ClassPk, ClassPkCell } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { classLabel } from "utils/classes"
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 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) => ({
// 手机上八根柱挤在一行,「10月12日」只能用 10px 才放得下,太小;改短写法换大一号字
label: parseTime(week, props.compact ? "M/D" : "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>
</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>
</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>
</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: var(--oj-gap);
}
.score {
padding: 24px 32px;
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
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: var(--oj-fs-meta);
font-weight: 400;
color: v-bind("theme.textColor3");
}
.card {
border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
padding: 16px var(--oj-pad-x) 6px;
min-width: 0;
}
.card.pad {
padding: 16px var(--oj-pad-x);
}
.card-head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 8px;
padding-bottom: 10px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.card-title {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 8px;
}
.row {
min-height: 52px;
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: var(--oj-fs-body);
font-weight: 700;
font-variant-numeric: tabular-nums;
color: v-bind("theme.textColor3");
}
.value.win {
color: v-bind("theme.textColor1");
}
.sub {
font-size: 12px;
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: var(--oj-fs-sec);
font-weight: 600;
color: v-bind("theme.primaryColor");
text-decoration: none;
}
.title a:hover,
.m-title a:hover {
text-decoration: underline;
}
.ell {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tags {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.tags .muted {
font-size: 12px;
}
.most {
font-size: 12px;
font-weight: 600;
color: #b54708;
}
.tie {
font-size: 12px;
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: var(--oj-gap);
}
.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: 140px;
display: flex;
align-items: flex-end;
gap: 4px;
}
.bar-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.v {
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.wbar {
width: 18px;
border-radius: 3px 3px 0 0;
}
.axis {
font-size: 12px;
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: var(--oj-fs-meta);
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: var(--oj-fs-sec);
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: 12px;
}
</style>