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

+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;