feat(排名): 班级详情改成右侧抽屉、老师能在里面直接出 AI 分析;排名页加上个人智能分析入口;用词改成前一名 / 后一名
Deploy / deploy (push) Canceled after 0s

- 班级对抗点班名打开班级详情:人均做对和年级比、中间那位同学、做对过的人、全班分布点阵、
  每周人均新做对;去掉四分位数、标准差、综合分。老师多看到「要多关心的同学」(这学期做对不到
  3 道)和 AI 分析,报告直接在抽屉里出
- 班级 AI 分析只传班号,数字由服务端按抽屉同一套现算(services/ranking.ts),报告和抽屉对得上
- 学生「你」那张卡底下加「看我的智能分析」;老师名单「⋯」加「看他的智能分析」;班级对抗右上角
  「班级 PK ›」
- 「要追的 / 追你 / 冲得最猛 / 对手」会被学生读歪,改成「前一名 / 后一名 / 进步最大」,
  对前后一名只说「比你早 / 晚几秒做到几道」
- 老师打开排名页默认看全部,班级下拉框第一项「全部」,选了班就切到本班

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-10 07:58:52 +08:00
1 parent 2cf2d4260a
commit 3c6e4a1e7c
17 files changed
+1053 -229

No files matched your search

@@ -5,7 +5,7 @@ import UserName from "shared/components/UserName.vue"
import { parseTime } from "utils/functions"
import RankAvatar from "./RankAvatar.vue"
/** 本班最近几周的每周冠军,底下再挂这张榜上「冲得最猛」的那个人 */
/** 本班最近几周的每周冠军,底下再挂这张榜上「进步最大」的那个人 */
defineProps<{
champions: WeeklyChampion[]
hot: RankRow | null
@@ -51,7 +51,7 @@ function weekLabel(start: string) {
</div>
<span v-if="!champions.length" class="muted">最近几周还没有冠军</span>
<div v-if="hot" class="row hot-line">
<span class="hot-tag">冲得最猛</span>
<span class="hot-tag">进步最大</span>
<RankAvatar
:username="hot.user.username"
:avatar="hot.avatar"
@@ -14,7 +14,10 @@ const props = defineProps<{
teacher?: boolean
/** 放在手机页签卡里:不要自己的边框和标题 */
bare?: boolean
/** 显示「班级 PK」入口(原来排名页上那个按钮,管理员角色才有) */
pk?: boolean
}>()
const emit = defineEmits<{ pick: [className: string] }>()
const theme = useThemeVars()
const palette = useRankPalette()
@@ -52,7 +55,8 @@ function shortLabel(className: string) {
<template>
<div class="battle" :class="{ bare }">
<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>
</div>
<div
v-for="item in shown"
@@ -61,7 +65,9 @@ function shortLabel(className: string) {
:class="{ mine: item.className === mine }"
>
<span class="rank">{{ item.rank }}</span>
<span class="name">{{ classLabel(item.className) }}</span>
<button class="name" title="看班级详情" @click="emit('pick', item.className)">
{{ classLabel(item.className) }}
</button>
<span class="bar-box">
<span
class="bar"
@@ -132,6 +138,26 @@ function shortLabel(className: string) {
.name {
width: 86px;
padding: 0;
border: 0;
background: none;
font: inherit;
color: inherit;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.name:hover {
color: v-bind("theme.primaryColor");
text-decoration: underline;
}
.pk {
margin-left: auto;
font-size: 12px;
color: v-bind("theme.primaryColor");
text-decoration: none;
white-space: nowrap;
}
@@ -0,0 +1,477 @@
<script setup lang="ts">
import type { ClassDetail } from "@oj2/contract"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { useThemeVars } from "naive-ui"
import { getClassDetail } from "oj/api"
import { classLabel } from "oj/submission/utils"
import UserName from "shared/components/UserName.vue"
import { useAIStream } from "shared/composables/aiStream"
import { useBreakpoints } from "shared/composables/breakpoints"
import { parseTime } from "utils/functions"
import { useRankPalette } from "../palette"
import RankAvatar from "./RankAvatar.vue"
/**
* 班级详情(设计稿「班级详情 · 老师 / 学生」):从右边滑出的抽屉,代替原来那个满是
* 四分位数、标准差、综合分的弹框。谁都能看;「要多关心的同学」和 AI 分析只给老师,
* AI 报告直接在抽屉里出,不再关掉再弹第二个框。
*/
const props = defineProps<{ className: string | null; teacher: boolean; pk: boolean }>()
const show = defineModel<boolean>("show", { required: true })
const router = useRouter()
const message = useMessage()
const theme = useThemeVars()
const palette = useRankPalette()
const { isDesktop } = useBreakpoints()
const detail = ref<ClassDetail | null>(null)
const loading = ref(false)
const report = ref("")
const ai = useAIStream()
watch(
() => [show.value, props.className] as const,
async ([open, className]) => {
if (!open || !className) return
if (detail.value?.className !== className) {
report.value = ""
ai.abort()
}
loading.value = true
try {
detail.value = await getClassDetail(className)
} catch {
detail.value = null
} finally {
loading.value = false
}
},
)
/** 分布点阵:每个做对数一列,一列里一个点一个同学;做对不到 3 道的老师那边标红 */
const columns = computed(() => {
const counts = new Map<number, number>()
for (const n of detail.value?.distribution ?? []) counts.set(n, (counts.get(n) ?? 0) + 1)
return [...counts].sort((a, b) => a[0] - b[0]).map(([solved, people]) => ({ solved, people }))
})
const dot = computed(() => {
const tallest = Math.max(1, ...columns.value.map((column) => column.people))
return Math.max(4, Math.min(10, Math.floor(172 / tallest) - 2))
})
const weekMax = computed(() =>
Math.max(
1,
...(detail.value?.weeks ?? []).flatMap((week) => [week.perCapita, week.gradeAvg ?? 0]),
),
)
async function analyze() {
if (!props.className) return
report.value = ""
try {
await ai.run(
"ai/class-analysis",
{ className: props.className },
{ onDelta: (content) => (report.value += content) },
)
} catch (error) {
message.error((error as Error).message)
}
}
function go(path: string, query: Record<string, string>) {
show.value = false
router.push({ path, query })
}
</script>
<template>
<n-drawer v-model:show="show" :width="isDesktop ? 560 : '100%'" placement="right">
<n-drawer-content closable :native-scrollbar="false">
<template #header>
<div class="head">
<span class="title">{{ className ? classLabel(className) : "班级详情" }}</span>
<span v-if="detail" class="muted">
{{ detail.members }} 人 · 这学期({{ parseTime(detail.start, "M月D日") }}起)
<template v-if="detail.rank"> · 班级对抗第 {{ detail.rank }}</template>
</span>
</div>
</template>
<n-spin :show="loading">
<div v-if="detail" class="body">
<div class="bigs">
<div class="big">
<span>人均做对</span><b>{{ detail.perCapita }}</b>
<span v-if="detail.gradeAvg !== null">年级平均 {{ detail.gradeAvg }}</span>
</div>
<div class="big">
<span>中间那位同学做对</span><b>{{ detail.median }} 道</b><span>一半人比他多</span>
</div>
<div class="big">
<span>做对过题的</span>
<b
>{{ detail.solvedMembers }} <small>/ {{ detail.members }} 人</small></b
>
<span>{{
detail.members - detail.solvedMembers
? `${detail.members - detail.solvedMembers} 人还没做对`
: "人人都做对过"
}}</span>
</div>
</div>
<section>
<div class="sec-title">
全班分布<span class="muted">一个点一个同学,下面是这学期做对几道</span>
</div>
<div class="dots">
<div v-for="column in columns" :key="column.solved" class="dot-col">
<div class="stack">
<span
v-for="i in column.people"
:key="i"
class="dot"
:style="{
width: `${dot}px`,
height: `${dot}px`,
background: teacher && column.solved < 3 ? '#e5484d' : '#9fb3c8',
}"
/>
</div>
<span class="axis">{{ column.solved }}</span>
</div>
</div>
<span class="line">
前 10% 平均 <b>{{ detail.top10Avg }}</b> 道 · 后 10% 平均
<b>{{ detail.bottom10Avg }}</b> 道
<template v-if="detail.plateau">
· {{ detail.plateau.count }} 个人停在 {{ detail.plateau.solved }} 道</template
>
</span>
</section>
<section v-if="detail.weeks.length">
<div class="sec-title">
每周人均新做对
<span class="key"><i :style="{ background: palette.me }" />本班</span>
<span v-if="detail.gradeAvg !== null" class="key"
><i :style="{ background: palette.bar }" />年级平均</span
>
</div>
<div class="weeks">
<div v-for="week in detail.weeks" :key="week.weekStart" class="week">
<div class="pair">
<div class="bar-col">
<span class="v me">{{ week.perCapita }}</span>
<span
class="bar"
:style="{
height: `${Math.max(2, (week.perCapita / weekMax) * 90)}px`,
background: palette.me,
}"
/>
</div>
<div v-if="week.gradeAvg !== null" class="bar-col">
<span class="v">{{ week.gradeAvg }}</span>
<span
class="bar"
:style="{
height: `${Math.max(2, (week.gradeAvg / weekMax) * 90)}px`,
background: palette.bar,
}"
/>
</div>
</div>
<span class="axis">{{ parseTime(week.weekStart, "M月D日") }}那周</span>
</div>
</div>
</section>
<section v-if="detail.care">
<div class="sec-title">
要多关心的同学<span class="muted">这学期做对不到 3 道 · 只有老师看得到</span>
</div>
<div v-for="row in detail.care" :key="row.user.id" class="care">
<RankAvatar :username="row.user.username" :avatar="row.avatar" :size="20" />
<UserName :username="row.user.username" />
<span class="muted">这学期 {{ row.solved }} 道 · 交过 {{ row.submissions }} 次</span>
<div class="spacer" />
<a
href="#"
@click.prevent="
go('/submission', { username: row.user.username, exactUsername: '1' })
"
>看他的提交 ›</a
>
<a href="#" @click.prevent="go('/ai-analysis', { username: row.user.username })"
>智能分析 ›</a
>
</div>
<span v-if="!detail.care.length" class="muted">没有,这学期人人都做对 3 道以上</span>
</section>
<section v-if="teacher" class="ai">
<div class="ai-head">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z" />
</svg>
<b>AI 分析这个班</b>
<span class="muted">根据上面这些数字写,仅供参考</span>
<div class="spacer" />
<n-button
size="small"
:type="report ? 'default' : 'info'"
:loading="ai.running.value"
@click="analyze"
>
{{ report ? "重新分析" : "开始分析" }}
</n-button>
</div>
<span v-if="ai.waiting.value" class="muted">正在看这个班的数据…</span>
<MdPreview v-if="report" class="report" :model-value="report" />
</section>
<a v-if="pk" href="#" class="pk" @click.prevent="go('/class', {})"
>和别的班比 → 班级 PK ›</a
>
</div>
<div v-else-if="!loading" class="muted">这个班的数据没取到</div>
</n-spin>
</n-drawer-content>
</n-drawer>
</template>
<style scoped>
.head {
display: flex;
flex-direction: column;
gap: 3px;
}
.title {
font-size: 20px;
font-weight: 700;
}
.muted {
font-size: 12px;
font-weight: 400;
color: v-bind("theme.textColor3");
}
.body {
display: flex;
flex-direction: column;
gap: 20px;
}
.bigs {
display: flex;
gap: 8px;
}
.big {
flex: 1;
min-width: 0;
padding: 10px 12px;
border-radius: 6px;
background: v-bind("theme.actionColor");
display: flex;
flex-direction: column;
gap: 2px;
}
.big b {
font-size: 24px;
font-variant-numeric: tabular-nums;
}
.big b small {
font-size: 13px;
font-weight: 400;
color: v-bind("theme.textColor3");
}
.big span {
font-size: 12px;
color: v-bind("theme.textColor3");
}
section {
display: flex;
flex-direction: column;
gap: 8px;
}
.sec-title {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: v-bind("theme.textColor2");
}
.dots {
display: flex;
align-items: flex-end;
gap: 2px;
}
.dot-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.stack {
height: 172px;
display: flex;
flex-direction: column-reverse;
align-items: center;
gap: 2px;
padding-bottom: 4px;
border-bottom: 1px solid v-bind("theme.dividerColor");
width: 100%;
}
.dot {
border-radius: 50%;
flex-shrink: 0;
}
.axis {
font-size: 11px;
color: v-bind("theme.textColor3");
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.line {
font-size: 13px;
color: v-bind("theme.textColor2");
}
.key {
display: flex;
align-items: center;
gap: 4px;
font-weight: 400;
font-size: 12px;
}
.key i {
width: 10px;
height: 10px;
border-radius: 2px;
}
.weeks {
display: flex;
gap: 4px;
}
.week {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.pair {
height: 110px;
display: flex;
align-items: flex-end;
gap: 4px;
}
.bar-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.bar {
width: 16px;
border-radius: 3px 3px 0 0;
}
.v {
font-size: 11px;
color: v-bind("theme.textColor3");
font-variant-numeric: tabular-nums;
}
.v.me {
color: #18a058;
font-weight: 600;
}
.care {
height: 30px;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.care a,
.pk {
font-size: 13px;
color: v-bind("theme.primaryColor");
text-decoration: none;
white-space: nowrap;
}
.spacer {
flex-grow: 1;
}
.ai {
padding: 12px 14px;
border-radius: 8px;
border: 1px solid rgba(47, 111, 208, 0.3);
background: rgba(47, 111, 208, 0.05);
}
.ai-head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
color: #2f6fd0;
}
.ai-head b {
color: v-bind("theme.textColor1");
}
.report {
background: transparent;
}
.report :deep(.md-editor-preview-wrapper) {
padding: 0;
}
</style>
@@ -6,7 +6,7 @@ import { USERNAME_CLASS_RE } from "utils/constants"
/**
* 排名页的头像。九成以上的学生没换过头像(1797 个号是 default.png),一排一模一样的
* 默认图分不出人,所以默认头像画成「名字最后一个字 + 彩色圆」,颜色按用户名固定 ——
* 同一个人在领奖台、赛道、对手卡里是同一个颜色。自己是实心绿。
* 同一个人在领奖台、赛道、「你」那张卡里是同一个颜色。自己是实心绿。
*/
const props = defineProps<{
username: string
+41 -7
View File
@@ -14,7 +14,10 @@ export interface MiniRank {
/**
* 「你」那张卡:大字第几名、比周一升了几名,一句最能推人一把的话(离领奖台几道 / 升了几名),
* 再是两个对手 —— 前面那个要追的、后面那个在追你的,差几秒、差几道都写出来。
* 再是前一名、后一名,差几秒、差几道都写出来。
*
* 用词只说位置(前一名 / 后一名 / 进步最大),不用「追」「对手」「冲」:用户指出
* 「要追的」「追你」会被中学生读成追求谁,「冲得最猛」也容易误会。
*/
const props = defineProps<{
me: RankRow
@@ -25,7 +28,7 @@ const props = defineProps<{
total: number
label: string
minis: MiniRank[]
/** 手机:对手的说明换到名字下面一行,别被截断 */
/** 手机:前后一名的说明换到名字下面一行,别被截断 */
compact?: boolean
}>()
@@ -33,7 +36,7 @@ const theme = useThemeVars()
const headline = computed(() => {
const { me, ahead, third } = props
if (me.rank === 1) return "你是第一名,后面的人在追你"
if (me.rank === 1) return "你是第一名"
if (me.rank <= 3 && ahead)
return `你在领奖台上 · 再做对 ${toPass(me, ahead)} 道就是第 ${ahead.rank} 名`
if (third) {
@@ -47,13 +50,13 @@ const headline = computed(() => {
function chaseNote(me: RankRow, ahead: RankRow) {
if (ahead.solved === me.solved && ahead.reachedAt && me.reachedAt)
return `早你 ${gapText(ahead.reachedAt, me.reachedAt)}做到 ${me.solved} 道`
return `比你早 ${gapText(ahead.reachedAt, me.reachedAt)}做到 ${me.solved} 道`
return `比你多 ${ahead.solved - me.solved} 道`
}
function threatNote(me: RankRow, behind: RankRow) {
if (behind.solved === me.solved && behind.reachedAt && me.reachedAt)
return `只比你晚 ${gapText(me.reachedAt, behind.reachedAt)},再做对 1 道就超过你`
return `比你晚 ${gapText(me.reachedAt, behind.reachedAt)}做到 ${me.solved} 道`
if (!behind.solved) return "还没做对"
return `比你少 ${me.solved - behind.solved} 道`
}
@@ -92,13 +95,13 @@ function threatNote(me: RankRow, behind: RankRow) {
<b>{{ headline }}</b>
</div>
<div v-if="ahead" class="rival">
<span class="tag chase">要追</span>
<span class="tag chase">前一名</span>
<RankAvatar :username="ahead.user.username" :avatar="ahead.avatar" :size="20" />
<UserName :username="ahead.user.username" class="rival-name" />
<span class="note">{{ chaseNote(me, ahead) }}</span>
</div>
<div v-if="behind" class="rival">
<span class="tag threat">追你</span>
<span class="tag threat">后一名</span>
<RankAvatar :username="behind.user.username" :avatar="behind.avatar" :size="20" />
<UserName :username="behind.user.username" class="rival-name" />
<span class="note">{{ threatNote(me, behind) }}</span>
@@ -112,6 +115,22 @@ function threatNote(me: RankRow, behind: RankRow) {
<b v-else class="none">没上榜</b>
</button>
</div>
<router-link to="/ai-analysis" class="analysis">
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z" />
</svg>
看我的智能分析:哪类题卡得多、哪段时间最常做题 ›
</router-link>
</div>
</template>
@@ -238,6 +257,21 @@ function threatNote(me: RankRow, behind: RankRow) {
background: rgba(199, 106, 18, 0.12);
}
.analysis {
display: flex;
align-items: center;
gap: 6px;
padding-top: 8px;
border-top: 1px solid rgba(24, 160, 88, 0.3);
font-size: 13px;
color: #2f6fd0;
text-decoration: none;
}
.analysis:hover {
text-decoration: underline;
}
.minis {
display: flex;
gap: 6px;
@@ -7,7 +7,7 @@ import RankAvatar from "./RankAvatar.vue"
/**
* 领奖台:前三名站在台阶上,第一名戴皇冠。赛道从第 4 名接着排,前三不再出现第二遍。
* 你要追的人站在台上时台阶描蓝边、挂「你要追的」,和赛道里的蓝条是一回事。
* 你的前一名站在台上时台阶描蓝边、挂「前一名」,和赛道里的蓝条是一回事。
*/
const props = defineProps<{
rows: RankRow[]
@@ -49,8 +49,8 @@ const HEIGHTS: Record<number, number> = { 1: 50, 2: 38, 3: 30 }
>
<path d="M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z" />
</svg>
<span v-if="row.user.id === chaseId" class="tag chase">你要追的</span>
<span v-else-if="row.user.id === threatId" class="tag threat">在追你</span>
<span v-if="row.user.id === chaseId" class="tag chase">前一名</span>
<span v-else-if="row.user.id === threatId" class="tag threat">后一名</span>
<span v-else-if="row.user.id === meId" class="tag me">你</span>
</div>
<RankAvatar
@@ -36,6 +36,7 @@ const emit = defineEmits<{
open: [username: string]
expand: []
submissions: [username: string]
analysis: [username: string]
hide: [row: RankRow]
}>()
@@ -130,11 +131,13 @@ function width(row: RankRow) {
const menu: DropdownOption[] = [
{ label: "看他的提交", key: "submissions" },
{ label: "看他的智能分析", key: "analysis" },
{ label: "不计入排名…", key: "hide" },
]
function onMenu(key: string, row: RankRow) {
if (key === "submissions") emit("submissions", row.user.username)
else if (key === "analysis") emit("analysis", row.user.username)
else emit("hide", row)
}
</script>
@@ -165,9 +168,9 @@ function onMenu(key: string, row: RankRow) {
<button class="who" @click="emit('open', item.row.user.username)">
<UserName :username="item.row.user.username" />
</button>
<span v-if="item.row.user.id === chaseId" class="tag chase">要追</span>
<span v-else-if="item.row.user.id === threatId" class="tag threat">追你</span>
<span v-else-if="item.row.user.id === hotId" class="tag hot">冲得最猛</span>
<span v-if="item.row.user.id === chaseId" class="tag chase">前一名</span>
<span v-else-if="item.row.user.id === threatId" class="tag threat">后一名</span>
<span v-else-if="item.row.user.id === hotId" class="tag hot">进步最大</span>
<span class="solved">{{ item.row.solved }}</span>
<span class="change">
<span v-if="(item.row.change ?? 0) > 0" class="up">↑{{ item.row.change }}</span>
@@ -208,9 +211,9 @@ function onMenu(key: string, row: RankRow) {
<span v-else-if="item.row.change === 0" class="flat">—</span>
</span>
<span class="tag-box">
<span v-if="item.row.user.id === chaseId" class="tag chase">要追</span>
<span v-else-if="item.row.user.id === threatId" class="tag threat">追你</span>
<span v-else-if="item.row.user.id === hotId" class="tag hot">冲得最猛</span>
<span v-if="item.row.user.id === chaseId" class="tag chase">前一名</span>
<span v-else-if="item.row.user.id === threatId" class="tag threat">后一名</span>
<span v-else-if="item.row.user.id === hotId" class="tag hot">进步最大</span>
</span>
<n-dropdown
v-if="teacher"
@@ -4,7 +4,7 @@ import { useThemeVars } from "naive-ui"
import { parseTime } from "utils/functions"
/**
* 我和两个对手每周日晚上的名次。名次越小越好,所以纵轴倒过来(第 1 在最上面)。
* 我和前后一名每周日晚上的名次。名次越小越好,所以纵轴倒过来(第 1 在最上面)。
* 线的末端直接写名字,不要图例 —— 三条线,看名字比对颜色快。
*/
const props = defineProps<{
@@ -88,7 +88,7 @@ const ends = computed(() => {
</script>
<template>
<svg :viewBox="`0 0 ${W} ${H}`" class="chart" role="img" aria-label="我和对手每周的名次">
<svg :viewBox="`0 0 ${W} ${H}`" class="chart" role="img" aria-label="我和前后一名每周的名次">
<g v-for="tick in ticks" :key="tick">
<line :x1="PAD.left" :x2="W - PAD.right" :y1="y(tick)" :y2="y(tick)" class="grid" />
<text :x="PAD.left - 6" :y="y(tick) + 4" text-anchor="end" class="axis">第 {{ tick }}</text>
+84 -20
View File
@@ -18,6 +18,7 @@ import { USERNAME_CLASS_RE } from "utils/constants"
import { parseTime } from "utils/functions"
import ChampionList from "./components/ChampionList.vue"
import ClassBattle from "./components/ClassBattle.vue"
import ClassDetailDrawer from "./components/ClassDetailDrawer.vue"
import RankMe, { type MiniRank } from "./components/RankMe.vue"
import RankPodium from "./components/RankPodium.vue"
import RankTrack from "./components/RankTrack.vue"
@@ -86,8 +87,11 @@ async function load() {
loading.value = true
try {
board.value = await fetchBoard(scope.value, period.value)
if (teacher.value && !teacherClass.value && board.value.scope !== "all")
teacherClass.value = board.value.scope === "class" ? (board.value.className ?? "") : ""
// 老师在「全部」时点了本班:后端挑了最近上课的班,回填到下拉框(这一下不用再取一遍)
if (teacher.value && !teacherClass.value && board.value.scope === "class") {
backfilling = true
teacherClass.value = board.value.className ?? ""
}
} catch {
board.value = null
} finally {
@@ -131,6 +135,9 @@ async function expand() {
}
}
let backfilling = false
/** 范围、时间、老师选的班:三个一起变(下拉框选班会顺手改范围)也只取一次 */
watch([scope, period, teacherClass], () => {
router.replace({
query: {
@@ -140,19 +147,15 @@ watch([scope, period, teacherClass], () => {
class: teacher.value && teacherClass.value ? teacherClass.value : undefined,
},
})
})
watch([scope, period], load)
watch(teacherClass, (value, old) => {
// 后端回填的那一下(空 → 班号)不用再取一遍
if (old || !board.value || board.value.className !== value) {
cache.clear()
load()
}
if (backfilling) backfilling = false
else load()
})
onMounted(async () => {
if (!userStore.user) await userStore.getMyProfile().catch(() => {})
scope.value = pick(route.query.scope, SCOPE_OPTIONS, canScopeClass.value ? "class" : "all")
// 老师默认看全部(用户定的),要看哪个班自己在下拉框里选;学生默认看本班
const fallback = teacher.value ? "all" : canScopeClass.value ? "class" : "all"
scope.value = pick(route.query.scope, SCOPE_OPTIONS, fallback)
if (!canScopeClass.value && scope.value !== "all") scope.value = "all"
loadBattle()
// scope 从 "all" 改成别的会触发 watch 去取;没改就自己取
@@ -160,7 +163,19 @@ onMounted(async () => {
})
/** 班级那几块(走势、这周本班、每周冠军、班级对抗高亮)对的是哪个班 */
const classContext = computed(() => (teacher.value ? teacherClass.value : myClass.value) || null)
const classContext = computed(
() => (teacher.value ? (scope.value === "all" ? "" : teacherClass.value) : myClass.value) || null,
)
/** 老师的班级下拉框:「全部」就是全服,选了班就切到本班(本年级时留在本年级) */
const pickedClass = computed({
get: () => (scope.value === "all" ? "" : teacherClass.value),
set: (value: string) => {
teacherClass.value = value
if (!value) scope.value = "all"
else if (scope.value === "all") scope.value = "class"
},
})
const rows = computed(() => board.value?.rows ?? [])
const podium = computed(() => rows.value.filter((row) => row.rank <= 3 && row.solved > 0))
@@ -239,7 +254,10 @@ const teacherClassOptions = computed(() => {
const list = configStore.config?.classList ?? []
const all =
teacherClass.value && !list.includes(teacherClass.value) ? [teacherClass.value, ...list] : list
return all.map((name) => ({ label: classLabel(name), value: name }))
return [
{ label: "全部", value: "" },
...all.map((name) => ({ label: classLabel(name), value: name })),
]
})
const myBattle = computed(() => battle.value.find((item) => item.className === classContext.value))
@@ -250,7 +268,7 @@ const daysLeft = computed(() => {
return Math.max(1, Math.ceil((Date.parse(start) + 7 * 86_400_000 - Date.now()) / 86_400_000))
})
/** 走势图三条线:在追你的、你要追的、你(画在最上面) */
/** 走势图三条线:后一名、前一名、你(画在最上面) */
const trendLines = computed(() => {
const b = board.value
if (!b?.me) return []
@@ -287,10 +305,23 @@ watch(phoneTabs, (tabs) => {
if (!tabs.some((tab) => tab.key === phoneTab.value)) phoneTab.value = "battle"
})
/** 班级详情(+ 老师的 AI 分析):点班级对抗里的班名、或老师「这个班」卡上的按钮 */
const detailClass = ref<string | null>(null)
const showDetail = ref(false)
function openDetail(className: string) {
detailClass.value = className
showDetail.value = true
}
function open(username: string) {
router.push({ path: "/user", query: { name: username } })
}
/** 老师看某个学生的智能分析(那一页本来就支持 ?username=) */
function openAnalysis(username: string) {
router.push({ path: "/ai-analysis", query: { username } })
}
function openSubmissions(username: string) {
router.push({ path: "/submission", query: { username, exactUsername: "1" } })
}
@@ -350,8 +381,8 @@ async function restore(userId: number) {
</button>
</div>
<n-select
v-if="teacher && scope !== 'all'"
v-model:value="teacherClass"
v-if="teacher"
v-model:value="pickedClass"
class="class-select"
size="small"
filterable
@@ -373,8 +404,8 @@ async function restore(userId: number) {
<div class="spacer" />
<span v-if="!teacher && me" class="legend">
<span><i :style="{ background: palette.me }" />你</span>
<span v-if="board.ahead"><i :style="{ background: palette.chase }" />你要追的</span>
<span v-if="board.behind"><i :style="{ background: palette.threat }" />在追你的</span>
<span v-if="board.ahead"><i :style="{ background: palette.chase }" />前一名</span>
<span v-if="board.behind"><i :style="{ background: palette.threat }" />后一名</span>
<span v-if="mateClass"><i :style="{ background: palette.mate }" />你们班</span>
</span>
</div>
@@ -402,6 +433,7 @@ async function restore(userId: number) {
@open="open"
@expand="expand"
@submissions="openSubmissions"
@analysis="openAnalysis"
@hide="hide"
/>
</template>
@@ -438,6 +470,18 @@ async function restore(userId: number) {
<span>这周人均涨</span><b>+{{ myBattle.weekGain }}</b>
</div>
</div>
<div class="actions">
<n-button
v-if="classContext"
size="small"
secondary
type="info"
@click="openDetail(classContext)"
>
班级详情 · AI 分析
</n-button>
<n-button size="small" secondary @click="router.push('/class')">班级 PK</n-button>
</div>
<div class="hidden-list">
<span class="hidden-title">不计入排名的人</span>
<template v-if="board.scope === 'class'">
@@ -457,13 +501,20 @@ async function restore(userId: number) {
<span class="muted">做对 1 道新题就能上榜</span>
</div>
</template>
<ClassBattle v-if="isDesktop" :items="battle" :mine="classContext" :teacher="teacher" />
<ClassBattle
v-if="isDesktop"
:items="battle"
:mine="classContext"
:teacher="teacher"
:pk="userStore.isAdminRole"
@pick="openDetail"
/>
</aside>
</div>
<div v-if="isDesktop && classContext" class="below">
<div v-if="board?.trend && me" class="card">
<div class="card-title">
<b>我和对手的名次</b><span class="muted">每周日晚上 · 越高越好</span>
<b>我和前后一名的名次</b><span class="muted">每周日晚上 · 越高越好</span>
</div>
<RankTrend :trend="board.trend" :lines="trendLines" />
</div>
@@ -509,6 +560,8 @@ async function restore(userId: number) {
:items="battle"
:mine="classContext"
:teacher="teacher"
:pk="userStore.isAdminRole"
@pick="openDetail"
/>
<RankTrend
v-else-if="phoneTab === 'trend' && board?.trend"
@@ -529,6 +582,12 @@ async function restore(userId: number) {
/>
</div>
</div>
<ClassDetailDrawer
v-model:show="showDetail"
:class-name="detailClass"
:teacher="teacher"
:pk="userStore.isAdminRole"
/>
</div>
</template>
@@ -709,6 +768,11 @@ async function restore(userId: number) {
font-variant-numeric: tabular-nums;
}
.actions {
display: flex;
gap: 8px;
}
.hidden-list {
display: flex;
flex-direction: column;
+2 -2
View File
@@ -1,8 +1,8 @@
import { useDark } from "@vueuse/core"
/**
* 排名页的几种颜色(设计稿「排名重设计」G1)。用户说「颜色可以保留」:你绿、你要追的蓝、
* 在追你的橙、本年级 / 全服里你们班的人浅绿,其余灰;前三名金银铜。
* 排名页的几种颜色(设计稿「排名重设计」G1)。用户说「颜色可以保留」:你绿、前一名蓝、
* 后一名橙、本年级 / 全服里你们班的人浅绿,其余灰;前三名金银铜。
* 暗色下灰条和浅绿换深一档,不然在暗底上发白。
*/
export function useRankPalette() {
+1 -1
View File
@@ -53,7 +53,7 @@ export function toPass(me: RankRow, target: RankRow) {
return target.solved - me.solved + 1
}
/** 这张榜上升得最猛的人(至少升 3 名才算),给他挂「冲得最猛」 */
/** 这张榜上升得最多的人(至少升 3 名才算),给他挂「进步最大」 */
export function hottest(rows: RankRow[]) {
let best: RankRow | null = null
for (const row of rows) {