Files
OJ2/apps/web/src/oj/contest/pages/class.vue
T
xuyueandClaude Opus 5.5 1e441411a5
Deploy / deploy (push) Canceled after 0s
style(前台): 页面整体放大一档,列表不再挤成一团;宽屏上内容居中,不再铺满到两边
- index.css 加一张 --oj-* 尺寸表和 .oj-page 外壳,二十几个前台页面改成引用它:
  正文 14→15、页标题 20→24、题目/提交列表行高 40→48、比赛/公告 56、筛选控件 28→34
- 阅读型页面限宽 1200 居中;题目/提交列表 1600、比赛详情 1400、排名 1280、班级 PK 1440
- 1280 宽的筛选条放大后放不下,题目/提交列表在 1366 以下退回 13px 的字
- 班级 PK 五个班起格子里不写「一次 x%」(只剩「一次 8…」);赛跑图线尾的数字不再被截掉
- 手机上比赛列表的「没参加」挪到比赛名下面;题单里「以前做对过 · 旧代码先藏着」缩成「做对过」
- 题目详情页(编辑器)和后台不在这一轮

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

1037 lines
29 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { ContestClassView, ContestScoreCell, ContestScoreRow } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { updateACMHelperChecked } from "admin/api"
import { getContestClassView, getSubmission, getSubmissions } from "oj/api"
import { useContestStore } from "oj/store/contest"
import UserName from "shared/components/UserName.vue"
import { useTone } from "oj/submission/composables/tone"
import { classLabel } from "oj/submission/utils"
import { useLeaveStudents } from "shared/composables/leaveStudents"
import { ContestStatus, LANGUAGE_FORMAT_VALUE } from "utils/constants"
import { parseTime, secondsToDuration } from "utils/functions"
import type { Submission } from "utils/types"
/**
* 老师的「全班情况」,考完叫「成绩」(设计稿「比赛重设计」老师两块)。学生看不到这一页。
*
* 名字和排行榜一样用用户名(ks241XXX),不用真名。
* 考试中:卡在一道题(错 3 次以上)和 15 分钟以上没交的人放最上面,
* 没进来的人在最下面(看板里标了请假的写「请假」)。
* 考完:按姓名一张表,点格子右边看代码、标「看过了」(代替原来后台的「审核」页),导出成绩。
* 名单是推出来的:比赛不绑班,来了一半以上的班才算这场的班(后端 buildClassView)
*/
const props = defineProps<{ contestID: string }>()
const contestStore = useContestStore()
const theme = useThemeVars()
const tone = useTone()
const message = useMessage()
const { onLeave } = useLeaveStudents("oj_leave_students")
const view = ref<ContestClassView | null>(null)
const failed = ref(false)
async function load() {
try {
view.value = await getContestClassView(props.contestID)
} catch {
failed.value = true
}
}
const running = computed(() => contestStore.contestStatus === ContestStatus.underway)
const ended = computed(() => contestStore.contestStatus === ContestStatus.finished)
const { pause, resume } = useIntervalFn(load, 15_000, { immediate: false })
watch(
() => [contestStore.canEnter, running.value] as const,
([enter, run]) => {
if (!enter) return
load()
if (run) resume()
else pause()
},
{ immediate: true },
)
const problems = computed(() => view.value?.problems ?? [])
const rows = computed(() => view.value?.rows ?? [])
function cellOf(row: ContestScoreRow, problemId: number): ContestScoreCell | undefined {
return row.cells[String(problemId)]
}
// ---------------------------------------------------------------- 考试中
const STUCK_ERRORS = 3
const IDLE_MINUTES = 15
function minutesSinceLast(row: ContestScoreRow) {
const last = view.value?.lastSubmit[String(row.userId)]
if (!last) return null
return Math.max(0, Math.floor((contestStore.now - Date.parse(last.time)) / 60_000))
}
function flagOf(row: ContestScoreRow) {
for (const p of problems.value) {
const cell = cellOf(row, p.id)
if (cell && !cell.isAc && cell.errors >= STUCK_ERRORS) {
return { kind: "stuck" as const, text: `卡在第 ${p._id} 题,错 ${cell.errors} 次` }
}
}
const idle = minutesSinceLast(row)
if (
running.value &&
idle !== null &&
idle >= IDLE_MINUTES &&
row.solved < problems.value.length
) {
return { kind: "idle" as const, text: `${idle} 分钟没交` }
}
return null
}
const attention = computed(() =>
rows.value
.map((row) => ({ row, flag: flagOf(row) }))
.filter((item) => item.flag !== null)
.sort((a, b) =>
a.flag!.kind === b.flag!.kind
? a.row.solved - b.row.solved
: a.flag!.kind === "stuck"
? -1
: 1,
),
)
const others = computed(() => {
const flagged = new Set(attention.value.map((item) => item.row.userId))
return rows.value
.filter((row) => !flagged.has(row.userId))
.sort((a, b) => a.solved - b.solved || (minutesSinceLast(b) ?? 0) - (minutesSinceLast(a) ?? 0))
})
const absent = computed(() =>
(view.value?.absent ?? []).map((user) => ({ ...user, leave: onLeave(user.username) })),
)
const leaveCount = computed(() => absent.value.filter((user) => user.leave).length)
const rosterSize = computed(() => {
const classes = new Set(view.value?.classes ?? [])
return (
rows.value.filter((row) => row.className && classes.has(row.className)).length +
absent.value.length
)
})
const average = computed(() =>
rows.value.length
? (rows.value.reduce((sum, row) => sum + row.solved, 0) / rows.value.length).toFixed(1)
: "0",
)
const stuckCount = computed(
() => attention.value.filter((item) => item.flag!.kind === "stuck").length,
)
const idleCount = computed(
() => attention.value.filter((item) => item.flag!.kind === "idle").length,
)
function lastText(row: ContestScoreRow) {
const minutes = minutesSinceLast(row)
if (minutes === null) return ""
return minutes === 0 ? "刚刚" : `${minutes} 分钟前`
}
const classNames = computed(() => (view.value?.classes ?? []).map(classLabel).join("、"))
// ---------------------------------------------------------------- 考完:成绩
/** 按姓名(用户名,中文按拼音)/ 名次 / 做对少的在前 */
type Sort = "username" | "rank" | "weak"
const sort = ref<Sort>("username")
const gradeRows = computed(() => {
const list = [...rows.value]
if (sort.value === "username") list.sort((a, b) => a.username.localeCompare(b.username, "zh-CN"))
else if (sort.value === "weak") list.sort((a, b) => a.solved - b.solved || b.rank! - a.rank!)
return list
})
const distribution = computed(() => {
const count = new Map<number, number>()
for (const row of rows.value) count.set(row.solved, (count.get(row.solved) ?? 0) + 1)
return [...count.entries()].sort(([a], [b]) => b - a)
})
const acTotal = computed(() =>
rows.value.reduce((sum, row) => sum + Object.values(row.cells).filter((c) => c.isAc).length, 0),
)
const checkedTotal = computed(() =>
rows.value.reduce(
(sum, row) => sum + Object.values(row.cells).filter((c) => c.isAc && c.checked).length,
0,
),
)
function barColor(solved: number) {
const n = problems.value.length || 1
const ratio = solved / n
if (ratio >= 1) return theme.value.successColor
if (ratio >= 0.7) return tone("success").solid + "b3"
if (ratio >= 0.5) return tone("success").solid + "66"
if (ratio >= 0.3) return theme.value.warningColor
return theme.value.errorColor + "aa"
}
const selected = ref<{ userId: number; problemId: number } | null>(null)
const code = ref<Submission | null>(null)
const codeState = ref<"idle" | "loading" | "none" | "failed">("idle")
const selectedRow = computed(() =>
selected.value ? rows.value.find((row) => row.userId === selected.value!.userId) : undefined,
)
const selectedProblem = computed(() =>
selected.value ? problems.value.find((p) => p.id === selected.value!.problemId) : undefined,
)
const selectedCell = computed(() =>
selectedRow.value && selectedProblem.value
? cellOf(selectedRow.value, selectedProblem.value.id)
: undefined,
)
async function pick(row: ContestScoreRow, problemId: number) {
selected.value = { userId: row.userId, problemId }
code.value = null
const problem = problems.value.find((p) => p.id === problemId)
if (!problem || !cellOf(row, problemId)) {
codeState.value = "none"
return
}
codeState.value = "loading"
try {
const list = await getSubmissions({
contestId: props.contestID,
username: row.username,
problemDisplayId: problem._id,
limit: 100,
offset: 0,
})
const mine = list.results.filter((item) => item.username === row.username)
const target = mine.find((item) => item.result === 0) ?? mine[0]
if (!target) {
codeState.value = "none"
return
}
const detail = await getSubmission(target.id)
if (selected.value?.userId !== row.userId || selected.value.problemId !== problemId) return
code.value = detail
codeState.value = "idle"
} catch {
codeState.value = "failed"
}
}
async function toggleChecked(value: boolean) {
const row = selectedRow.value
const cell = selectedCell.value
if (!row || !cell || !selected.value) return
try {
await updateACMHelperChecked(
Number(props.contestID),
row.rankId,
String(selected.value.problemId),
value,
)
cell.checked = value
} catch {
message.error("没标上,只有出这场比赛的老师能标")
}
}
function step(dx: number, dy: number) {
if (!selected.value) return
const list = gradeRows.value
const r = list.findIndex((row) => row.userId === selected.value!.userId)
const c = problems.value.findIndex((p) => p.id === selected.value!.problemId)
const nr = Math.min(list.length - 1, Math.max(0, r + dy))
const nc = Math.min(problems.value.length - 1, Math.max(0, c + dx))
if (list[nr] && problems.value[nc]) pick(list[nr], problems.value[nc].id)
}
onKeyStroke(["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"], (event) => {
if (!ended.value || !selected.value) return
const target = event.target as HTMLElement | null
if (target && ["INPUT", "TEXTAREA"].includes(target.tagName)) return
event.preventDefault()
const map = {
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
ArrowUp: [0, -1],
ArrowDown: [0, 1],
} as const
const [dx, dy] = map[event.key as keyof typeof map]
step(dx, dy)
})
function exportGrades() {
const title = contestStore.contest?.title ?? "比赛"
const head = [
"用户名",
"班级",
"做对",
"名次",
"用时",
...problems.value.map((p) => `第${p._id}题 ${p.title}`),
]
const lines = [head]
const byUsername = [...rows.value].sort((a, b) => a.username.localeCompare(b.username, "zh-CN"))
for (const row of byUsername) {
lines.push([
row.username,
row.className ? classLabel(row.className) : "",
String(row.solved),
String(row.rank ?? ""),
secondsToDuration(row.totalTime),
...problems.value.map((p) => {
const cell = cellOf(row, p.id)
return !cell ? "没交" : cell.isAc ? "对" : "错"
}),
])
}
for (const user of absent.value) {
lines.push([
user.username,
user.className ? classLabel(user.className) : "",
user.leave ? "请假" : "没考",
"",
"",
...problems.value.map(() => ""),
])
}
const csv = lines
.map((line) => line.map((value) => `"${value.replaceAll('"', '""')}"`).join(","))
.join("\r\n")
// 带 BOM,Excel 双击打开中文才不乱码
const blob = new Blob(["" + csv], { type: "text/csv;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `${title} 成绩.csv`
a.click()
URL.revokeObjectURL(url)
}
const success = computed(() => tone("success"))
const danger = computed(() => tone("error"))
const warning = computed(() => tone("warning"))
const liveColumns = computed(
() => `130px 48px repeat(${problems.value.length}, minmax(70px, 96px)) 90px 1fr`,
)
const gradeColumns = computed(() => `24px 124px 40px 36px repeat(${problems.value.length}, 50px)`)
</script>
<template>
<div v-if="failed" class="empty">没拉到全班情况,刷新试试</div>
<div v-else-if="!view" class="empty">加载中…</div>
<!-- ==================== 考试中 / 还没开始 ==================== -->
<div v-else-if="!ended" class="live">
<div class="summary">
<div class="stat">
<b>{{ rows.length }}</b>
<span
>进来了<template v-if="classNames">
· {{ classNames }} {{ rosterSize }} 人</template
></span
>
</div>
<div v-if="absent.length" class="stat">
<b class="grey">{{ absent.length }}</b>
<span
>没进来<template v-if="leaveCount">({{ leaveCount }} 人请假)</template></span
>
</div>
<div class="stat">
<b>{{ average }}</b
><span>平均做对(共 {{ problems.length }} 道)</span>
</div>
<div class="stat">
<b class="red">{{ stuckCount }}</b
><span>卡在一道题上(错 3 次以上)</span>
</div>
<div v-if="running" class="stat">
<b class="amber">{{ idleCount }}</b
><span>{{ IDLE_MINUTES }} 分钟以上没交</span>
</div>
<div class="spacer"></div>
<span class="muted small">学生看不到这一页 · 15 秒刷新一次</span>
</div>
<div class="grid-wrap">
<div class="tr th" :style="{ gridTemplateColumns: liveColumns }">
<span>学生</span>
<span class="num">做对</span>
<span v-for="p in problems" :key="p.id" class="ph" :title="p.title">
<span class="ell"
><b>{{ p._id }}</b> {{ p.title }}</span
>
<span>{{ p.solvedUsers }} 人对</span>
</span>
<span class="num">最近一次交</span>
<span></span>
</div>
<template v-if="attention.length">
<div class="group red-group">要看一眼的 {{ attention.length }} 人</div>
<div
v-for="{ row, flag } in attention"
:key="row.userId"
class="tr"
:style="{ gridTemplateColumns: liveColumns }"
>
<UserName :username="row.username" />
<span class="num solved">{{ row.solved }}</span>
<span v-for="p in problems" :key="p.id" class="lc">
<template v-if="cellOf(row, p.id)">
<span v-if="cellOf(row, p.id)!.isAc" class="cell ac"
>✓ {{ Math.floor(cellOf(row, p.id)!.acTime / 60) }}′<small
v-if="cellOf(row, p.id)!.errors"
>-{{ cellOf(row, p.id)!.errors }}</small
></span
>
<span v-else class="cell no" :class="{ hot: cellOf(row, p.id)!.errors >= 3 }">{{
cellOf(row, p.id)!.errors ? `错 ${cellOf(row, p.id)!.errors}` : "交过"
}}</span>
</template>
<span v-else class="dot">·</span>
</span>
<span class="num muted tiny">{{ lastText(row) }}</span>
<span class="flag-wrap">
<span class="flag" :class="flag!.kind">{{ flag!.text }}</span>
</span>
</div>
</template>
<div class="group">其余 {{ others.length }} 人 · 做对少的在前</div>
<div
v-for="row in others"
:key="row.userId"
class="tr"
:style="{ gridTemplateColumns: liveColumns }"
>
<UserName :username="row.username" />
<span class="num solved">{{ row.solved }}</span>
<span v-for="p in problems" :key="p.id" class="lc">
<template v-if="cellOf(row, p.id)">
<span v-if="cellOf(row, p.id)!.isAc" class="cell ac"
>✓ {{ Math.floor(cellOf(row, p.id)!.acTime / 60) }}′<small
v-if="cellOf(row, p.id)!.errors"
>-{{ cellOf(row, p.id)!.errors }}</small
></span
>
<span v-else class="cell no">{{
cellOf(row, p.id)!.errors ? `错 ${cellOf(row, p.id)!.errors}` : "交过"
}}</span>
</template>
<span v-else class="dot">·</span>
</span>
<span class="num muted tiny">{{ lastText(row) }}</span>
<span></span>
</div>
<template v-if="absent.length">
<div class="group">没进来 {{ absent.length }} 人</div>
<div
v-for="user in absent"
:key="user.userId"
class="tr absent"
:style="{ gridTemplateColumns: liveColumns }"
>
<UserName :username="user.username" muted />
<span></span>
<span v-for="p in problems" :key="p.id"></span>
<span></span>
<span class="flag-wrap">
<span class="flag grey">{{ user.leave ? "请假(看板里标的)" : "没进来" }}</span>
</span>
</div>
</template>
</div>
</div>
<!-- ==================== 考完:成绩 ==================== -->
<div v-else class="grades">
<div class="grade-bar">
<div class="dist">
<div class="dist-bar">
<span
v-for="[solved, n] in distribution"
:key="solved"
:title="`做对 ${solved} 道 ${n} 人`"
:style="{ width: `${(n / rows.length) * 100}%`, background: barColor(solved) }"
></span>
</div>
<div class="dist-legend">
<span v-for="[solved, n] in distribution" :key="solved">
<i :style="{ background: barColor(solved) }"></i>{{ solved }} 道 {{ n }} 人
</span>
</div>
</div>
<span class="sec small">
{{ rows.length }} 人考了<template v-if="absent.length">
· {{ absent.length }} 人没考<template v-if="leaveCount"
>({{ leaveCount }} 人请假)</template
></template
>
· 平均做对 {{ average }} 道
</span>
<div class="spacer"></div>
<span class="muted small">代码看过 {{ checkedTotal }} / {{ acTotal }} 份</span>
<n-button type="primary" @click="exportGrades">导出成绩</n-button>
</div>
<div class="split">
<section class="sheet">
<div class="sort">
<span class="muted small">排序</span>
<button class="chip" :class="{ on: sort === 'username' }" @click="sort = 'username'">
姓名
</button>
<button class="chip" :class="{ on: sort === 'rank' }" @click="sort = 'rank'">名次</button>
<button class="chip" :class="{ on: sort === 'weak' }" @click="sort = 'weak'">
做对少的在前
</button>
</div>
<div class="sheet-scroll">
<div class="tr th" :style="{ gridTemplateColumns: gradeColumns }">
<span></span>
<span>学生</span>
<span class="num">做对</span>
<span class="num">名次</span>
<span v-for="p in problems" :key="p.id" class="ph center" :title="p.title">
<b>{{ p._id }}</b
><span>{{ p.solvedUsers }} 人对</span>
</span>
</div>
<div
v-for="(row, index) in gradeRows"
:key="row.userId"
class="tr"
:class="{ picked: selected?.userId === row.userId }"
:style="{ gridTemplateColumns: gradeColumns }"
>
<span class="muted tiny num">{{ index + 1 }}</span>
<UserName :username="row.username" />
<span class="num solved">{{ row.solved }}</span>
<span class="num muted tiny">{{ row.rank }}</span>
<button
v-for="p in problems"
:key="p.id"
class="gc"
:class="{
ac: cellOf(row, p.id)?.isAc,
no: cellOf(row, p.id) && !cellOf(row, p.id)!.isAc,
sel: selected?.userId === row.userId && selected.problemId === p.id,
}"
:title="`${row.username} · 第 ${p._id} 题`"
@click="pick(row, p.id)"
>
<template v-if="cellOf(row, p.id)?.isAc">✓</template>
<template v-else-if="cellOf(row, p.id)">✗</template>
<template v-else>·</template>
<i v-if="cellOf(row, p.id)?.checked" class="seen"></i>
</button>
</div>
<div
v-for="user in absent"
:key="user.userId"
class="tr absent"
:style="{ gridTemplateColumns: gradeColumns }"
>
<span></span>
<UserName :username="user.username" muted />
<span class="tiny">{{ user.leave ? "请假" : "没考" }}</span>
</div>
</div>
</section>
<section class="code-panel">
<template v-if="selectedRow && selectedProblem">
<div class="code-head">
<UserName :username="selectedRow.username" />
<span class="muted">·</span>
<span
><span class="muted">{{ selectedProblem._id }}</span>
{{ selectedProblem.title }}</span
>
<span v-if="selectedCell?.isAc" class="pill ok">
{{
Math.floor(selectedCell.acTime / 60) === 0
? "1 分钟内做对"
: `第 ${Math.floor(selectedCell.acTime / 60)} 分钟做对`
}}
</span>
<span v-else-if="selectedCell" class="pill bad">没做对</span>
<span v-if="selectedCell?.errors" class="muted small"
>错过 {{ selectedCell.errors }} 次</span
>
<span v-if="selectedCell?.firstAc" class="muted small">· 全班最先做对</span>
</div>
<div class="code-box">
<n-code
v-if="code"
:code="code.code"
:language="LANGUAGE_FORMAT_VALUE[code.language]"
show-line-numbers
/>
<span v-else-if="codeState === 'loading'" class="muted">代码加载中…</span>
<span v-else-if="codeState === 'none'" class="muted">这题没交</span>
<span v-else-if="codeState === 'failed'" class="muted">代码没拉下来,再点一次</span>
</div>
<div class="code-foot">
<n-checkbox
v-if="selectedCell?.isAc"
:checked="selectedCell.checked"
:disabled="!contestStore.isContestAdmin"
@update:checked="toggleChecked"
>看过了,没问题</n-checkbox
>
<span v-if="code" class="muted tiny">
{{ code.language }} · {{ parseTime(code.createTime, "M月D日 HH:mm:ss") }}
</span>
<div class="spacer"></div>
<span class="muted tiny">点格子换人换题 · ← → ↑ ↓ 也行 · 看过的格子右上角有个点</span>
</div>
</template>
<div v-else class="code-empty muted">
点左边任意一格,这里看那个人那道题的代码。<br />做对的格子看完可以标「看过了」,查抄袭用。
</div>
</section>
</div>
</div>
</template>
<style scoped>
.empty {
padding: 60px 20px;
text-align: center;
color: v-bind("theme.textColor3");
}
.muted {
color: v-bind("theme.textColor3");
}
.sec {
color: v-bind("theme.textColor2");
}
.small {
font-size: var(--oj-fs-sec);
}
.tiny {
font-size: var(--oj-fs-meta);
}
.spacer {
flex-grow: 1;
}
.ell {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.summary {
min-height: 80px;
box-sizing: border-box;
padding: 12px var(--oj-pad-x);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px 36px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.stat b {
font-size: 26px;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.stat span {
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
}
.stat .grey {
color: v-bind("theme.textColor3");
}
.stat .red {
color: v-bind("danger.color");
}
.stat .amber {
color: v-bind("warning.color");
}
.grid-wrap {
overflow-x: auto;
}
.tr {
display: grid;
align-items: center;
column-gap: 6px;
min-width: max-content;
min-height: 46px;
padding: 0 var(--oj-pad-x);
font-size: var(--oj-fs-body);
box-sizing: border-box;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.th {
min-height: 50px;
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor3");
background: v-bind("theme.actionColor");
}
.ph {
display: flex;
flex-direction: column;
line-height: 16px;
min-width: 0;
}
.ph b {
color: v-bind("theme.textColor2");
}
.ph.center {
align-items: center;
}
.ph span {
font-size: 12px;
}
.solved {
font-weight: 700;
padding-right: 10px;
}
.lc {
display: flex;
}
.cell {
width: 100%;
height: 30px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
font-size: var(--oj-fs-meta);
font-variant-numeric: tabular-nums;
}
.cell small {
font-size: 11px;
opacity: 0.8;
}
.cell.ac {
font-weight: 600;
background: v-bind("success.background");
color: v-bind("success.color");
}
.cell.no {
background: v-bind("danger.background");
color: v-bind("danger.color");
}
.cell.no.hot {
font-weight: 700;
box-shadow: inset 0 0 0 1px v-bind("danger.color");
}
.dot {
width: 100%;
text-align: center;
color: v-bind("theme.textColor3");
opacity: 0.5;
}
.group {
height: 32px;
padding: 0 var(--oj-pad-x);
display: flex;
align-items: center;
font-size: var(--oj-fs-meta);
font-weight: 600;
color: v-bind("theme.textColor3");
background: v-bind("theme.actionColor");
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.red-group {
color: v-bind("danger.color");
background: v-bind("danger.background");
}
.flag-wrap {
display: flex;
justify-content: flex-end;
}
.flag {
height: 24px;
padding: 0 9px;
border-radius: 4px;
font-size: var(--oj-fs-meta);
display: inline-flex;
align-items: center;
white-space: nowrap;
}
.flag.stuck {
background: v-bind("danger.background");
color: v-bind("danger.color");
}
.flag.idle {
background: v-bind("warning.background");
color: v-bind("warning.color");
}
.flag.grey {
background: v-bind("theme.actionColor");
color: v-bind("theme.textColor3");
}
.tr.absent {
color: v-bind("theme.textColor3");
background: v-bind("theme.actionColor");
}
/* ---------- 成绩 ---------- */
.grade-bar {
min-height: 72px;
box-sizing: border-box;
padding: 12px var(--oj-pad-x);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px 24px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.dist {
width: 480px;
max-width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
}
.dist-bar {
display: flex;
height: 12px;
border-radius: 3px;
overflow: hidden;
}
.dist-legend {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
font-size: var(--oj-fs-meta);
color: v-bind("theme.textColor2");
}
.dist-legend span {
display: flex;
align-items: center;
gap: 4px;
}
.dist-legend i {
width: 10px;
height: 10px;
border-radius: 2px;
}
.split {
display: flex;
flex-wrap: wrap;
}
.sheet {
flex: 1 1 560px;
min-width: 0;
border-right: 1px solid v-bind("theme.dividerColor");
}
.sort {
height: 48px;
padding: 0 var(--oj-pad-x);
display: flex;
align-items: center;
gap: 6px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.chip {
height: 30px;
padding: 0 12px;
border-radius: 15px;
border: 1px solid v-bind("theme.borderColor");
background: transparent;
font: inherit;
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor2");
cursor: pointer;
}
.chip.on {
border-color: transparent;
background: v-bind("success.background");
color: v-bind("success.color");
font-weight: 600;
}
.sheet-scroll {
max-height: calc(100vh - 284px);
overflow: auto;
}
.sheet .tr {
min-height: 40px;
padding: 0 16px 0 var(--oj-pad-x);
}
.sheet .th {
position: sticky;
top: 0;
z-index: 1;
min-height: 46px;
}
.tr.picked {
background: v-bind("theme.hoverColor");
}
.gc {
position: relative;
height: 28px;
border: 0;
border-radius: 4px;
background: transparent;
font: inherit;
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor3");
cursor: pointer;
}
.gc.ac {
background: v-bind("success.background");
color: v-bind("success.color");
}
.gc.no {
background: v-bind("danger.background");
color: v-bind("danger.color");
}
.gc.sel {
box-shadow: 0 0 0 2px v-bind("theme.textColor1");
}
.seen {
position: absolute;
top: 3px;
right: 4px;
width: 5px;
height: 5px;
border-radius: 50%;
background: v-bind("success.color");
}
.code-panel {
flex: 1 1 480px;
min-width: 0;
box-sizing: border-box;
padding: 16px var(--oj-pad-x) 14px;
display: flex;
flex-direction: column;
gap: 12px;
background: v-bind("theme.actionColor");
}
.code-head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.pill {
height: 24px;
padding: 0 9px;
border-radius: 4px;
font-size: var(--oj-fs-meta);
font-weight: 600;
display: inline-flex;
align-items: center;
}
.pill.ok {
background: v-bind("success.background");
color: v-bind("success.color");
}
.pill.bad {
background: v-bind("danger.background");
color: v-bind("danger.color");
}
.code-box {
flex-grow: 1;
min-height: 300px;
max-height: calc(100vh - 360px);
overflow: auto;
padding: 10px 12px;
border: 1px solid v-bind("theme.dividerColor");
border-radius: var(--oj-radius);
background: v-bind("theme.cardColor");
font-size: 15px;
}
.code-foot {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.code-empty {
padding: 80px 20px;
text-align: center;
line-height: 1.8;
}
</style>